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

@ -542,7 +542,7 @@ if (ctx.model) {
### ctx.isIdle()
Returns `true` if the agent is not currently streaming. Useful for hooks that need to wait or check state:
Returns `true` if the agent is not currently streaming:
```typescript
if (ctx.isIdle()) {
@ -550,18 +550,9 @@ if (ctx.isIdle()) {
}
```
### ctx.waitForIdle()
Wait for the agent to finish streaming:
```typescript
await ctx.waitForIdle();
// Agent is now idle
```
### ctx.abort()
Abort the current agent operation:
Abort the current agent operation (fire-and-forget, does not wait):
```typescript
await ctx.abort();
@ -578,6 +569,62 @@ if (ctx.hasQueuedMessages()) {
}
```
## HookCommandContext (Slash Commands Only)
Slash command handlers receive `HookCommandContext`, which extends `HookContext` with session control methods. These methods are only safe in user-initiated commands because they can cause deadlocks if called from event handlers (which run inside the agent loop).
### ctx.waitForIdle()
Wait for the agent to finish streaming:
```typescript
await ctx.waitForIdle();
// Agent is now idle
```
### ctx.newSession(options?)
Create a new session, optionally with initialization:
```typescript
const result = await ctx.newSession({
parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
setup: async (sm) => {
// Initialize the new session
sm.appendMessage({
role: "user",
content: [{ type: "text", text: "Context from previous session..." }],
timestamp: Date.now(),
});
},
});
if (result.cancelled) {
// A hook cancelled the new session
}
```
### ctx.branch(entryId)
Branch from a specific entry, creating a new session file:
```typescript
const result = await ctx.branch("entry-id-123");
if (!result.cancelled) {
// Now in the branched session
}
```
### ctx.navigateTree(targetId, options?)
Navigate to a different point in the session tree:
```typescript
const result = await ctx.navigateTree("entry-id-456", {
summarize: true, // Summarize the abandoned branch
});
```
## HookAPI Methods
### pi.on(event, handler)
@ -693,47 +740,6 @@ const result = await pi.exec("git", ["status"], {
// result.stdout, result.stderr, result.code, result.killed
```
### pi.newSession(options?)
Start a new session, optionally with a setup callback to initialize it:
```typescript
await pi.newSession({
parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
setup: async (sessionManager) => {
// sessionManager is writable, can append messages
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "Context from previous session..." }]
});
}
});
```
Returns `{ cancelled: boolean }` - cancelled if a `session_before_switch` hook cancelled.
### pi.branch(entryId)
Branch from a specific entry, creating a new session file:
```typescript
const result = await pi.branch(entryId);
if (!result.cancelled) {
// Branched successfully
}
```
### pi.navigateTree(targetId, options?)
Navigate to a different point in the session tree (in-place):
```typescript
const result = await pi.navigateTree(targetId, { summarize: true });
if (!result.cancelled) {
// Navigated successfully
}
```
## Examples
### Permission Gate