diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 08c523b9..374901ba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,6 +35,10 @@ The hooks API has been restructured with more granular events and better session - New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context) - New `pi.registerCommand(name, options)` for custom slash commands - 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.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 diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 7c272497..9da0dc15 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -540,6 +540,44 @@ if (ctx.model) { } ``` +### ctx.isIdle() + +Returns `true` if the agent is not currently streaming. Useful for hooks that need to wait or check state: + +```typescript +if (ctx.isIdle()) { + // Agent is not processing +} +``` + +### ctx.waitForIdle() + +Wait for the agent to finish streaming: + +```typescript +await ctx.waitForIdle(); +// Agent is now idle +``` + +### ctx.abort() + +Abort the current agent operation: + +```typescript +await ctx.abort(); +``` + +### ctx.hasQueuedMessages() + +Check if there are messages queued (user typed while agent was streaming): + +```typescript +if (ctx.hasQueuedMessages()) { + // Skip interactive prompt, let queued message take over + return; +} +``` + ## HookAPI Methods ### pi.on(event, handler) @@ -655,6 +693,47 @@ 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 64aab64b..7446b7c7 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -558,6 +558,10 @@ export class AgentSession { sessionManager: this.sessionManager, modelRegistry: this._modelRegistry, model: this.model, + isIdle: () => !this.isStreaming, + waitForIdle: () => this.agent.waitForIdle(), + abort: () => this.abort(), + hasQueuedMessages: () => this.queuedMessageCount > 0, }; try { diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 3ac44b27..bfd1a6f5 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; import type { HookMessage } from "../messages.js"; +import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; @@ -60,6 +61,27 @@ export type SendMessageHandler = ( */ export type AppendEntryHandler = (customType: string, data?: T) => void; +/** + * New session handler type for pi.newSession(). + */ +export type NewSessionHandler = (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; +}) => Promise<{ cancelled: boolean }>; + +/** + * Branch handler type for pi.branch(). + */ +export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>; + +/** + * Navigate tree handler type for pi.navigateTree(). + */ +export type NavigateTreeHandler = ( + targetId: string, + options?: { summarize?: boolean }, +) => Promise<{ cancelled: boolean }>; + /** * Registered handlers for a loaded hook. */ @@ -78,6 +100,12 @@ 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; } /** @@ -126,7 +154,7 @@ function resolveHookPath(hookPath: string, cwd: string): string { /** * Create a HookAPI instance that collects handlers, renderers, and commands. - * Returns the API, maps, and a function to set the send message handler later. + * Returns the API, maps, and functions to set handlers later. */ function createHookAPI( handlers: Map, @@ -137,6 +165,9 @@ 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 @@ -144,6 +175,18 @@ 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(); @@ -170,6 +213,15 @@ 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 { @@ -182,6 +234,15 @@ function createHookAPI( setAppendEntryHandler: (handler: AppendEntryHandler) => { appendEntryHandler = handler; }, + setNewSessionHandler: (handler: NewSessionHandler) => { + newSessionHandler = handler; + }, + setBranchHandler: (handler: BranchHandler) => { + branchHandler = handler; + }, + setNavigateTreeHandler: (handler: NavigateTreeHandler) => { + navigateTreeHandler = handler; + }, }; } @@ -209,10 +270,16 @@ 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 } = createHookAPI( - handlers, - cwd, - ); + const { + api, + messageRenderers, + commands, + setSendMessageHandler, + setAppendEntryHandler, + setNewSessionHandler, + setBranchHandler, + setNavigateTreeHandler, + } = createHookAPI(handlers, cwd); // Call factory to register handlers factory(api); @@ -226,6 +293,9 @@ 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 12624098..798d30de 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -7,7 +7,14 @@ import type { Model } from "@mariozechner/pi-ai"; import { theme } from "../../modes/interactive/theme/theme.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; -import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; +import type { + AppendEntryHandler, + BranchHandler, + LoadedHook, + NavigateTreeHandler, + NewSessionHandler, + SendMessageHandler, +} from "./loader.js"; import type { BeforeAgentStartEvent, BeforeAgentStartEventResult, @@ -61,6 +68,10 @@ export class HookRunner { private modelRegistry: ModelRegistry; private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; + private isIdleFn: () => boolean = () => true; + private waitForIdleFn: () => Promise = async () => {}; + private abortFn: () => Promise = async () => {}; + private hasQueuedMessagesFn: () => boolean = () => false; constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) { this.hooks = hooks; @@ -82,15 +93,42 @@ export class HookRunner { sendMessageHandler: SendMessageHandler; /** Handler for hooks to append entries */ appendEntryHandler: AppendEntryHandler; + /** Handler for hooks to create new sessions */ + newSessionHandler?: NewSessionHandler; + /** Handler for hooks to branch sessions */ + branchHandler?: BranchHandler; + /** Handler for hooks to navigate the session tree */ + navigateTreeHandler?: NavigateTreeHandler; + /** Function to check if agent is idle */ + isIdle?: () => boolean; + /** Function to wait for agent to be idle */ + waitForIdle?: () => Promise; + /** Function to abort current operation */ + abort?: () => Promise; + /** Function to check if there are queued messages */ + hasQueuedMessages?: () => boolean; /** UI context for interactive prompts */ uiContext?: HookUIContext; /** Whether UI is available */ hasUI?: boolean; }): void { this.getModel = options.getModel; + this.isIdleFn = options.isIdle ?? (() => true); + this.waitForIdleFn = options.waitForIdle ?? (async () => {}); + this.abortFn = options.abort ?? (async () => {}); + this.hasQueuedMessagesFn = options.hasQueuedMessages ?? (() => false); 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; @@ -203,6 +241,10 @@ export class HookRunner { sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, model: this.getModel(), + isIdle: () => this.isIdleFn(), + waitForIdle: () => this.waitForIdleFn(), + abort: () => this.abortFn(), + hasQueuedMessages: () => this.hasQueuedMessagesFn(), }; } @@ -211,15 +253,9 @@ export class HookRunner { */ private isSessionBeforeEvent( type: string, - ): type is - | "session_before_switch" - | "session_before_new" - | "session_before_branch" - | "session_before_compact" - | "session_before_tree" { + ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" { return ( type === "session_before_switch" || - type === "session_before_new" || type === "session_before_branch" || type === "session_before_compact" || type === "session_before_tree" diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 3f263ab6..d628799e 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,7 +13,13 @@ import type { CompactionPreparation, CompactionResult } from "../compaction/inde import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js"; +import type { + BranchSummaryEntry, + CompactionEntry, + ReadonlySessionManager, + SessionEntry, + SessionManager, +} from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { @@ -140,6 +146,14 @@ export interface HookContext { modelRegistry: ModelRegistry; /** Current model (may be undefined if no model is selected yet) */ 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(): Promise; + /** Whether there are queued messages waiting to be processed */ + hasQueuedMessages(): boolean; } // ============================================================================ @@ -673,6 +687,45 @@ 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 e24cd6e7..12b07020 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -345,6 +345,11 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa const commands = new Map(); let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {}; + let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); + let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); + let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ + cancelled: false, + }); const api = { on: (event: string, handler: (...args: unknown[]) => Promise) => { @@ -364,6 +369,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa registerCommand: (name: string, options: any) => { commands.set(name, { name, ...options }); }, + newSession: (options?: any) => newSessionHandler(options), + branch: (entryId: string) => branchHandler(entryId), + navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), }; def.factory(api as any); @@ -380,6 +388,15 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { appendEntryHandler = handler; }, + setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => { + newSessionHandler = handler; + }, + setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => { + branchHandler = handler; + }, + setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { + navigateTreeHandler = handler; + }, }; }); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9a63672a..d6fe474b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -412,6 +412,72 @@ export class InteractiveMode { appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, + newSessionHandler: async (options) => { + // Stop any loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + + // Create new session + const success = await this.session.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } + + // Call setup callback if provided + if (options?.setup) { + await options.setup(this.sessionManager); + } + + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); + this.ui.requestRender(); + + return { cancelled: false }; + }, + branchHandler: async (entryId) => { + const result = await this.session.branch(entryId); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + this.editor.setText(result.selectedText); + this.showStatus("Branched to new session"); + + return { cancelled: false }; + }, + navigateTreeHandler: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + + return { cancelled: false }; + }, + isIdle: () => !this.session.isStreaming, + waitForIdle: () => this.session.agent.waitForIdle(), + abort: () => this.session.abort(), + hasQueuedMessages: () => this.session.queuedMessageCount > 0, uiContext, hasUI: true, }); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d5bcbb15..d08984a6 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -80,6 +80,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setNewSessionHandler: () => {}, + setBranchHandler: () => {}, + setNavigateTreeHandler: () => {}, }; } @@ -266,6 +269,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setNewSessionHandler: () => {}, + setBranchHandler: () => {}, + setNavigateTreeHandler: () => {}, }; createSession([throwingHook]); @@ -313,6 +319,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setNewSessionHandler: () => {}, + setBranchHandler: () => {}, + setNavigateTreeHandler: () => {}, }; const hook2: LoadedHook = { @@ -342,6 +351,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, + setNewSessionHandler: () => {}, + setBranchHandler: () => {}, + setNavigateTreeHandler: () => {}, }; createSession([hook1, hook2]);