Split HookContext and HookCommandContext to prevent deadlocks

HookContext (all events):
- isIdle() - read-only state check
- hasQueuedMessages() - read-only state check
- abort() - fire-and-forget, does not wait

HookCommandContext (slash commands only):
- waitForIdle() - waits for agent to finish
- newSession(options?) - create new session
- branch(entryId) - branch from entry
- navigateTree(targetId, options?) - navigate session tree

Session control methods moved from HookAPI (pi.*) to HookCommandContext (ctx.*)
because they can deadlock when called from event handlers that run inside
the agent loop (tool_call, tool_result, context events).
This commit is contained in:
Mario Zechner 2026-01-02 00:24:58 +01:00
parent ccdd7bd283
commit 0d9fddec1e
9 changed files with 170 additions and 203 deletions

View file

@ -62,7 +62,7 @@ export type SendMessageHandler = <T = unknown>(
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* New session handler type for pi.newSession().
* New session handler type for ctx.newSession() in HookCommandContext.
*/
export type NewSessionHandler = (options?: {
parentSession?: string;
@ -70,12 +70,12 @@ export type NewSessionHandler = (options?: {
}) => Promise<{ cancelled: boolean }>;
/**
* Branch handler type for pi.branch().
* Branch handler type for ctx.branch() in HookCommandContext.
*/
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
/**
* Navigate tree handler type for pi.navigateTree().
* Navigate tree handler type for ctx.navigateTree() in HookCommandContext.
*/
export type NavigateTreeHandler = (
targetId: string,
@ -100,12 +100,6 @@ export interface LoadedHook {
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
/** Set the new session handler for this hook's pi.newSession() */
setNewSessionHandler: (handler: NewSessionHandler) => void;
/** Set the branch handler for this hook's pi.branch() */
setBranchHandler: (handler: BranchHandler) => void;
/** Set the navigate tree handler for this hook's pi.navigateTree() */
setNavigateTreeHandler: (handler: NavigateTreeHandler) => void;
}
/**
@ -165,9 +159,6 @@ function createHookAPI(
commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setNewSessionHandler: (handler: NewSessionHandler) => void;
setBranchHandler: (handler: BranchHandler) => void;
setNavigateTreeHandler: (handler: NavigateTreeHandler) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
@ -175,18 +166,6 @@ function createHookAPI(
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
let newSessionHandler: NewSessionHandler = async () => {
// Default no-op until mode sets the handler
return { cancelled: false };
};
let branchHandler: BranchHandler = async () => {
// Default no-op until mode sets the handler
return { cancelled: false };
};
let navigateTreeHandler: NavigateTreeHandler = async () => {
// Default no-op until mode sets the handler
return { cancelled: false };
};
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
@ -213,15 +192,6 @@ function createHookAPI(
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
newSession(options) {
return newSessionHandler(options);
},
branch(entryId) {
return branchHandler(entryId);
},
navigateTree(targetId, options) {
return navigateTreeHandler(targetId, options);
},
} as HookAPI;
return {
@ -234,15 +204,6 @@ function createHookAPI(
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
setNewSessionHandler: (handler: NewSessionHandler) => {
newSessionHandler = handler;
},
setBranchHandler: (handler: BranchHandler) => {
branchHandler = handler;
},
setNavigateTreeHandler: (handler: NavigateTreeHandler) => {
navigateTreeHandler = handler;
},
};
}
@ -270,16 +231,10 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const {
api,
messageRenderers,
commands,
setSendMessageHandler,
setAppendEntryHandler,
setNewSessionHandler,
setBranchHandler,
setNavigateTreeHandler,
} = createHookAPI(handlers, cwd);
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
handlers,
cwd,
);
// Call factory to register handlers
factory(api);
@ -293,9 +248,6 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
commands,
setSendMessageHandler,
setAppendEntryHandler,
setNewSessionHandler,
setBranchHandler,
setNavigateTreeHandler,
},
error: null,
};