diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 374901ba..58b128e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,7 +20,7 @@ The hooks API has been restructured with more granular events and better session **Type renames:** - `HookEventContext` → `HookContext` -- `HookCommandContext` removed (use `HookContext` for command handlers) +- `HookCommandContext` is now a new interface extending `HookContext` with session control methods **Event changes:** - The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown` @@ -33,12 +33,9 @@ The hooks API has been restructured with more granular events and better session **API changes:** - `pi.send(text, attachments?)` → `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`) - New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context) -- New `pi.registerCommand(name, options)` for custom slash commands +- New `pi.registerCommand(name, options)` for custom slash commands (handler receives `HookCommandContext`) - New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering -- New `pi.newSession(options?)` to create new sessions with optional setup callback -- New `pi.branch(entryId)` to branch from a specific entry -- New `pi.navigateTree(targetId, options?)` to navigate the session tree -- New `ctx.isIdle()`, `ctx.waitForIdle()`, `ctx.abort()`, `ctx.hasQueuedMessages()` for agent state access +- New `ctx.isIdle()`, `ctx.abort()`, `ctx.hasQueuedMessages()` for agent state (available in all events) - New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus - New `ctx.ui.setStatus(key, text)` for persistent status text in footer (multiple hooks can set their own) - New `ctx.ui.theme` getter for styling text with theme colors @@ -46,6 +43,14 @@ The hooks API has been restructured with more granular events and better session - `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()` - New `ctx.modelRegistry` and `ctx.model` for API key resolution +**HookCommandContext (slash commands only):** +- `ctx.waitForIdle()` - wait for agent to finish streaming +- `ctx.newSession(options?)` - create new sessions with optional setup callback +- `ctx.branch(entryId)` - branch from a specific entry +- `ctx.navigateTree(targetId, options?)` - navigate the session tree + +These methods are only on `HookCommandContext` (not `HookContext`) because they can deadlock if called from event handlers that run inside the agent loop. + **Removed:** - `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort) - `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 9da0dc15..50395e3f 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 7446b7c7..aaec0ee0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -30,7 +30,6 @@ import { import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html/index.js"; import type { - HookContext, HookRunner, SessionBeforeBranchResult, SessionBeforeCompactResult, @@ -545,24 +544,8 @@ export class AgentSession { const command = this._hookRunner.getCommand(commandName); if (!command) return false; - // Get UI context from hook runner (set by mode) - const uiContext = this._hookRunner.getUIContext(); - if (!uiContext) return false; - - // Build command context - const cwd = process.cwd(); - const ctx: HookContext = { - ui: uiContext, - hasUI: this._hookRunner.getHasUI(), - cwd, - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, - model: this.model, - isIdle: () => !this.isStreaming, - waitForIdle: () => this.agent.waitForIdle(), - abort: () => this.abort(), - hasQueuedMessages: () => this.queuedMessageCount > 0, - }; + // Get command context from hook runner (includes session control methods) + const ctx = this._hookRunner.createCommandContext(); try { await command.handler(args, ctx); diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index cb0806ed..a6a2172a 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -3,8 +3,11 @@ export { discoverAndLoadHooks, loadHooks, type AppendEntryHandler, + type BranchHandler, type LoadedHook, type LoadHooksResult, + type NavigateTreeHandler, + type NewSessionHandler, type SendMessageHandler, } from "./loader.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index bfd1a6f5..f876aed9 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -62,7 +62,7 @@ export type SendMessageHandler = ( export type AppendEntryHandler = (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; 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(); const commands = new Map(); @@ -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(); - 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, }; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 798d30de..2c029285 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -20,6 +20,7 @@ import type { BeforeAgentStartEventResult, ContextEvent, ContextEventResult, + HookCommandContext, HookContext, HookError, HookEvent, @@ -72,6 +73,9 @@ export class HookRunner { private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => Promise = async () => {}; private hasQueuedMessagesFn: () => boolean = () => false; + private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + private branchHandler: BranchHandler = async () => ({ cancelled: false }); + private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) { this.hooks = hooks; @@ -93,11 +97,11 @@ export class HookRunner { sendMessageHandler: SendMessageHandler; /** Handler for hooks to append entries */ appendEntryHandler: AppendEntryHandler; - /** Handler for hooks to create new sessions */ + /** Handler for creating new sessions (for HookCommandContext) */ newSessionHandler?: NewSessionHandler; - /** Handler for hooks to branch sessions */ + /** Handler for branching sessions (for HookCommandContext) */ branchHandler?: BranchHandler; - /** Handler for hooks to navigate the session tree */ + /** Handler for navigating session tree (for HookCommandContext) */ navigateTreeHandler?: NavigateTreeHandler; /** Function to check if agent is idle */ isIdle?: () => boolean; @@ -117,18 +121,20 @@ export class HookRunner { this.waitForIdleFn = options.waitForIdle ?? (async () => {}); this.abortFn = options.abort ?? (async () => {}); this.hasQueuedMessagesFn = options.hasQueuedMessages ?? (() => false); + // Store session handlers for HookCommandContext + if (options.newSessionHandler) { + this.newSessionHandler = options.newSessionHandler; + } + if (options.branchHandler) { + this.branchHandler = options.branchHandler; + } + if (options.navigateTreeHandler) { + this.navigateTreeHandler = options.navigateTreeHandler; + } + // Set per-hook handlers for pi.sendMessage() and pi.appendEntry() for (const hook of this.hooks) { hook.setSendMessageHandler(options.sendMessageHandler); hook.setAppendEntryHandler(options.appendEntryHandler); - if (options.newSessionHandler) { - hook.setNewSessionHandler(options.newSessionHandler); - } - if (options.branchHandler) { - hook.setBranchHandler(options.branchHandler); - } - if (options.navigateTreeHandler) { - hook.setNavigateTreeHandler(options.navigateTreeHandler); - } } this.uiContext = options.uiContext ?? noOpUIContext; this.hasUI = options.hasUI ?? false; @@ -242,12 +248,25 @@ export class HookRunner { modelRegistry: this.modelRegistry, model: this.getModel(), isIdle: () => this.isIdleFn(), - waitForIdle: () => this.waitForIdleFn(), abort: () => this.abortFn(), hasQueuedMessages: () => this.hasQueuedMessagesFn(), }; } + /** + * Create the command context for slash command handlers. + * Extends HookContext with session control methods that are only safe in commands. + */ + createCommandContext(): HookCommandContext { + return { + ...this.createContext(), + waitForIdle: () => this.waitForIdleFn(), + newSession: (options) => this.newSessionHandler(options), + branch: (entryId) => this.branchHandler(entryId), + navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), + }; + } + /** * Check if event type is a session "before_*" event that can be cancelled. */ diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index d628799e..4910fe6f 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -131,7 +131,8 @@ export interface HookUIContext { } /** - * Context passed to hook event and command handlers. + * Context passed to hook event handlers. + * For command handlers, see HookCommandContext which extends this with session control methods. */ export interface HookContext { /** UI methods for user interaction */ @@ -148,14 +149,63 @@ export interface HookContext { model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; - /** Wait for the agent to finish streaming */ - waitForIdle(): Promise; - /** Abort the current agent operation */ + /** Abort the current agent operation (fire-and-forget, does not wait) */ abort(): Promise; /** Whether there are queued messages waiting to be processed */ hasQueuedMessages(): boolean; } +/** + * Extended context for slash command handlers. + * Includes session control methods that are only safe in user-initiated commands. + * + * These methods are not available in event handlers because they can cause + * deadlocks when called from within the agent loop (e.g., tool_call, context events). + */ +export interface HookCommandContext extends HookContext { + /** Wait for the agent to finish streaming */ + waitForIdle(): Promise; + + /** + * Start a new session, optionally with a setup callback to initialize it. + * The setup callback receives a writable SessionManager for the new session. + * + * @param options.parentSession - Path to parent session for lineage tracking + * @param options.setup - Async callback to initialize the new session (e.g., append messages) + * @returns Object with `cancelled: true` if a hook cancelled the new session + * + * @example + * // Handoff: summarize current session and start fresh with context + * await ctx.newSession({ + * parentSession: ctx.sessionManager.getSessionFile(), + * setup: async (sm) => { + * sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] }); + * } + * }); + */ + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + }): Promise<{ cancelled: boolean }>; + + /** + * Branch from a specific entry, creating a new session file. + * + * @param entryId - ID of the entry to branch from + * @returns Object with `cancelled: true` if a hook cancelled the branch + */ + branch(entryId: string): Promise<{ cancelled: boolean }>; + + /** + * Navigate to a different point in the session tree (in-place). + * + * @param targetId - ID of the entry to navigate to + * @param options.summarize - Whether to summarize the abandoned branch + * @returns Object with `cancelled: true` if a hook cancelled the navigation + */ + navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; +} + // ============================================================================ // Session Events // ============================================================================ @@ -588,7 +638,7 @@ export interface RegisteredCommand { name: string; description?: string; - handler: (args: string, ctx: HookContext) => Promise; + handler: (args: string, ctx: HookCommandContext) => Promise; } /** @@ -678,7 +728,7 @@ export interface HookAPI { /** * Register a custom slash command. - * Handler receives HookCommandContext. + * Handler receives HookCommandContext with session control methods. */ registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; @@ -687,45 +737,6 @@ export interface HookAPI { * Supports timeout and abort signal. */ exec(command: string, args: string[], options?: ExecOptions): Promise; - - /** - * Start a new session, optionally with a setup callback to initialize it. - * The setup callback receives a writable SessionManager for the new session. - * - * @param options.parentSession - Path to parent session for lineage tracking - * @param options.setup - Async callback to initialize the new session (e.g., append messages) - * @returns Object with `cancelled: true` if a hook cancelled the new session - * - * @example - * // Handoff: summarize current session and start fresh with context - * await pi.newSession({ - * parentSession: ctx.sessionManager.getSessionFile(), - * setup: async (sm) => { - * sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] }); - * } - * }); - */ - newSession(options?: { - parentSession?: string; - setup?: (sessionManager: SessionManager) => Promise; - }): Promise<{ cancelled: boolean }>; - - /** - * Branch from a specific entry, creating a new session file. - * - * @param entryId - ID of the entry to branch from - * @returns Object with `cancelled: true` if a hook cancelled the branch - */ - branch(entryId: string): Promise<{ cancelled: boolean }>; - - /** - * Navigate to a different point in the session tree (in-place). - * - * @param targetId - ID of the entry to navigate to - * @param options.summarize - Whether to summarize the abandoned branch - * @returns Object with `cancelled: true` if a hook cancelled the navigation - */ - navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 12b07020..5ab85ef6 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -140,7 +140,7 @@ export interface CreateAgentSessionResult { // Re-exports export type { CustomTool } from "./custom-tools/types.js"; -export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js"; +export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { FileSlashCommand } from "./slash-commands.js"; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d08984a6..d5bcbb15 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -80,9 +80,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setNewSessionHandler: () => {}, - setBranchHandler: () => {}, - setNavigateTreeHandler: () => {}, }; } @@ -269,9 +266,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setNewSessionHandler: () => {}, - setBranchHandler: () => {}, - setNavigateTreeHandler: () => {}, }; createSession([throwingHook]); @@ -319,9 +313,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setNewSessionHandler: () => {}, - setBranchHandler: () => {}, - setNavigateTreeHandler: () => {}, }; const hook2: LoadedHook = { @@ -351,9 +342,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, - setNewSessionHandler: () => {}, - setBranchHandler: () => {}, - setNavigateTreeHandler: () => {}, }; createSession([hook1, hook2]);