diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 087e8ac3..e0b7e4b7 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3223,9 +3223,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.24, - output: 0.38, - cacheRead: 0.02, + input: 0.23900000000000002, + output: 0.378, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 163840, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index abb1d474..5bb586a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking + +- **Session hooks API redesign**: Merged `branch` event into `session` event. `BranchEvent`, `BranchEventResult` types and `pi.on("branch", ...)` removed. Use `pi.on("session", ...)` with `reason: "before_branch" | "branch"` instead. `AgentSession.branch()` returns `{ cancelled }` instead of `{ skipped }`. `AgentSession.reset()` and `switchSession()` now return `boolean` (false if cancelled by hook). RPC commands `reset`, `switch_session`, and `branch` now include `cancelled` in response data. ([#278](https://github.com/badlogic/pi-mono/issues/278)) + +### Added + +- **Session lifecycle hooks**: Added `before_*` variants (`before_switch`, `before_clear`, `before_branch`) that fire before actions and can be cancelled with `{ cancel: true }`. Added `shutdown` reason for graceful exit handling. ([#278](https://github.com/badlogic/pi-mono/issues/278)) + ### Fixed - **File tab completion display**: File paths no longer get cut off early. Folders now show trailing `/` and removed redundant "directory"/"file" labels to maximize horizontal space. ([#280](https://github.com/badlogic/pi-mono/issues/280)) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 84192330..89936ced 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -117,51 +117,55 @@ user sends another prompt ◄───────────────── user branches (/branch) │ - ├─► branch (BEFORE branch, can control) - └─► session (reason: "switch", AFTER branch) + ├─► session (reason: "before_branch", can cancel) + └─► session (reason: "branch", AFTER branch) user switches session (/session) │ - └─► session (reason: "switch") + ├─► session (reason: "before_switch", can cancel) + └─► session (reason: "switch", AFTER switch) user clears session (/clear) │ - └─► session (reason: "clear") + ├─► session (reason: "before_clear", can cancel) + └─► session (reason: "clear", AFTER clear) + +user exits (double Ctrl+C or Ctrl+D) + │ + └─► session (reason: "shutdown") ``` A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools. ### session -Fired on startup and when session changes. +Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`. ```typescript pi.on("session", async (event, ctx) => { // event.entries: SessionEntry[] - all session entries // event.sessionFile: string | null - current session file (null with --no-session) // event.previousSessionFile: string | null - previous session file - // event.reason: "start" | "switch" | "clear" + // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" | + // "before_branch" | "branch" | "shutdown" + // event.targetTurnIndex: number - only for "before_branch" and "branch" + + // Cancel a before_* action: + if (event.reason === "before_clear") { + return { cancel: true }; + } + // No return needed if not cancelling }); ``` **Reasons:** - `start`: Initial session load on startup -- `switch`: User switched sessions (`/session`) or branched (`/branch`) -- `clear`: User cleared the session (`/clear`) +- `before_switch` / `switch`: User switched sessions (`/session`) +- `before_clear` / `clear`: User cleared the session (`/clear`) +- `before_branch` / `branch`: User branched the session (`/branch`) +- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM) -### branch - -Fired BEFORE a branch happens. Can control branch behavior. - -```typescript -pi.on("branch", async (event, ctx) => { - // event.targetTurnIndex: number - // event.entries: SessionEntry[] - return { skipConversationRestore: true }; // or undefined -}); -``` - -Note: After branch completes, a `session` event fires with `reason: "switch"`. +For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from. ### agent_start / agent_end @@ -544,9 +548,12 @@ export default function (pi: HookAPI) { } }); - pi.on("branch", async (event, ctx) => { + pi.on("session", async (event, ctx) => { + // Only handle before_branch events + if (event.reason !== "before_branch") return; + const ref = checkpoints.get(event.targetTurnIndex); - if (!ref) return undefined; + if (!ref) return; const choice = await ctx.ui.select("Restore code state?", [ "Yes, restore code to that point", @@ -557,8 +564,6 @@ export default function (pi: HookAPI) { await ctx.exec("git", ["stash", "apply", ref]); ctx.ui.notify("Code restored to checkpoint", "info"); } - - return undefined; }); pi.on("agent_end", async () => { @@ -712,17 +717,26 @@ User sends prompt: Branch: -> AgentSession.branch() - -> hookRunner.emit({ type: "branch", ... }) # BEFORE branch - -> [branch happens] - -> hookRunner.emit({ type: "session", reason: "switch", ... }) # AFTER + -> hookRunner.emit({ type: "session", reason: "before_branch", ... }) # can cancel + -> [if not cancelled: branch happens] + -> hookRunner.emit({ type: "session", reason: "branch", ... }) Session switch: -> AgentSession.switchSession() + -> hookRunner.emit({ type: "session", reason: "before_switch", ... }) # can cancel + -> [if not cancelled: switch happens] -> hookRunner.emit({ type: "session", reason: "switch", ... }) Clear: -> AgentSession.reset() + -> hookRunner.emit({ type: "session", reason: "before_clear", ... }) # can cancel + -> [if not cancelled: clear happens] -> hookRunner.emit({ type: "session", reason: "clear", ... }) + +Shutdown (interactive mode): + -> handleCtrlC() or handleCtrlD() + -> hookRunner.emit({ type: "session", reason: "shutdown", ... }) + -> process.exit(0) ``` ## UI Context by Mode diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index cbc1ca5f..d5f25bb5 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -78,7 +78,7 @@ Response: #### reset -Clear context and start a fresh session. +Clear context and start a fresh session. Can be cancelled by a `before_clear` hook. ```json {"type": "reset"} @@ -86,7 +86,12 @@ Clear context and start a fresh session. Response: ```json -{"type": "response", "command": "reset", "success": true} +{"type": "response", "command": "reset", "success": true, "data": {"cancelled": false}} +``` + +If a hook cancelled the reset: +```json +{"type": "response", "command": "reset", "success": true, "data": {"cancelled": true}} ``` ### State @@ -465,7 +470,7 @@ Response: #### switch_session -Load a different session file. +Load a different session file. Can be cancelled by a `before_switch` hook. ```json {"type": "switch_session", "sessionPath": "/path/to/session.jsonl"} @@ -473,12 +478,17 @@ Load a different session file. Response: ```json -{"type": "response", "command": "switch_session", "success": true} +{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}} +``` + +If a hook cancelled the switch: +```json +{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}} ``` #### branch -Create a new branch from a previous user message. Returns the text of the message being branched from. +Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from. ```json {"type": "branch", "entryIndex": 2} @@ -490,7 +500,17 @@ Response: "type": "response", "command": "branch", "success": true, - "data": {"text": "The original prompt text..."} + "data": {"text": "The original prompt text...", "cancelled": false} +} +``` + +If a hook cancelled the branch: +```json +{ + "type": "response", + "command": "branch", + "success": true, + "data": {"text": "The original prompt text...", "cancelled": true} } ``` diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index f043bed9..79e2a6a7 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -16,6 +16,15 @@ Blocks writes to protected paths (.env, .git/, node_modules/). ### file-trigger.ts Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent. +### confirm-destructive.ts +Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events. + +### dirty-repo-guard.ts +Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit. + +### auto-commit-on-exit.ts +Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message. + ## Usage ```bash @@ -38,8 +47,16 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { pi.on("session", async (event, ctx) => { - // event.reason: "start" | "switch" | "clear" + // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" | + // "before_branch" | "branch" | "shutdown" + // event.targetTurnIndex: number (only for before_branch/branch) // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI + + // Cancel before_* actions: + if (event.reason === "before_clear") { + return { cancel: true }; + } + return undefined; }); pi.on("tool_call", async (event, ctx) => { @@ -58,8 +75,7 @@ export default function (pi: HookAPI) { ``` **Available events:** -- `session` - startup, session switch, clear -- `branch` - before branching (can skip conversation restore) +- `session` - lifecycle events with before/after variants (can cancel before_* actions) - `agent_start` / `agent_end` - per user prompt - `turn_start` / `turn_end` - per LLM turn - `tool_call` - before tool execution (can block) diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts new file mode 100644 index 00000000..8d167882 --- /dev/null +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -0,0 +1,50 @@ +/** + * Auto-Commit on Exit Hook + * + * Automatically commits changes when the agent exits. + * Uses the last assistant message to generate a commit message. + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function (pi: HookAPI) { + pi.on("session", async (event, ctx) => { + if (event.reason !== "shutdown") return; + + // Check for uncommitted changes + const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]); + + if (code !== 0 || status.trim().length === 0) { + // Not a git repo or no changes + return; + } + + // Find the last assistant message for commit context + let lastAssistantText = ""; + for (let i = event.entries.length - 1; i >= 0; i--) { + const entry = event.entries[i]; + if (entry.type === "message" && entry.message.role === "assistant") { + const content = entry.message.content; + if (Array.isArray(content)) { + lastAssistantText = content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + break; + } + } + + // Generate a simple commit message + const firstLine = lastAssistantText.split("\n")[0] || "Work in progress"; + const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`; + + // Stage and commit + await ctx.exec("git", ["add", "-A"]); + const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]); + + if (commitCode === 0 && ctx.hasUI) { + ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info"); + } + }); +} diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts new file mode 100644 index 00000000..6273baed --- /dev/null +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -0,0 +1,62 @@ +/** + * Confirm Destructive Actions Hook + * + * Prompts for confirmation before destructive session actions (clear, switch, branch). + * Demonstrates how to cancel session events using the before_* variants. + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function (pi: HookAPI) { + pi.on("session", async (event, ctx) => { + // Only handle before_* events (the ones that can be cancelled) + if (event.reason === "before_clear") { + if (!ctx.hasUI) return; + + const confirmed = await ctx.ui.confirm( + "Clear session?", + "This will delete all messages in the current session.", + ); + + if (!confirmed) { + ctx.ui.notify("Clear cancelled", "info"); + return { cancel: true }; + } + } + + if (event.reason === "before_switch") { + if (!ctx.hasUI) return; + + // Check if there are unsaved changes (messages since last assistant response) + const hasUnsavedWork = event.entries.some( + (e) => e.type === "message" && e.message.role === "user", + ); + + if (hasUnsavedWork) { + const confirmed = await ctx.ui.confirm( + "Switch session?", + "You have messages in the current session. Switch anyway?", + ); + + if (!confirmed) { + ctx.ui.notify("Switch cancelled", "info"); + return { cancel: true }; + } + } + } + + if (event.reason === "before_branch") { + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select( + `Branch from turn ${event.targetTurnIndex}?`, + ["Yes, create branch", "No, stay in current session"], + ); + + if (choice !== "Yes, create branch") { + ctx.ui.notify("Branch cancelled", "info"); + return { cancel: true }; + } + } + }); +} diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts new file mode 100644 index 00000000..a7cbfc45 --- /dev/null +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -0,0 +1,59 @@ +/** + * Dirty Repo Guard Hook + * + * Prevents session changes when there are uncommitted git changes. + * Useful to ensure work is committed before switching context. + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function (pi: HookAPI) { + pi.on("session", async (event, ctx) => { + // Only guard destructive actions + if ( + event.reason !== "before_clear" && + event.reason !== "before_switch" && + event.reason !== "before_branch" + ) { + return; + } + + // Check for uncommitted changes + const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]); + + if (code !== 0) { + // Not a git repo, allow the action + return; + } + + const hasChanges = stdout.trim().length > 0; + if (!hasChanges) { + return; + } + + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { cancel: true }; + } + + // Count changed files + const changedFiles = stdout.trim().split("\n").filter(Boolean).length; + + const action = + event.reason === "before_clear" + ? "clear session" + : event.reason === "before_switch" + ? "switch session" + : "branch"; + + const choice = await ctx.ui.select( + `You have ${changedFiles} uncommitted file(s). ${action} anyway?`, + ["Yes, proceed anyway", "No, let me commit first"], + ); + + if (choice !== "Yes, proceed anyway") { + ctx.ui.notify("Commit your changes first", "warning"); + return { cancel: true }; + } + }); +} diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index eebd9913..f7a73a7f 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -19,13 +19,16 @@ export default function (pi: HookAPI) { } }); - pi.on("branch", async (event, ctx) => { + pi.on("session", async (event, ctx) => { + // Only handle before_branch events + if (event.reason !== "before_branch") return; + const ref = checkpoints.get(event.targetTurnIndex); - if (!ref) return undefined; + if (!ref) return; if (!ctx.hasUI) { // In non-interactive mode, don't restore automatically - return undefined; + return; } const choice = await ctx.ui.select("Restore code state?", [ @@ -37,8 +40,6 @@ export default function (pi: HookAPI) { await ctx.exec("git", ["stash", "apply", ref]); ctx.ui.notify("Code restored to checkpoint", "info"); } - - return undefined; }); pi.on("agent_end", async () => { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6570dc46..54c574da 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -21,7 +21,7 @@ import { type BashResult, executeBash as executeBashCommand } from "./bash-execu import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; +import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; import { getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { loadSessionFromEntries, type SessionManager } from "./session-manager.js"; @@ -501,9 +501,26 @@ export class AgentSession { * Reset agent and session to start fresh. * Clears all messages and starts a new session. * Listeners are preserved and will continue receiving events. + * @returns true if reset completed, false if cancelled by hook */ - async reset(): Promise { + async reset(): Promise { const previousSessionFile = this.sessionFile; + const entries = this.sessionManager.loadEntries(); + + // Emit before_clear event (can be cancelled) + if (this._hookRunner?.hasHandlers("session")) { + const result = (await this._hookRunner.emit({ + type: "session", + entries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "before_clear", + })) as SessionEventResult | undefined; + + if (result?.cancel) { + return false; + } + } this._disconnectFromAgent(); await this.abort(); @@ -526,6 +543,7 @@ export class AgentSession { // Emit session event to custom tools await this._emitToolSessionEvent("clear", previousSessionFile); + return true; } // ========================================================================= @@ -1142,9 +1160,26 @@ export class AgentSession { * Switch to a different session file. * Aborts current operation, loads messages, restores model/thinking. * Listeners are preserved and will continue receiving events. + * @returns true if switch completed, false if cancelled by hook */ - async switchSession(sessionPath: string): Promise { + async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionFile; + const oldEntries = this.sessionManager.loadEntries(); + + // Emit before_switch event (can be cancelled) + if (this._hookRunner?.hasHandlers("session")) { + const result = (await this._hookRunner.emit({ + type: "session", + entries: oldEntries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "before_switch", + })) as SessionEventResult | undefined; + + if (result?.cancel) { + return false; + } + } this._disconnectFromAgent(); await this.abort(); @@ -1191,18 +1226,19 @@ export class AgentSession { } this._reconnectToAgent(); + return true; } /** * Create a branch from a specific entry index. - * Emits branch event to hooks, which can control the branch behavior. + * Emits before_branch/branch session events to hooks. * * @param entryIndex Index into session entries to branch from * @returns Object with: * - selectedText: The text of the selected user message (for editor pre-fill) - * - skipped: True if a hook requested to skip conversation restore + * - cancelled: True if a hook cancelled the branch */ - async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> { + async branch(entryIndex: number): Promise<{ selectedText: string; cancelled: boolean }> { const previousSessionFile = this.sessionFile; const entries = this.sessionManager.loadEntries(); const selectedEntry = entries[entryIndex]; @@ -1213,19 +1249,20 @@ export class AgentSession { const selectedText = this._extractUserMessageText(selectedEntry.message.content); - // Emit branch event to hooks - let hookResult: BranchEventResult | undefined; - if (this._hookRunner?.hasHandlers("branch")) { - hookResult = (await this._hookRunner.emit({ - type: "branch", - targetTurnIndex: entryIndex, + // Emit before_branch event (can be cancelled) + if (this._hookRunner?.hasHandlers("session")) { + const result = (await this._hookRunner.emit({ + type: "session", entries, - })) as BranchEventResult | undefined; - } + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "before_branch", + targetTurnIndex: entryIndex, + })) as SessionEventResult | undefined; - // If hook says skip conversation restore, don't branch - if (hookResult?.skipConversationRestore) { - return { selectedText, skipped: true }; + if (result?.cancel) { + return { selectedText, cancelled: true }; + } } // Create branched session (returns null in --no-session mode) @@ -1240,7 +1277,7 @@ export class AgentSession { const newEntries = this.sessionManager.loadEntries(); const loaded = loadSessionFromEntries(newEntries); - // Emit session event to hooks (in --no-session mode, both files are null) + // Emit branch event to hooks (after branch completes) if (this._hookRunner) { this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ @@ -1248,7 +1285,8 @@ export class AgentSession { entries: newEntries, sessionFile: newSessionFile, previousSessionFile, - reason: "switch", + reason: "branch", + targetTurnIndex: entryIndex, }); } @@ -1257,7 +1295,7 @@ export class AgentSession { this.agent.replaceMessages(loaded.messages); - return { selectedText, skipped: false }; + return { selectedText, cancelled: false }; } /** diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index a2efab7a..db222989 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -5,8 +5,6 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - BranchEvent, - BranchEventResult, CustomToolResultEvent, EditToolResultEvent, ExecResult, @@ -21,6 +19,7 @@ export type { LsToolResultEvent, ReadToolResultEvent, SessionEvent, + SessionEventResult, ToolCallEvent, ToolCallEventResult, ToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 70fb4b90..89e2df89 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -5,13 +5,13 @@ import { spawn } from "node:child_process"; import type { LoadedHook, SendHandler } from "./loader.js"; import type { - BranchEventResult, ExecOptions, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext, + SessionEventResult, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, @@ -217,11 +217,11 @@ export class HookRunner { /** * Emit an event to all hooks. - * Returns the result from branch/tool_result events (if any handler returns one). + * Returns the result from session/tool_result events (if any handler returns one). */ - async emit(event: HookEvent): Promise { + async emit(event: HookEvent): Promise { const ctx = this.createContext(); - let result: BranchEventResult | ToolResultEventResult | undefined; + let result: SessionEventResult | ToolResultEventResult | undefined; for (const hook of this.hooks) { const handlers = hook.handlers.get(event.type); @@ -233,9 +233,13 @@ export class HookRunner { const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); timeout.clear(); - // For branch events, capture the result - if (event.type === "branch" && handlerResult) { - result = handlerResult as BranchEventResult; + // For session events, capture the result (for before_* cancellation) + if (event.type === "session" && handlerResult) { + result = handlerResult as SessionEventResult; + // If cancelled, stop processing further hooks + if (result.cancel) { + return result; + } } // For tool_result events, capture the result diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 91827692..02563048 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -90,11 +90,9 @@ export interface HookEventContext { // ============================================================================ /** - * Event data for session event. - * Fired on startup and when session changes (switch or clear). - * Note: branch has its own event that fires BEFORE the branch happens. + * Base fields shared by all session events. */ -export interface SessionEvent { +interface SessionEventBase { type: "session"; /** All session entries (including pre-compaction history) */ entries: SessionEntry[]; @@ -102,10 +100,32 @@ export interface SessionEvent { sessionFile: string | null; /** Previous session file path, or null for "start" and "clear" */ previousSessionFile: string | null; - /** Reason for the session event */ - reason: "start" | "switch" | "clear"; } +/** + * Event data for session events. + * Discriminated union based on reason. + * + * Lifecycle: + * - start: Initial session load + * - before_switch / switch: Session switch (e.g., /session command) + * - before_clear / clear: Session clear (e.g., /clear command) + * - before_branch / branch: Session branch (e.g., /branch command) + * - shutdown: Process exit (SIGINT/SIGTERM) + * + * "before_*" events fire before the action and can be cancelled via SessionEventResult. + * Other events fire after the action completes. + */ +export type SessionEvent = + | (SessionEventBase & { + reason: "start" | "switch" | "clear" | "before_switch" | "before_clear" | "shutdown"; + }) + | (SessionEventBase & { + reason: "branch" | "before_branch"; + /** Index of the turn to branch from */ + targetTurnIndex: number; + }); + /** * Event data for agent_start event. * Fired when an agent loop starts (once per user prompt). @@ -256,17 +276,6 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { return e.toolName === "ls"; } -/** - * Event data for branch event. - */ -export interface BranchEvent { - type: "branch"; - /** Index of the turn to branch from */ - targetTurnIndex: number; - /** Full session history */ - entries: SessionEntry[]; -} - /** * Union of all hook event types. */ @@ -277,8 +286,7 @@ export type HookEvent = | TurnStartEvent | TurnEndEvent | ToolCallEvent - | ToolResultEvent - | BranchEvent; + | ToolResultEvent; // ============================================================================ // Event Results @@ -309,12 +317,12 @@ export interface ToolResultEventResult { } /** - * Return type for branch event handlers. - * Allows hooks to control branch behavior. + * Return type for session event handlers. + * Allows hooks to cancel "before_*" actions. */ -export interface BranchEventResult { - /** If true, skip restoring the conversation (only restore code) */ - skipConversationRestore?: boolean; +export interface SessionEventResult { + /** If true, cancel the pending action (switch, clear, or branch) */ + cancel?: boolean; } // ============================================================================ @@ -331,14 +339,14 @@ export type HookHandler = (event: E, ctx: HookEventContext) => Prom * Hooks use pi.on() to subscribe to events and pi.send() to inject messages. */ export interface HookAPI { - on(event: "session", handler: HookHandler): void; + // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything + on(event: "session", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; on(event: "turn_end", handler: HookHandler): void; on(event: "tool_call", handler: HookHandler): void; on(event: "tool_result", handler: HookHandler): void; - on(event: "branch", handler: HookHandler): void; /** * Send a message to the agent. diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 5a449cd8..0bd0992f 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -242,7 +242,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Read before edit guideline if (hasRead && hasEdit) { - guidelinesList.push("Use read to examine files before editing"); + guidelinesList.push("Use read to examine files before editing. You must use this tool instead of cat or sed."); } // Edit guideline diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 98ef4d3e..1fd35dfd 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -40,8 +40,6 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - BranchEvent, - BranchEventResult, CustomToolResultEvent, EditToolResultEvent, FindToolResultEvent, @@ -54,6 +52,7 @@ export type { LsToolResultEvent, ReadToolResultEvent, SessionEvent, + SessionEventResult, ToolCallEvent, ToolCallEventResult, ToolResultEvent, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0135fec2..ba50263f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1162,8 +1162,7 @@ export class InteractiveMode { private handleCtrlC(): void { const now = Date.now(); if (now - this.lastSigintTime < 500) { - this.stop(); - process.exit(0); + void this.shutdown(); } else { this.clearEditor(); this.lastSigintTime = now; @@ -1172,6 +1171,27 @@ export class InteractiveMode { private handleCtrlD(): void { // Only called when editor is empty (enforced by CustomEditor) + void this.shutdown(); + } + + /** + * Gracefully shutdown the agent. + * Emits shutdown event to hooks, then exits. + */ + private async shutdown(): Promise { + // Emit shutdown event to hooks + const hookRunner = this.session.hookRunner; + if (hookRunner?.hasHandlers("session")) { + const entries = this.sessionManager.loadEntries(); + await hookRunner.emit({ + type: "session", + entries, + sessionFile: this.session.sessionFile, + previousSessionFile: null, + reason: "shutdown", + }); + } + this.stop(); process.exit(0); } @@ -1496,8 +1516,8 @@ export class InteractiveMode { userMessages.map((m) => ({ index: m.entryIndex, text: m.text })), async (entryIndex) => { const result = await this.session.branch(entryIndex); - if (result.skipped) { - // Hook requested to skip conversation restore + if (result.cancelled) { + // Hook cancelled the branch done(); this.ui.requestRender(); return; @@ -1533,8 +1553,7 @@ export class InteractiveMode { this.ui.requestRender(); }, () => { - this.stop(); - process.exit(0); + void this.shutdown(); }, ); return { component: selector, focus: selector.getSessionList() }; diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index ff744640..4b79b946 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -186,9 +186,11 @@ export class RpcClient { /** * Reset session (clear all messages). + * @returns Object with `cancelled: true` if a hook cancelled the reset */ - async reset(): Promise { - await this.send({ type: "reset" }); + async reset(): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "reset" }); + return this.getData(response); } /** @@ -311,15 +313,18 @@ export class RpcClient { /** * Switch to a different session file. + * @returns Object with `cancelled: true` if a hook cancelled the switch */ - async switchSession(sessionPath: string): Promise { - await this.send({ type: "switch_session", sessionPath }); + async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "switch_session", sessionPath }); + return this.getData(response); } /** * Branch from a specific message. + * @returns Object with `text` (the message text) and `cancelled` (if hook cancelled) */ - async branch(entryIndex: number): Promise<{ text: string }> { + async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> { const response = await this.send({ type: "branch", entryIndex }); return this.getData(response); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f7b2ee6f..248fa338 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -205,8 +205,8 @@ export async function runRpcMode(session: AgentSession): Promise { } case "reset": { - await session.reset(); - return success(id, "reset"); + const cancelled = !(await session.reset()); + return success(id, "reset", { cancelled }); } // ================================================================= @@ -339,13 +339,13 @@ export async function runRpcMode(session: AgentSession): Promise { } case "switch_session": { - await session.switchSession(command.sessionPath); - return success(id, "switch_session"); + const cancelled = !(await session.switchSession(command.sessionPath)); + return success(id, "switch_session", { cancelled }); } case "branch": { const result = await session.branch(command.entryIndex); - return success(id, "branch", { text: result.selectedText, skipped: result.skipped }); + return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled }); } case "get_branch_messages": { diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index f70153d3..ab4f0b61 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -86,7 +86,7 @@ export type RpcResponse = | { id?: string; type: "response"; command: "prompt"; success: true } | { id?: string; type: "response"; command: "queue_message"; success: true } | { id?: string; type: "response"; command: "abort"; success: true } - | { id?: string; type: "response"; command: "reset"; success: true } + | { id?: string; type: "response"; command: "reset"; success: true; data: { cancelled: boolean } } // State | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState } @@ -142,8 +142,8 @@ export type RpcResponse = // Session | { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats } | { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } } - | { id?: string; type: "response"; command: "switch_session"; success: true } - | { id?: string; type: "response"; command: "branch"; success: true; data: { text: string } } + | { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } } + | { id?: string; type: "response"; command: "branch"; success: true; data: { text: string; cancelled: boolean } } | { id?: string; type: "response"; diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index d14fa483..8d2c87da 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -86,7 +86,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // Branch from the first message const result = await session.branch(userMessages[0].entryIndex); expect(result.selectedText).toBe("Say hello"); - expect(result.skipped).toBe(false); + expect(result.cancelled).toBe(false); // After branching, conversation should be empty (branched before the first message) expect(session.messages.length).toBe(0); @@ -116,7 +116,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // Branch from the first message const result = await session.branch(userMessages[0].entryIndex); expect(result.selectedText).toBe("Say hi"); - expect(result.skipped).toBe(false); + expect(result.cancelled).toBe(false); // After branching, conversation should be empty expect(session.messages.length).toBe(0);