mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 04:01:56 +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
|
|
@ -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