diff --git a/package-lock.json b/package-lock.json index d0d26ef0..f853a3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6520,11 +6520,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.18.0", - "@mariozechner/pi-tui": "^0.18.0" + "@mariozechner/pi-ai": "^0.17.0", + "@mariozechner/pi-tui": "^0.17.0" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6554,7 +6554,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.71.2", @@ -6595,15 +6595,16 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.18.0", - "@mariozechner/pi-ai": "^0.18.0", - "@mariozechner/pi-tui": "^0.18.0", + "@mariozechner/pi-agent-core": "^0.17.0", + "@mariozechner/pi-ai": "^0.17.0", + "@mariozechner/pi-tui": "^0.17.0", "chalk": "^5.5.0", "diff": "^8.0.2", - "glob": "^11.0.3" + "glob": "^11.0.3", + "jiti": "^2.6.1" }, "bin": { "pi": "dist/cli.js" @@ -6637,12 +6638,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.18.0", - "@mariozechner/pi-ai": "^0.18.0", + "@mariozechner/pi-agent-core": "^0.17.0", + "@mariozechner/pi-ai": "^0.17.0", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6680,10 +6681,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.18.0", + "@mariozechner/pi-agent-core": "^0.17.0", "chalk": "^5.5.0" }, "bin": { @@ -6696,7 +6697,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.18.0", + "version": "0.17.0", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6712,7 +6713,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6756,12 +6757,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.18.0", + "version": "0.17.0", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.18.0", - "@mariozechner/pi-tui": "^0.18.0", + "@mariozechner/pi-ai": "^0.17.0", + "@mariozechner/pi-tui": "^0.17.0", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6782,7 +6783,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.6.0", + "version": "1.5.0", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index d921ec1b..590bb323 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5068,23 +5068,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "mistralai/ministral-8b": { - id: "mistralai/ministral-8b", - name: "Mistral: Ministral 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/ministral-3b": { id: "mistralai/ministral-3b", name: "Mistral: Ministral 3B", @@ -5102,6 +5085,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "mistralai/ministral-8b": { + id: "mistralai/ministral-8b", + name: "Mistral: Ministral 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nvidia/llama-3.1-nemotron-70b-instruct": { id: "nvidia/llama-3.1-nemotron-70b-instruct", name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", @@ -5272,23 +5272,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -5323,6 +5306,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5459,23 +5459,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5510,22 +5493,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.3, - output: 0.39999999999999997, + input: 5, + output: 15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 16384, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", @@ -5544,6 +5527,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5629,23 +5629,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -5663,6 +5646,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", @@ -5731,23 +5731,6 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -5782,6 +5765,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 8aee14ad..90b827cd 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -33,7 +33,8 @@ "@mariozechner/pi-tui": "^0.17.0", "chalk": "^5.5.0", "diff": "^8.0.2", - "glob": "^11.0.3" + "glob": "^11.0.3", + "jiti": "^2.6.1" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 694c931a..e4bb75f6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -20,6 +20,15 @@ import { getModelsPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; import { exportSessionToHtml } from "./export-html.js"; +import { + type BranchEventResult, + type HookError, + HookRunner, + type HookUIContext, + loadHooks, + type TurnEndEvent, + type 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"; @@ -47,6 +56,10 @@ export interface AgentSessionConfig { scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; /** File-based slash commands for expansion */ fileCommands?: FileSlashCommand[]; + /** UI context for hooks. If not provided, hooks are disabled. */ + hookUIContext?: HookUIContext; + /** Callback for hook errors */ + onHookError?: (error: HookError) => void; } /** Options for AgentSession.prompt() */ @@ -117,12 +130,21 @@ export class AgentSession { private _bashAbortController: AbortController | null = null; private _pendingBashMessages: BashExecutionMessage[] = []; + // Hook system + private _hookRunner: HookRunner | null = null; + private _hookUIContext?: HookUIContext; + private _onHookError?: (error: HookError) => void; + private _hooksInitialized = false; + private _turnIndex = 0; + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; this._fileCommands = config.fileCommands ?? []; + this._hookUIContext = config.hookUIContext; + this._onHookError = config.onHookError; } // ========================================================================= @@ -141,6 +163,9 @@ export class AgentSession { /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { + // Emit to hooks first + await this._emitHookEvent(event); + // Notify all listeners this._emit(event); @@ -167,14 +192,83 @@ export class AgentSession { } }; + /** Emit hook events based on agent events */ + private async _emitHookEvent(event: AgentEvent): Promise { + if (!this._hookRunner) return; + + if (event.type === "agent_start") { + this._turnIndex = 0; + await this._hookRunner.emit({ type: "agent_start" }); + } else if (event.type === "agent_end") { + await this._hookRunner.emit({ type: "agent_end", messages: event.messages }); + } else if (event.type === "turn_start") { + const hookEvent: TurnStartEvent = { + type: "turn_start", + turnIndex: this._turnIndex, + timestamp: Date.now(), + }; + await this._hookRunner.emit(hookEvent); + } else if (event.type === "turn_end") { + const hookEvent: TurnEndEvent = { + type: "turn_end", + turnIndex: this._turnIndex, + message: event.message, + toolResults: event.toolResults, + }; + await this._hookRunner.emit(hookEvent); + this._turnIndex++; + } + } + + /** + * Initialize hooks from settings. + * Called automatically on first subscribe, but can be called manually earlier. + * Returns any errors encountered during hook loading. + */ + async initHooks(): Promise> { + if (this._hooksInitialized) return []; + this._hooksInitialized = true; + + // Skip if no UI context (hooks disabled) + if (!this._hookUIContext) return []; + + const hookPaths = this.settingsManager.getHookPaths(); + if (hookPaths.length === 0) return []; + + const cwd = process.cwd(); + const { hooks, errors } = await loadHooks(hookPaths, cwd); + + if (hooks.length > 0) { + const timeout = this.settingsManager.getHookTimeout(); + this._hookRunner = new HookRunner(hooks, this._hookUIContext, cwd, timeout); + + // Subscribe to hook errors + if (this._onHookError) { + this._hookRunner.onError(this._onHookError); + } + } + + return errors; + } + /** * Subscribe to agent events. * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. + * + * Note: Call initHooks() before subscribe() if you want to handle hook loading errors. + * Otherwise hooks are initialized automatically on first subscribe. */ subscribe(listener: AgentSessionEventListener): () => void { this._eventListeners.push(listener); + // Initialize hooks if not done yet (fire and forget - errors go to callback) + if (!this._hooksInitialized && this._hookUIContext) { + this.initHooks().catch(() => { + // Errors are reported via onHookError callback + }); + } + // Set up agent subscription if not already done if (!this._unsubscribeAgent) { this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); @@ -858,10 +952,14 @@ export class AgentSession { /** * Create a branch from a specific entry index. + * Emits branch event to hooks, which can control the branch behavior. + * * @param entryIndex Index into session entries to branch from - * @returns The text of the selected user message (for editor pre-fill) + * @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 */ - branch(entryIndex: number): string { + async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> { const entries = this.sessionManager.loadEntries(); const selectedEntry = entries[entryIndex]; @@ -871,6 +969,21 @@ 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, + entries, + }); + } + + // If hook says skip conversation restore, don't branch + if (hookResult?.skipConversationRestore) { + return { selectedText, skipped: true }; + } + // Create branched session const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex); this.sessionManager.setSessionFile(newSessionFile); @@ -879,7 +992,7 @@ export class AgentSession { const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); this.agent.replaceMessages(loaded.messages); - return selectedText; + return { selectedText, skipped: false }; } /** @@ -996,4 +1109,35 @@ export class AgentSession { return text.trim() || null; } + + // ========================================================================= + // Hook System + // ========================================================================= + + /** + * Check if hooks have handlers for a specific event type. + */ + hasHookHandlers(eventType: string): boolean { + return this._hookRunner?.hasHandlers(eventType) ?? false; + } + + /** + * Get the hook runner (for advanced use cases). + */ + get hookRunner(): HookRunner | null { + return this._hookRunner; + } + + /** + * Set hook UI context after construction. + * Useful when the UI context depends on components not available at construction time. + * Must be called before initHooks() or subscribe(). + */ + setHookUIContext(context: HookUIContext, onError?: (error: HookError) => void): void { + if (this._hooksInitialized) { + throw new Error("Cannot set hook UI context after hooks have been initialized"); + } + this._hookUIContext = context; + this._onHookError = onError; + } } diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts new file mode 100644 index 00000000..cd0a1ee6 --- /dev/null +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -0,0 +1,17 @@ +export { type LoadedHook, type LoadHooksResult, loadHooks } from "./loader.js"; +export { type HookErrorListener, HookRunner } from "./runner.js"; +export type { + AgentEndEvent, + AgentStartEvent, + BranchEvent, + BranchEventResult, + ExecResult, + HookAPI, + HookError, + HookEvent, + HookEventContext, + HookFactory, + HookUIContext, + TurnEndEvent, + TurnStartEvent, +} from "./types.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts new file mode 100644 index 00000000..e0e6f805 --- /dev/null +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -0,0 +1,138 @@ +/** + * Hook loader - loads TypeScript hook modules using jiti. + */ + +import * as os from "node:os"; +import * as path from "node:path"; +import { createJiti } from "jiti"; +import type { HookAPI, HookFactory } from "./types.js"; + +/** + * Generic handler function type. + */ +type HandlerFn = (...args: unknown[]) => Promise; + +/** + * Registered handlers for a loaded hook. + */ +export interface LoadedHook { + /** Original path from config */ + path: string; + /** Resolved absolute path */ + resolvedPath: string; + /** Map of event type to handler functions */ + handlers: Map; +} + +/** + * Result of loading hooks. + */ +export interface LoadHooksResult { + /** Successfully loaded hooks */ + hooks: LoadedHook[]; + /** Errors encountered during loading */ + errors: Array<{ path: string; error: string }>; +} + +/** + * Expand path with ~ support. + */ +function expandPath(p: string): string { + if (p.startsWith("~/")) { + return path.join(os.homedir(), p.slice(2)); + } + if (p.startsWith("~")) { + return path.join(os.homedir(), p.slice(1)); + } + return p; +} + +/** + * Resolve hook path. + * - Absolute paths used as-is + * - Paths starting with ~ expanded to home directory + * - Relative paths resolved from cwd + */ +function resolveHookPath(hookPath: string, cwd: string): string { + const expanded = expandPath(hookPath); + + if (path.isAbsolute(expanded)) { + return expanded; + } + + // Relative paths resolved from cwd + return path.resolve(cwd, expanded); +} + +/** + * Create a HookAPI instance that collects handlers. + */ +function createHookAPI(handlers: Map): HookAPI { + return { + on(event: string, handler: HandlerFn): void { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + } as HookAPI; +} + +/** + * Load a single hook module using jiti. + */ +async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> { + const resolvedPath = resolveHookPath(hookPath, cwd); + + try { + // Create jiti instance for TypeScript/ESM loading + const jiti = createJiti(import.meta.url); + + // Import the module + const module = await jiti.import(resolvedPath, { default: true }); + const factory = module as HookFactory; + + if (typeof factory !== "function") { + return { hook: null, error: "Hook must export a default function" }; + } + + // Create handlers map and API + const handlers = new Map(); + const api = createHookAPI(handlers); + + // Call factory to register handlers + factory(api); + + return { + hook: { path: hookPath, resolvedPath, handlers }, + error: null, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { hook: null, error: `Failed to load hook: ${message}` }; + } +} + +/** + * Load all hooks from configuration. + * @param paths - Array of hook file paths + * @param cwd - Current working directory for resolving relative paths + */ +export async function loadHooks(paths: string[], cwd: string): Promise { + const hooks: LoadedHook[] = []; + const errors: Array<{ path: string; error: string }> = []; + + for (const hookPath of paths) { + const { hook, error } = await loadHook(hookPath, cwd); + + if (error) { + errors.push({ path: hookPath, error }); + continue; + } + + if (hook) { + hooks.push(hook); + } + } + + return { hooks, errors }; +} diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts new file mode 100644 index 00000000..b77038a0 --- /dev/null +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -0,0 +1,157 @@ +/** + * Hook runner - executes hooks and manages their lifecycle. + */ + +import { spawn } from "node:child_process"; +import type { LoadedHook } from "./loader.js"; +import type { BranchEventResult, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext } from "./types.js"; + +/** + * Default timeout for hook execution (30 seconds). + */ +const DEFAULT_TIMEOUT = 30000; + +/** + * Listener for hook errors. + */ +export type HookErrorListener = (error: HookError) => void; + +/** + * Execute a command and return stdout/stderr/code. + */ +async function exec(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { cwd, shell: false }); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + + proc.on("error", (_err) => { + resolve({ stdout, stderr, code: 1 }); + }); + }); +} + +/** + * Create a promise that rejects after a timeout. + */ +function createTimeout(ms: number): { promise: Promise; clear: () => void } { + let timeoutId: NodeJS.Timeout; + const promise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); + }); + return { + promise, + clear: () => clearTimeout(timeoutId), + }; +} + +/** + * HookRunner executes hooks and manages event emission. + */ +export class HookRunner { + private hooks: LoadedHook[]; + private uiContext: HookUIContext; + private cwd: string; + private timeout: number; + private errorListeners: Set = new Set(); + + constructor(hooks: LoadedHook[], uiContext: HookUIContext, cwd: string, timeout: number = DEFAULT_TIMEOUT) { + this.hooks = hooks; + this.uiContext = uiContext; + this.cwd = cwd; + this.timeout = timeout; + } + + /** + * Subscribe to hook errors. + * @returns Unsubscribe function + */ + onError(listener: HookErrorListener): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + /** + * Emit an error to all listeners. + */ + private emitError(error: HookError): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + /** + * Check if any hooks have handlers for the given event type. + */ + hasHandlers(eventType: string): boolean { + for (const hook of this.hooks) { + const handlers = hook.handlers.get(eventType); + if (handlers && handlers.length > 0) { + return true; + } + } + return false; + } + + /** + * Create the event context for handlers. + */ + private createContext(): HookEventContext { + return { + exec: (command: string, args: string[]) => exec(command, args, this.cwd), + ui: this.uiContext, + cwd: this.cwd, + }; + } + + /** + * Emit an event to all hooks. + * Returns the result from branch events (if any handler returns one). + */ + async emit(event: HookEvent): Promise { + const ctx = this.createContext(); + let result: BranchEventResult | undefined; + + for (const hook of this.hooks) { + const handlers = hook.handlers.get(event.type); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const timeout = createTimeout(this.timeout); + + 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; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.emitError({ + hookPath: hook.path, + event: event.type, + error: message, + }); + } + } + } + + return result; + } +} diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts new file mode 100644 index 00000000..af91aa26 --- /dev/null +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -0,0 +1,172 @@ +/** + * Hook system types. + * + * Hooks are TypeScript modules that can subscribe to agent lifecycle events + * and interact with the user via UI primitives. + */ + +import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { SessionEntry } from "../session-manager.js"; + +// ============================================================================ +// Execution Context +// ============================================================================ + +/** + * Result of executing a command via ctx.exec() + */ +export interface ExecResult { + stdout: string; + stderr: string; + code: number; +} + +/** + * UI context for hooks to request interactive UI from the harness. + * Each mode (interactive, RPC, print) provides its own implementation. + */ +export interface HookUIContext { + /** + * Show a selector and return the user's choice. + * @param title - Title to display + * @param options - Array of string options + * @returns Selected option string, or null if cancelled + */ + select(title: string, options: string[]): Promise; + + /** + * Show a confirmation dialog. + * @returns true if confirmed, false if cancelled + */ + confirm(title: string, message: string): Promise; + + /** + * Show a text input dialog. + * @returns User input, or null if cancelled + */ + input(title: string, placeholder?: string): Promise; + + /** + * Show a notification to the user. + */ + notify(message: string, type?: "info" | "warning" | "error"): void; +} + +/** + * Context passed to hook event handlers. + */ +export interface HookEventContext { + /** Execute a command and return stdout/stderr/code */ + exec(command: string, args: string[]): Promise; + /** UI methods for user interaction */ + ui: HookUIContext; + /** Current working directory */ + cwd: string; +} + +// ============================================================================ +// Events +// ============================================================================ + +/** + * Event data for agent_start event. + */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** + * Event data for agent_end event. + */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AppMessage[]; +} + +/** + * Event data for turn_start event. + */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** + * Event data for turn_end event. + */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AppMessage; + toolResults: AppMessage[]; +} + +/** + * 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. + */ +export type HookEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | BranchEvent; + +// ============================================================================ +// Event Results +// ============================================================================ + +/** + * Return type for branch event handlers. + * Allows hooks to control branch behavior. + */ +export interface BranchEventResult { + /** If true, skip restoring the conversation (only restore code) */ + skipConversationRestore?: boolean; +} + +// ============================================================================ +// Hook API +// ============================================================================ + +/** + * Handler function type for each event. + */ +export type HookHandler = (event: E, ctx: HookEventContext) => Promise; + +/** + * HookAPI passed to hook factory functions. + * Hooks use pi.on() to subscribe to events. + */ +export interface HookAPI { + 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: "branch", handler: HookHandler): void; +} + +/** + * Hook factory function type. + * Hooks export a default function that receives the HookAPI. + */ +export type HookFactory = (pi: HookAPI) => void; + +// ============================================================================ +// Errors +// ============================================================================ + +/** + * Error emitted when a hook fails. + */ +export interface HookError { + hookPath: string; + event: string; + error: string; +} diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index eefe99ca..48759028 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -13,3 +13,13 @@ export { type SessionStats, } from "./agent-session.js"; export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; +export { + type HookAPI, + type HookError, + type HookEvent, + type HookEventContext, + type HookFactory, + HookRunner, + type HookUIContext, + loadHooks, +} from "./hooks/index.js"; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index be507ab4..0582a99a 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -19,6 +19,8 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) + hooks?: string[]; // Array of hook file paths + hookTimeout?: number; // Timeout for hook execution in ms (default: 30000) } export class SettingsManager { @@ -173,4 +175,22 @@ export class SettingsManager { this.settings.collapseChangelog = collapse; this.save(); } + + getHookPaths(): string[] { + return this.settings.hooks ?? []; + } + + setHookPaths(paths: string[]): void { + this.settings.hooks = paths; + this.save(); + } + + getHookTimeout(): number { + return this.settings.hookTimeout ?? 30000; + } + + setHookTimeout(timeout: number): void { + this.settings.hookTimeout = timeout; + this.save(); + } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3f6d7700..d223a754 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -1,3 +1,17 @@ +// Hook system types +export type { + AgentEndEvent, + AgentStartEvent, + BranchEvent, + BranchEventResult, + HookAPI, + HookEvent, + HookEventContext, + HookFactory, + HookUIContext, + TurnEndEvent, + TurnStartEvent, +} from "./core/hooks/index.js"; export { SessionManager } from "./core/session-manager.js"; export { bashTool, codingTools, editTool, readTool, writeTool } from "./core/tools/index.js"; export { main } from "./main.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/hook-input.ts new file mode 100644 index 00000000..de7d7b9d --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/hook-input.ts @@ -0,0 +1,64 @@ +/** + * Simple text input component for hooks. + */ + +import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +export class HookInputComponent extends Container { + private input: Input; + private onSubmitCallback: (value: string) => void; + private onCancelCallback: () => void; + + constructor( + title: string, + _placeholder: string | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + ) { + super(); + + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add title + this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.addChild(new Spacer(1)); + + // Create input + this.input = new Input(); + this.addChild(this.input); + + this.addChild(new Spacer(1)); + + // Add hint + this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0)); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + // Enter + if (keyData === "\r" || keyData === "\n") { + this.onSubmitCallback(this.input.getValue()); + return; + } + + // Escape to cancel + if (keyData === "\x1b") { + this.onCancelCallback(); + return; + } + + // Forward to input + this.input.handleInput(keyData); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts new file mode 100644 index 00000000..9936b756 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts @@ -0,0 +1,91 @@ +/** + * Generic selector component for hooks. + * Displays a list of string options with keyboard navigation. + */ + +import { Container, Spacer, Text } from "@mariozechner/pi-tui"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +export class HookSelectorComponent extends Container { + private options: string[]; + private selectedIndex = 0; + private listContainer: Container; + private onSelectCallback: (option: string) => void; + private onCancelCallback: () => void; + + constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) { + super(); + + this.options = options; + this.onSelectCallback = onSelect; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add title + this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.addChild(new Spacer(1)); + + // Create list container + this.listContainer = new Container(); + this.addChild(this.listContainer); + + this.addChild(new Spacer(1)); + + // Add hint + this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0)); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + + // Initial render + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + + for (let i = 0; i < this.options.length; i++) { + const option = this.options[i]; + const isSelected = i === this.selectedIndex; + + let text = ""; + if (isSelected) { + text = theme.fg("accent", "→ ") + theme.fg("accent", option); + } else { + text = " " + theme.fg("text", option); + } + + this.listContainer.addChild(new Text(text, 1, 0)); + } + } + + handleInput(keyData: string): void { + // Up arrow or k + if (keyData === "\x1b[A" || keyData === "k") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } + // Down arrow or j + else if (keyData === "\x1b[B" || keyData === "j") { + this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); + this.updateList(); + } + // Enter + else if (keyData === "\r" || keyData === "\n") { + const selected = this.options[this.selectedIndex]; + if (selected) { + this.onSelectCallback(selected); + } + } + // Escape + else if (keyData === "\x1b") { + this.onCancelCallback(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a1865119..506dc772 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -25,6 +25,7 @@ import { import { exec } from "child_process"; import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; +import type { HookUIContext } from "../../core/hooks/index.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js"; @@ -38,6 +39,8 @@ import { CompactionComponent } from "./components/compaction.js"; import { CustomEditor } from "./components/custom-editor.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; +import { HookInputComponent } from "./components/hook-input.js"; +import { HookSelectorComponent } from "./components/hook-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js"; @@ -98,6 +101,10 @@ export class InteractiveMode { private autoCompactionLoader: Loader | null = null; private autoCompactionEscapeHandler?: () => void; + // Hook UI state + private hookSelector: HookSelectorComponent | null = null; + private hookInput: HookInputComponent | null = null; + // Convenience accessors private get agent() { return this.session.agent; @@ -242,6 +249,9 @@ export class InteractiveMode { this.ui.start(); this.isInitialized = true; + // Initialize hooks with TUI-based UI context + await this.initHooks(); + // Subscribe to agent events this.subscribeToAgent(); @@ -258,6 +268,144 @@ export class InteractiveMode { }); } + // ========================================================================= + // Hook System + // ========================================================================= + + /** + * Initialize the hook system with TUI-based UI context. + */ + private async initHooks(): Promise { + // Create hook UI context + const hookUIContext = this.createHookUIContext(); + + // Set context on session + this.session.setHookUIContext(hookUIContext, (error) => { + this.showHookError(error.hookPath, error.error); + }); + + // Initialize hooks and report any loading errors + const loadErrors = await this.session.initHooks(); + for (const { path, error } of loadErrors) { + this.showHookError(path, error); + } + } + + /** + * Create the UI context for hooks. + */ + private createHookUIContext(): HookUIContext { + return { + select: (title, options) => this.showHookSelector(title, options), + confirm: (title, message) => this.showHookConfirm(title, message), + input: (title, placeholder) => this.showHookInput(title, placeholder), + notify: (message, type) => this.showHookNotify(message, type), + }; + } + + /** + * Show a selector for hooks. + */ + private showHookSelector(title: string, options: string[]): Promise { + return new Promise((resolve) => { + this.hookSelector = new HookSelectorComponent( + title, + options, + (option) => { + this.hideHookSelector(); + resolve(option); + }, + () => { + this.hideHookSelector(); + resolve(null); + }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.hookSelector); + this.ui.setFocus(this.hookSelector); + this.ui.requestRender(); + }); + } + + /** + * Hide the hook selector. + */ + private hideHookSelector(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.hookSelector = null; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + /** + * Show a confirmation dialog for hooks. + */ + private async showHookConfirm(title: string, message: string): Promise { + const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]); + return result === "Yes"; + } + + /** + * Show a text input for hooks. + */ + private showHookInput(title: string, placeholder?: string): Promise { + return new Promise((resolve) => { + this.hookInput = new HookInputComponent( + title, + placeholder, + (value) => { + this.hideHookInput(); + resolve(value); + }, + () => { + this.hideHookInput(); + resolve(null); + }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.hookInput); + this.ui.setFocus(this.hookInput); + this.ui.requestRender(); + }); + } + + /** + * Hide the hook input. + */ + private hideHookInput(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.hookInput = null; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + /** + * Show a notification for hooks. + */ + private showHookNotify(message: string, type?: "info" | "warning" | "error"): void { + const color = type === "error" ? "error" : type === "warning" ? "warning" : "dim"; + const text = new Text(theme.fg(color, `[Hook] ${message}`), 1, 0); + this.chatContainer.addChild(text); + this.ui.requestRender(); + } + + /** + * Show a hook error in the UI. + */ + private showHookError(hookPath: string, error: string): void { + const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0); + this.chatContainer.addChild(errorText); + this.ui.requestRender(); + } + + // ========================================================================= + // Key Handlers + // ========================================================================= + private setupKeyHandlers(): void { this.editor.onEscape = () => { if (this.loadingAnimation) { @@ -1029,12 +1177,18 @@ export class InteractiveMode { this.showSelector((done) => { const selector = new UserMessageSelectorComponent( userMessages.map((m) => ({ index: m.entryIndex, text: m.text })), - (entryIndex) => { - const selectedText = this.session.branch(entryIndex); + async (entryIndex) => { + const result = await this.session.branch(entryIndex); + if (result.skipped) { + // Hook requested to skip conversation restore + done(); + this.ui.requestRender(); + return; + } this.chatContainer.clear(); this.isFirstUserMessage = true; this.renderInitialMessages(this.session.state); - this.editor.setText(selectedText); + this.editor.setText(result.selectedText); done(); this.showStatus("Branched to new session"); }, diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 7130baae..a386a1c6 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,6 +9,28 @@ import type { Attachment } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AgentSession } from "../core/agent-session.js"; +import type { HookUIContext } from "../core/hooks/index.js"; + +/** + * Create a no-op hook UI context for print mode. + * Hooks can still run but can't prompt the user interactively. + */ +function createNoOpHookUIContext(): HookUIContext { + return { + async select() { + return null; + }, + async confirm() { + return false; + }, + async input() { + return null; + }, + notify() { + // Silent in print mode + }, + }; +} /** * Run in print (single-shot) mode. @@ -27,6 +49,12 @@ export async function runPrintMode( initialMessage?: string, initialAttachments?: Attachment[], ): Promise { + // Initialize hooks with no-op UI context (hooks run but can't prompt) + session.setHookUIContext(createNoOpHookUIContext(), (err) => { + console.error(`Hook error (${err.hookPath}): ${err.error}`); + }); + await session.initHooks(); + if (mode === "json") { // Output all events as JSON session.subscribe((event) => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f9718627..83408f3b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -8,21 +8,24 @@ * - Commands: JSON objects with `type` field, optional `id` for correlation * - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error` * - Events: AgentSessionEvent objects streamed as they occur + * - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response */ +import * as crypto from "node:crypto"; import * as readline from "readline"; import type { AgentSession } from "../../core/agent-session.js"; -import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js"; +import type { HookUIContext } from "../../core/hooks/index.js"; +import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js"; // Re-export types for consumers -export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js"; +export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js"; /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(session: AgentSession): Promise { - const output = (obj: RpcResponse | object) => { + const output = (obj: RpcResponse | RpcHookUIRequest | object) => { console.log(JSON.stringify(obj)); }; @@ -41,6 +44,89 @@ export async function runRpcMode(session: AgentSession): Promise { return { id, type: "response", command, success: false, error: message }; }; + // Pending hook UI requests waiting for response + const pendingHookRequests = new Map void; reject: (error: Error) => void }>(); + + /** + * Create a hook UI context that uses the RPC protocol. + */ + const createHookUIContext = (): HookUIContext => ({ + async select(title: string, options: string[]): Promise { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingHookRequests.set(id, { + resolve: (response: RpcHookUIResponse) => { + if ("cancelled" in response && response.cancelled) { + resolve(null); + } else if ("value" in response) { + resolve(response.value); + } else { + resolve(null); + } + }, + reject, + }); + output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest); + }); + }, + + async confirm(title: string, message: string): Promise { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingHookRequests.set(id, { + resolve: (response: RpcHookUIResponse) => { + if ("cancelled" in response && response.cancelled) { + resolve(false); + } else if ("confirmed" in response) { + resolve(response.confirmed); + } else { + resolve(false); + } + }, + reject, + }); + output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest); + }); + }, + + async input(title: string, placeholder?: string): Promise { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingHookRequests.set(id, { + resolve: (response: RpcHookUIResponse) => { + if ("cancelled" in response && response.cancelled) { + resolve(null); + } else if ("value" in response) { + resolve(response.value); + } else { + resolve(null); + } + }, + reject, + }); + output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest); + }); + }, + + notify(message: string, type?: "info" | "warning" | "error"): void { + // Fire and forget - no response needed + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "notify", + message, + notifyType: type, + } as RpcHookUIRequest); + }, + }); + + // Set up hooks with RPC-based UI context + const hookUIContext = createHookUIContext(); + session.setHookUIContext(hookUIContext, (err) => { + output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); + }); + await session.initHooks(); + // Output all agent events as JSON session.subscribe((event) => { output(event); @@ -202,8 +288,8 @@ export async function runRpcMode(session: AgentSession): Promise { } case "branch": { - const text = session.branch(command.entryIndex); - return success(id, "branch", { text }); + const result = await session.branch(command.entryIndex); + return success(id, "branch", { text: result.selectedText, skipped: result.skipped }); } case "get_branch_messages": { @@ -240,7 +326,21 @@ export async function runRpcMode(session: AgentSession): Promise { rl.on("line", async (line: string) => { try { - const command = JSON.parse(line) as RpcCommand; + const parsed = JSON.parse(line); + + // Handle hook UI responses + if (parsed.type === "hook_ui_response") { + const response = parsed as RpcHookUIResponse; + const pending = pendingHookRequests.get(response.id); + if (pending) { + pendingHookRequests.delete(response.id); + pending.resolve(response); + } + return; + } + + // Handle regular commands + const command = parsed as RpcCommand; const response = await handleCommand(command); output(response); } catch (e: any) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 019c800e..8a557c77 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -157,6 +157,33 @@ export type RpcResponse = // Error response (any command can fail) | { id?: string; type: "response"; command: string; success: false; error: string }; +// ============================================================================ +// Hook UI Events (stdout) +// ============================================================================ + +/** Emitted when a hook needs user input */ +export type RpcHookUIRequest = + | { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] } + | { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string } + | { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { + type: "hook_ui_request"; + id: string; + method: "notify"; + message: string; + notifyType?: "info" | "warning" | "error"; + }; + +// ============================================================================ +// Hook UI Commands (stdin) +// ============================================================================ + +/** Response to a hook UI request */ +export type RpcHookUIResponse = + | { type: "hook_ui_response"; id: string; value: string } + | { type: "hook_ui_response"; id: string; confirmed: boolean } + | { type: "hook_ui_response"; id: string; cancelled: true }; + // ============================================================================ // Helper type for extracting command types // ============================================================================