mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
feat(coding-agent): implement hooks system
- Add hooks infrastructure in core/hooks/ (loader, runner, types)
- HookUIContext interface with mode-specific implementations
- Interactive mode: TUI-based selector/input/confirm dialogs
- RPC mode: JSON protocol for hook UI requests/responses
- Print mode: no-op UI context (hooks run but can't prompt)
- AgentSession.branch() now async, returns { selectedText, skipped }
- Settings: hooks[] and hookTimeout configuration
- Export hook types from package for hook authors
Based on PR #147 proposal, adapted for new architecture.
This commit is contained in:
parent
195760d8ee
commit
04d59f31ea
17 changed files with 1264 additions and 126 deletions
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<any>; 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<void> => {
|
||||
// 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<void> {
|
||||
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<Array<{ path: string; error: string }>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
packages/coding-agent/src/core/hooks/index.ts
Normal file
17
packages/coding-agent/src/core/hooks/index.ts
Normal file
|
|
@ -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";
|
||||
138
packages/coding-agent/src/core/hooks/loader.ts
Normal file
138
packages/coding-agent/src/core/hooks/loader.ts
Normal file
|
|
@ -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<unknown>;
|
||||
|
||||
/**
|
||||
* 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<string, HandlerFn[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, HandlerFn[]>): 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<string, HandlerFn[]>();
|
||||
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<LoadHooksResult> {
|
||||
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 };
|
||||
}
|
||||
157
packages/coding-agent/src/core/hooks/runner.ts
Normal file
157
packages/coding-agent/src/core/hooks/runner.ts
Normal file
|
|
@ -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<ExecResult> {
|
||||
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<never>; clear: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const promise = new Promise<never>((_, 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<HookErrorListener> = 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<BranchEventResult | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
172
packages/coding-agent/src/core/hooks/types.ts
Normal file
172
packages/coding-agent/src/core/hooks/types.ts
Normal file
|
|
@ -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<string | null>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog.
|
||||
* @returns true if confirmed, false if cancelled
|
||||
*/
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Show a text input dialog.
|
||||
* @returns User input, or null if cancelled
|
||||
*/
|
||||
input(title: string, placeholder?: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 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<ExecResult>;
|
||||
/** 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<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
|
||||
|
||||
/**
|
||||
* HookAPI passed to hook factory functions.
|
||||
* Hooks use pi.on() to subscribe to events.
|
||||
*/
|
||||
export interface HookAPI {
|
||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
// 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<string | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -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<never> {
|
||||
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<never> {
|
|||
return { id, type: "response", command, success: false, error: message };
|
||||
};
|
||||
|
||||
// Pending hook UI requests waiting for response
|
||||
const pendingHookRequests = new Map<string, { resolve: (value: any) => 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<string | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<never> {
|
|||
}
|
||||
|
||||
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<never> {
|
|||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue