diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a42712ee..d6e35849 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,7 +22,10 @@ - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - `prepareCompaction()` now returns `firstKeptEntryId` in its result - **Hook types**: - - `SessionEventBase` now passes `sessionManager` and `modelRegistry` instead of `entries`, `sessionFile`, `previousSessionFile` + - `SessionEventBase` no longer has `sessionManager`/`modelRegistry` - access them via `HookEventContext` instead + - `HookEventContext` now has `sessionManager` and `modelRegistry` (moved from events) + - `HookEventContext` no longer has `exec()` - use `pi.exec()` instead + - `HookCommandContext` no longer has `exec()` - use `pi.exec()` instead - `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first) - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) @@ -32,6 +35,7 @@ - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - New `pi.registerCommand(name, options)` to register custom slash commands - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box - New types: `HookMessage`, `RegisteredCommand`, `HookCommandContext` diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index daef3a92..0f3ef871 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -12,7 +12,7 @@ export default function (pi: HookAPI) { if (event.reason !== "shutdown") return; // Check for uncommitted changes - const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]); + const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]); if (code !== 0 || status.trim().length === 0) { // Not a git repo or no changes @@ -20,7 +20,7 @@ export default function (pi: HookAPI) { } // Find the last assistant message for commit context - const entries = event.sessionManager.getEntries(); + const entries = ctx.sessionManager.getEntries(); let lastAssistantText = ""; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -41,8 +41,8 @@ export default function (pi: HookAPI) { 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]); + await pi.exec("git", ["add", "-A"]); + const { code: commitCode } = await pi.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 index 72ecc9d3..81e32eb5 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -29,7 +29,7 @@ export default function (pi: HookAPI) { if (!ctx.hasUI) return; // Check if there are unsaved changes (messages since last assistant response) - const entries = event.sessionManager.getEntries(); + const entries = ctx.sessionManager.getEntries(); const hasUnsavedWork = entries.some( (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", ); diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index efc2f59b..f2794060 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -23,7 +23,7 @@ export default function (pi: HookAPI) { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { preparation, previousCompactions, modelRegistry, signal } = event; + const { preparation, previousCompactions, signal } = event; const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation; // Get previous summary from most recent compaction (if any) @@ -37,7 +37,7 @@ export default function (pi: HookAPI) { } // Resolve API key for the summarization model - const apiKey = await modelRegistry.getApiKey(model); + const apiKey = await ctx.modelRegistry.getApiKey(model); if (!apiKey) { ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); return; diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index 8e6e5d66..852ff006 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -15,7 +15,7 @@ export default function (pi: HookAPI) { } // Check for uncommitted changes - const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]); + const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); if (code !== 0) { // Not a git repo, allow the action diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index f7a73a7f..a69cadc8 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -10,9 +10,9 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { const checkpoints = new Map(); - pi.on("turn_start", async (event, ctx) => { + pi.on("turn_start", async (event) => { // Create a git stash entry before LLM makes changes - const { stdout } = await ctx.exec("git", ["stash", "create"]); + const { stdout } = await pi.exec("git", ["stash", "create"]); const ref = stdout.trim(); if (ref) { checkpoints.set(event.turnIndex, ref); @@ -37,7 +37,7 @@ export default function (pi: HookAPI) { ]); if (choice?.startsWith("Yes")) { - await ctx.exec("git", ["stash", "apply", ref]); + await pi.exec("git", ["stash", "apply", ref]); ctx.ui.notify("Code restored to checkpoint", "info"); } }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0a7d523a..b00caad8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,15 +27,13 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import { - type ExecOptions, - execCommand, - type HookCommandContext, - type HookMessage, - type HookRunner, - type SessionEventResult, - type TurnEndEvent, - type TurnStartEvent, +import type { + HookCommandContext, + HookMessage, + HookRunner, + SessionEventResult, + TurnEndEvent, + TurnStartEvent, } from "./hooks/index.js"; import { type BashExecutionMessage, type HookAppMessage, isHookAppMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; @@ -519,7 +517,6 @@ export class AgentSession { cwd, sessionManager: this.sessionManager, modelRegistry: this._modelRegistry, - exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options), }; try { @@ -640,8 +637,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_new", })) as SessionEventResult | undefined; @@ -659,11 +654,8 @@ export class AgentSession { // Emit session event with reason "new" to hooks if (this._hookRunner) { - this._hookRunner.setSessionFile(this.sessionFile); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "new", }); } @@ -888,8 +880,6 @@ export class AgentSession { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_compact", preparation, previousCompactions, @@ -952,8 +942,6 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, fromHook, @@ -1060,8 +1048,6 @@ export class AgentSession { const hookResult = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_compact", preparation, previousCompactions, @@ -1125,8 +1111,6 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, fromHook, @@ -1431,8 +1415,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_switch", targetSessionFile: sessionPath, })) as SessionEventResult | undefined; @@ -1454,11 +1436,8 @@ export class AgentSession { // Emit session event to hooks if (this._hookRunner) { - this._hookRunner.setSessionFile(sessionPath); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "switch", previousSessionFile, }); @@ -1515,8 +1494,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_branch", targetTurnIndex: entryIndex, })) as SessionEventResult | undefined; @@ -1544,11 +1521,8 @@ export class AgentSession { // Emit branch event to hooks (after branch completes) if (this._hookRunner) { - this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "branch", targetTurnIndex: entryIndex, }); diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 736ed673..12b9966a 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -9,7 +9,15 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; -import type { CustomMessageRenderer, HookAPI, HookFactory, HookMessage, RegisteredCommand } from "./types.js"; +import { execCommand } from "./runner.js"; +import type { + CustomMessageRenderer, + ExecOptions, + HookAPI, + HookFactory, + HookMessage, + RegisteredCommand, +} from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -123,7 +131,10 @@ 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. */ -function createHookAPI(handlers: Map): { +function createHookAPI( + handlers: Map, + cwd: string, +): { api: HookAPI; customMessageRenderers: Map; commands: Map; @@ -139,7 +150,9 @@ function createHookAPI(handlers: Map): { const customMessageRenderers = new Map(); const commands = new Map(); - const api: HookAPI = { + // Cast to HookAPI - the implementation is more general (string event names) + // but the interface has specific overloads for type safety in hooks + const api = { on(event: string, handler: HandlerFn): void { const list = handlers.get(event) ?? []; list.push(handler); @@ -151,12 +164,15 @@ function createHookAPI(handlers: Map): { appendEntry(customType: string, data?: T): void { appendEntryHandler(customType, data); }, - registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { - customMessageRenderers.set(customType, renderer); + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { + customMessageRenderers.set(customType, renderer as CustomMessageRenderer); }, registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { commands.set(name, { name, ...options }); }, + exec(command: string, args: string[], options?: ExecOptions) { + return execCommand(command, args, options?.cwd ?? cwd, options); + }, } as HookAPI; return { @@ -196,8 +212,10 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = - createHookAPI(handlers); + const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( + handlers, + cwd, + ); // Call factory to register handlers factory(api); diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index e2efb149..bfdebd28 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,6 +3,8 @@ */ import { spawn } from "node:child_process"; +import type { ModelRegistry } from "../model-registry.js"; +import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { CustomMessageRenderer, @@ -133,16 +135,24 @@ export class HookRunner { private uiContext: HookUIContext; private hasUI: boolean; private cwd: string; - private sessionFile: string | null; + private sessionManager: SessionManager; + private modelRegistry: ModelRegistry; private timeout: number; private errorListeners: Set = new Set(); - constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) { + constructor( + hooks: LoadedHook[], + cwd: string, + sessionManager: SessionManager, + modelRegistry: ModelRegistry, + timeout: number = DEFAULT_TIMEOUT, + ) { this.hooks = hooks; this.uiContext = noOpUIContext; this.hasUI = false; this.cwd = cwd; - this.sessionFile = null; + this.sessionManager = sessionManager; + this.modelRegistry = modelRegistry; this.timeout = timeout; } @@ -176,13 +186,6 @@ export class HookRunner { return this.hooks.map((h) => h.path); } - /** - * Set the session file path. - */ - setSessionFile(sessionFile: string | null): void { - this.sessionFile = sessionFile; - } - /** * Set the send message handler for all hooks' pi.sendMessage(). * Call this when the mode initializes. @@ -283,12 +286,11 @@ export class HookRunner { */ private createContext(): HookEventContext { return { - exec: (command: string, args: string[], options?: ExecOptions) => - execCommand(command, args, this.cwd, options), ui: this.uiContext, hasUI: this.hasUI, cwd: this.cwd, - sessionFile: this.sessionFile, + sessionManager: this.sessionManager, + modelRegistry: this.modelRegistry, }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 35b339e1..7798dd6a 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -41,6 +41,8 @@ export interface ExecOptions { signal?: AbortSignal; /** Timeout in milliseconds */ timeout?: number; + /** Working directory */ + cwd?: string; } /** @@ -78,16 +80,16 @@ export interface HookUIContext { * Context passed to hook event handlers. */ export interface HookEventContext { - /** Execute a command and return stdout/stderr/code */ - exec(command: string, args: string[], options?: ExecOptions): Promise; /** UI methods for user interaction */ ui: HookUIContext; /** Whether UI is available (false in print mode) */ hasUI: boolean; /** Current working directory */ cwd: string; - /** Path to session file, or null if --no-session */ - sessionFile: string | null; + /** Session manager instance - use for entries, session file, etc. */ + sessionManager: SessionManager; + /** Model registry - use for API key resolution and model retrieval */ + modelRegistry: ModelRegistry; } // ============================================================================ @@ -99,10 +101,6 @@ export interface HookEventContext { */ interface SessionEventBase { type: "session"; - /** Session manager instance - use for entries, session file, etc. */ - sessionManager: SessionManager; - /** Model registry - use for API key resolution */ - modelRegistry: ModelRegistry; } /** @@ -402,8 +400,6 @@ export interface HookCommandContext { args: string; /** UI methods for user interaction */ ui: HookUIContext; - /** Execute a command and return stdout/stderr/code */ - exec(command: string, args: string[], options?: ExecOptions): Promise; /** Whether UI is available (false in print mode) */ hasUI: boolean; /** Current working directory */ @@ -491,9 +487,15 @@ export interface HookAPI { /** * Register a custom slash command. - * Handler receives CommandContext and can return a string to send as prompt. + * Handler receives HookCommandContext. */ registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; + + /** + * Execute a shell command and return stdout/stderr/code. + * Supports timeout and abort signal. + */ + exec(command: string, args: string[], options?: ExecOptions): Promise; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8397d2e9..1c7ab14f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -534,7 +534,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); } } else { // Discover hooks, merging with additional paths @@ -545,7 +545,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} console.error(`Failed to load hook "${path}": ${error}`); } if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b2baec16..23efe7c9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -369,7 +369,6 @@ export class InteractiveMode { // Set UI context on hook runner hookRunner.setUIContext(uiContext, true); - hookRunner.setSessionFile(this.session.sessionFile); // Subscribe to hook errors hookRunner.onError((error) => { @@ -407,8 +406,6 @@ export class InteractiveMode { // Emit session event await hookRunner.emit({ type: "session", - sessionManager: this.session.sessionManager, - modelRegistry: this.session.modelRegistry, reason: "start", }); } @@ -1204,8 +1201,6 @@ export class InteractiveMode { if (hookRunner?.hasHandlers("session")) { await hookRunner.emit({ type: "session", - sessionManager: this.session.sessionManager, - modelRegistry: this.session.modelRegistry, reason: "shutdown", }); } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 9375dd5c..6bc06dea 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -34,8 +34,6 @@ export async function runPrintMode( // Set up hooks for print mode (no UI) const hookRunner = session.hookRunner; if (hookRunner) { - // Use actual session file if configured (via --session), otherwise null - hookRunner.setSessionFile(session.sessionFile); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); @@ -51,8 +49,6 @@ export async function runPrintMode( // Emit session event await hookRunner.emit({ type: "session", - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 3840fb61..cae2d5f4 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -127,7 +127,6 @@ export async function runRpcMode(session: AgentSession): Promise { const hookRunner = session.hookRunner; if (hookRunner) { hookRunner.setUIContext(createHookUIContext(), false); - hookRunner.setSessionFile(session.sessionFile); hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); @@ -143,8 +142,6 @@ export async function runRpcMode(session: AgentSession): Promise { // Emit session event await hookRunner.emit({ type: "session", - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, reason: "start", }); }