mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 21:03:42 +00:00
Move hook command execution to AgentSession.prompt()
Hook commands registered via pi.registerCommand() are now handled in AgentSession.prompt() alongside file-based slash commands. This: - Removes duplicate tryHandleHookCommand from interactive-mode and rpc-mode - All modes (interactive, RPC, print) share the same command handling logic - AgentSession._tryExecuteHookCommand() builds CommandContext using: - UI context from hookRunner (set by mode) - sessionManager, modelRegistry from AgentSession - sendMessage via sendHookMessage - exec via exported execCommand - Handler returning string uses it as prompt, undefined returns early Also: - Export execCommand from hooks/runner.ts - Add getUIContext() and getHasUI() to HookRunner - Make HookRunner.emitError() public for error reporting
This commit is contained in:
parent
ba185b0571
commit
c8d9382aaa
6 changed files with 149 additions and 19 deletions
|
|
@ -133,7 +133,7 @@ Behavior:
|
||||||
**Renamed:**
|
**Renamed:**
|
||||||
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
||||||
|
|
||||||
**New: `sendMessage()`**
|
**New: `sendMessage()` ✅**
|
||||||
|
|
||||||
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
||||||
|
|
||||||
|
|
@ -143,34 +143,57 @@ type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'cont
|
||||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Implementation:
|
||||||
- If streaming → queue, append after turn ends (never triggers turn)
|
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
|
||||||
- If idle AND `triggerTurn: true` → append and trigger turn
|
- `message_end` handler routes based on marker presence
|
||||||
- If idle AND `triggerTurn: false` (default) → just append, no turn
|
- `AgentSession.sendHookMessage()` handles three cases:
|
||||||
- TUI updates if `display: true`
|
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
|
||||||
|
- Not streaming + triggerTurn: direct append + `agent.continue()`
|
||||||
|
- Not streaming + no trigger: direct append only
|
||||||
|
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
|
||||||
|
|
||||||
For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly.
|
**New: `appendEntry()` ✅**
|
||||||
|
|
||||||
**New: `registerCommand()`**
|
For hook state persistence (NOT in LLM context):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
appendEntry(customType: string, data?: unknown): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `sessionManager.appendCustomEntry()` directly.
|
||||||
|
|
||||||
|
**New: `registerCommand()` (types ✅, wiring TODO)**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface CommandContext {
|
interface CommandContext {
|
||||||
args: string; // Everything after /commandname
|
args: string; // Everything after /commandname
|
||||||
session: LimitedAgentSession; // No prompt(), use sendMessage()
|
|
||||||
ui: HookUIContext;
|
ui: HookUIContext;
|
||||||
|
hasUI: boolean;
|
||||||
|
cwd: string;
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
modelRegistry: ModelRegistry;
|
||||||
|
sendMessage: HookAPI['sendMessage'];
|
||||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerCommand(name: string, options: {
|
registerCommand(name: string, options: {
|
||||||
description?: string;
|
description?: string;
|
||||||
handler: (ctx: CommandContext) => Promise<string | void>;
|
handler: (ctx: CommandContext) => Promise<string | undefined>;
|
||||||
}): void;
|
}): void;
|
||||||
```
|
```
|
||||||
|
|
||||||
Handler return:
|
Handler return:
|
||||||
- `void` - command completed
|
- `undefined` - command completed
|
||||||
- `string` - text to send as prompt (like file-based slash commands)
|
- `string` - text to send as prompt (like file-based slash commands)
|
||||||
|
|
||||||
|
Wiring (all in AgentSession.prompt()):
|
||||||
|
- [x] Add hook commands to autocomplete in interactive-mode
|
||||||
|
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
|
||||||
|
- [x] Build CommandContext with ui (from hookRunner), exec, sessionManager, etc.
|
||||||
|
- [x] If handler returns string, use as prompt text
|
||||||
|
- [x] If handler returns undefined, return early (no LLM call)
|
||||||
|
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
|
||||||
|
|
||||||
**New: `ui.custom()`**
|
**New: `ui.custom()`**
|
||||||
|
|
||||||
For arbitrary hook UI with keyboard focus:
|
For arbitrary hook UI with keyboard focus:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,16 @@ import {
|
||||||
} from "./compaction.js";
|
} from "./compaction.js";
|
||||||
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
|
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
|
||||||
import { exportSessionToHtml } from "./export-html.js";
|
import { exportSessionToHtml } from "./export-html.js";
|
||||||
import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
|
import {
|
||||||
|
type CommandContext,
|
||||||
|
type ExecOptions,
|
||||||
|
execCommand,
|
||||||
|
type HookMessage,
|
||||||
|
type HookRunner,
|
||||||
|
type SessionEventResult,
|
||||||
|
type TurnEndEvent,
|
||||||
|
type TurnStartEvent,
|
||||||
|
} from "./hooks/index.js";
|
||||||
import type { BashExecutionMessage } from "./messages.js";
|
import type { BashExecutionMessage } from "./messages.js";
|
||||||
import type { ModelRegistry } from "./model-registry.js";
|
import type { ModelRegistry } from "./model-registry.js";
|
||||||
import type { CompactionEntry, SessionManager } from "./session-manager.js";
|
import type { CompactionEntry, SessionManager } from "./session-manager.js";
|
||||||
|
|
@ -441,6 +450,7 @@ export class AgentSession {
|
||||||
/**
|
/**
|
||||||
* Send a prompt to the agent.
|
* Send a prompt to the agent.
|
||||||
* - Validates model and API key before sending
|
* - Validates model and API key before sending
|
||||||
|
* - Handles hook commands (registered via pi.registerCommand)
|
||||||
* - Expands file-based slash commands by default
|
* - Expands file-based slash commands by default
|
||||||
* @throws Error if no model selected or no API key available
|
* @throws Error if no model selected or no API key available
|
||||||
*/
|
*/
|
||||||
|
|
@ -450,6 +460,20 @@ export class AgentSession {
|
||||||
|
|
||||||
const expandCommands = options?.expandSlashCommands ?? true;
|
const expandCommands = options?.expandSlashCommands ?? true;
|
||||||
|
|
||||||
|
// Handle hook commands first (if enabled and text is a slash command)
|
||||||
|
if (expandCommands && text.startsWith("/")) {
|
||||||
|
const result = await this._tryExecuteHookCommand(text);
|
||||||
|
if (result.handled) {
|
||||||
|
if (result.prompt) {
|
||||||
|
// Hook returned text to use as prompt
|
||||||
|
text = result.prompt;
|
||||||
|
} else {
|
||||||
|
// Hook command executed, no prompt to send
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate model
|
// Validate model
|
||||||
if (!this.model) {
|
if (!this.model) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -474,13 +498,65 @@ export class AgentSession {
|
||||||
await this._checkCompaction(lastAssistant, false);
|
await this._checkCompaction(lastAssistant, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand slash commands if requested
|
// Expand file-based slash commands if requested
|
||||||
const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
|
const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
|
||||||
|
|
||||||
await this.agent.prompt(expandedText, options?.attachments);
|
await this.agent.prompt(expandedText, options?.attachments);
|
||||||
await this.waitForRetry();
|
await this.waitForRetry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to execute a hook command. Returns whether it was handled and optional prompt text.
|
||||||
|
*/
|
||||||
|
private async _tryExecuteHookCommand(text: string): Promise<{ handled: boolean; prompt?: string }> {
|
||||||
|
if (!this._hookRunner) return { handled: false };
|
||||||
|
|
||||||
|
// Parse command name and args
|
||||||
|
const spaceIndex = text.indexOf(" ");
|
||||||
|
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
||||||
|
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
||||||
|
|
||||||
|
const command = this._hookRunner.getCommand(commandName);
|
||||||
|
if (!command) return { handled: false };
|
||||||
|
|
||||||
|
// Get UI context from hook runner (set by mode)
|
||||||
|
const uiContext = this._hookRunner.getUIContext();
|
||||||
|
if (!uiContext) return { handled: false };
|
||||||
|
|
||||||
|
// Build command context
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const ctx: CommandContext = {
|
||||||
|
args,
|
||||||
|
ui: uiContext,
|
||||||
|
hasUI: this._hookRunner.getHasUI(),
|
||||||
|
cwd,
|
||||||
|
sessionManager: this.sessionManager,
|
||||||
|
modelRegistry: this._modelRegistry,
|
||||||
|
sendMessage: (message, triggerTurn) => {
|
||||||
|
this.sendHookMessage(message, triggerTurn).catch(() => {
|
||||||
|
// Error handling is done in sendHookMessage
|
||||||
|
});
|
||||||
|
},
|
||||||
|
exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await command.handler(ctx);
|
||||||
|
if (typeof result === "string") {
|
||||||
|
return { handled: true, prompt: result };
|
||||||
|
}
|
||||||
|
return { handled: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Emit error via hook runner
|
||||||
|
this._hookRunner.emitError({
|
||||||
|
hookPath: `command:${commandName}`,
|
||||||
|
event: "command",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a message to be sent after the current response completes.
|
* Queue a message to be sent after the current response completes.
|
||||||
* Use when agent is currently streaming.
|
* Use when agent is currently streaming.
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,18 @@ export {
|
||||||
loadHooks,
|
loadHooks,
|
||||||
type SendMessageHandler,
|
type SendMessageHandler,
|
||||||
} from "./loader.js";
|
} from "./loader.js";
|
||||||
export { type HookErrorListener, HookRunner } from "./runner.js";
|
export { execCommand, type HookErrorListener, HookRunner } from "./runner.js";
|
||||||
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
||||||
export type {
|
export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
|
CommandContext,
|
||||||
CustomMessageRenderer,
|
CustomMessageRenderer,
|
||||||
CustomMessageRenderOptions,
|
CustomMessageRenderOptions,
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
|
ExecOptions,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
FindToolResultEvent,
|
FindToolResultEvent,
|
||||||
GrepToolResultEvent,
|
GrepToolResultEvent,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ export type HookErrorListener = (error: HookError) => void;
|
||||||
* Execute a command and return stdout/stderr/code.
|
* Execute a command and return stdout/stderr/code.
|
||||||
* Supports cancellation via AbortSignal and timeout.
|
* Supports cancellation via AbortSignal and timeout.
|
||||||
*/
|
*/
|
||||||
async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
export async function execCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
options?: ExecOptions,
|
||||||
|
): Promise<ExecResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn(command, args, { cwd, shell: false });
|
const proc = spawn(command, args, { cwd, shell: false });
|
||||||
|
|
||||||
|
|
@ -150,6 +155,20 @@ export class HookRunner {
|
||||||
this.hasUI = hasUI;
|
this.hasUI = hasUI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the UI context (set by mode).
|
||||||
|
*/
|
||||||
|
getUIContext(): HookUIContext | null {
|
||||||
|
return this.uiContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether UI is available.
|
||||||
|
*/
|
||||||
|
getHasUI(): boolean {
|
||||||
|
return this.hasUI;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the paths of all loaded hooks.
|
* Get the paths of all loaded hooks.
|
||||||
*/
|
*/
|
||||||
|
|
@ -196,7 +215,10 @@ export class HookRunner {
|
||||||
/**
|
/**
|
||||||
* Emit an error to all listeners.
|
* Emit an error to all listeners.
|
||||||
*/
|
*/
|
||||||
private emitError(error: HookError): void {
|
/**
|
||||||
|
* Emit an error to all error listeners.
|
||||||
|
*/
|
||||||
|
emitError(error: HookError): void {
|
||||||
for (const listener of this.errorListeners) {
|
for (const listener of this.errorListeners) {
|
||||||
listener(error);
|
listener(error);
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +283,8 @@ export class HookRunner {
|
||||||
*/
|
*/
|
||||||
private createContext(): HookEventContext {
|
private createContext(): HookEventContext {
|
||||||
return {
|
return {
|
||||||
exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options),
|
exec: (command: string, args: string[], options?: ExecOptions) =>
|
||||||
|
execCommand(command, args, this.cwd, options),
|
||||||
ui: this.uiContext,
|
ui: this.uiContext,
|
||||||
hasUI: this.hasUI,
|
hasUI: this.hasUI,
|
||||||
cwd: this.cwd,
|
cwd: this.cwd,
|
||||||
|
|
|
||||||
|
|
@ -179,9 +179,15 @@ export class InteractiveMode {
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Convert hook commands to SlashCommand format
|
||||||
|
const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
|
||||||
|
name: cmd.name,
|
||||||
|
description: cmd.description ?? "(hook command)",
|
||||||
|
}));
|
||||||
|
|
||||||
// Setup autocomplete
|
// Setup autocomplete
|
||||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||||
[...slashCommands, ...fileSlashCommands],
|
[...slashCommands, ...fileSlashCommands, ...hookCommands],
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
fdPath,
|
fdPath,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -182,10 +182,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
|
|
||||||
case "prompt": {
|
case "prompt": {
|
||||||
// Don't await - events will stream
|
// Don't await - events will stream
|
||||||
|
// Hook commands and file slash commands are handled in session.prompt()
|
||||||
session
|
session
|
||||||
.prompt(command.message, {
|
.prompt(command.message, {
|
||||||
attachments: command.attachments,
|
attachments: command.attachments,
|
||||||
expandSlashCommands: false,
|
|
||||||
})
|
})
|
||||||
.catch((e) => output(error(id, "prompt", e.message)));
|
.catch((e) => output(error(id, "prompt", e.message)));
|
||||||
return success(id, "prompt");
|
return success(id, "prompt");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue