mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 22:03:45 +00:00
Add before/after session events with cancellation support
- Merge branch event into session with before_branch/branch reasons
- Add before_switch, before_clear, shutdown reasons
- before_* events can be cancelled with { cancel: true }
- Update RPC commands to return cancelled status
- Add shutdown event on process exit
- New example hooks: confirm-destructive, dirty-repo-guard, auto-commit-on-exit
fixes #278
This commit is contained in:
parent
99081fce30
commit
42d7d9d9b6
20 changed files with 426 additions and 124 deletions
|
|
@ -21,7 +21,7 @@ import { type BashResult, executeBash as executeBashCommand } from "./bash-execu
|
|||
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
||||
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
|
||||
import { exportSessionToHtml } from "./export-html.js";
|
||||
import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
|
||||
import type { HookRunner, SessionEventResult, TurnEndEvent, 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";
|
||||
|
|
@ -501,9 +501,26 @@ export class AgentSession {
|
|||
* Reset agent and session to start fresh.
|
||||
* Clears all messages and starts a new session.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @returns true if reset completed, false if cancelled by hook
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
async reset(): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
const entries = this.sessionManager.loadEntries();
|
||||
|
||||
// Emit before_clear event (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: this.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "before_clear",
|
||||
})) as SessionEventResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this._disconnectFromAgent();
|
||||
await this.abort();
|
||||
|
|
@ -526,6 +543,7 @@ export class AgentSession {
|
|||
|
||||
// Emit session event to custom tools
|
||||
await this._emitToolSessionEvent("clear", previousSessionFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1142,9 +1160,26 @@ export class AgentSession {
|
|||
* Switch to a different session file.
|
||||
* Aborts current operation, loads messages, restores model/thinking.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @returns true if switch completed, false if cancelled by hook
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<void> {
|
||||
async switchSession(sessionPath: string): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
const oldEntries = this.sessionManager.loadEntries();
|
||||
|
||||
// Emit before_switch event (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
type: "session",
|
||||
entries: oldEntries,
|
||||
sessionFile: this.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "before_switch",
|
||||
})) as SessionEventResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this._disconnectFromAgent();
|
||||
await this.abort();
|
||||
|
|
@ -1191,18 +1226,19 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
this._reconnectToAgent();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branch from a specific entry index.
|
||||
* Emits branch event to hooks, which can control the branch behavior.
|
||||
* Emits before_branch/branch session events to hooks.
|
||||
*
|
||||
* @param entryIndex Index into session entries to branch from
|
||||
* @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
|
||||
* - cancelled: True if a hook cancelled the branch
|
||||
*/
|
||||
async branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }> {
|
||||
async branch(entryIndex: number): Promise<{ selectedText: string; cancelled: boolean }> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
const entries = this.sessionManager.loadEntries();
|
||||
const selectedEntry = entries[entryIndex];
|
||||
|
|
@ -1213,19 +1249,20 @@ 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,
|
||||
// Emit before_branch event (can be cancelled)
|
||||
if (this._hookRunner?.hasHandlers("session")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
})) as BranchEventResult | undefined;
|
||||
}
|
||||
sessionFile: this.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "before_branch",
|
||||
targetTurnIndex: entryIndex,
|
||||
})) as SessionEventResult | undefined;
|
||||
|
||||
// If hook says skip conversation restore, don't branch
|
||||
if (hookResult?.skipConversationRestore) {
|
||||
return { selectedText, skipped: true };
|
||||
if (result?.cancel) {
|
||||
return { selectedText, cancelled: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Create branched session (returns null in --no-session mode)
|
||||
|
|
@ -1240,7 +1277,7 @@ export class AgentSession {
|
|||
const newEntries = this.sessionManager.loadEntries();
|
||||
const loaded = loadSessionFromEntries(newEntries);
|
||||
|
||||
// Emit session event to hooks (in --no-session mode, both files are null)
|
||||
// Emit branch event to hooks (after branch completes)
|
||||
if (this._hookRunner) {
|
||||
this._hookRunner.setSessionFile(newSessionFile);
|
||||
await this._hookRunner.emit({
|
||||
|
|
@ -1248,7 +1285,8 @@ export class AgentSession {
|
|||
entries: newEntries,
|
||||
sessionFile: newSessionFile,
|
||||
previousSessionFile,
|
||||
reason: "switch",
|
||||
reason: "branch",
|
||||
targetTurnIndex: entryIndex,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1257,7 +1295,7 @@ export class AgentSession {
|
|||
|
||||
this.agent.replaceMessages(loaded.messages);
|
||||
|
||||
return { selectedText, skipped: false };
|
||||
return { selectedText, cancelled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ export type {
|
|||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
ExecResult,
|
||||
|
|
@ -21,6 +19,7 @@ export type {
|
|||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
SessionEventResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import type { LoadedHook, SendHandler } from "./loader.js";
|
||||
import type {
|
||||
BranchEventResult,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookUIContext,
|
||||
SessionEventResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEventResult,
|
||||
|
|
@ -217,11 +217,11 @@ export class HookRunner {
|
|||
|
||||
/**
|
||||
* Emit an event to all hooks.
|
||||
* Returns the result from branch/tool_result events (if any handler returns one).
|
||||
* Returns the result from session/tool_result events (if any handler returns one).
|
||||
*/
|
||||
async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
|
||||
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: BranchEventResult | ToolResultEventResult | undefined;
|
||||
let result: SessionEventResult | ToolResultEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(event.type);
|
||||
|
|
@ -233,9 +233,13 @@ export class HookRunner {
|
|||
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;
|
||||
// For session events, capture the result (for before_* cancellation)
|
||||
if (event.type === "session" && handlerResult) {
|
||||
result = handlerResult as SessionEventResult;
|
||||
// If cancelled, stop processing further hooks
|
||||
if (result.cancel) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// For tool_result events, capture the result
|
||||
|
|
|
|||
|
|
@ -90,11 +90,9 @@ export interface HookEventContext {
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Event data for session event.
|
||||
* Fired on startup and when session changes (switch or clear).
|
||||
* Note: branch has its own event that fires BEFORE the branch happens.
|
||||
* Base fields shared by all session events.
|
||||
*/
|
||||
export interface SessionEvent {
|
||||
interface SessionEventBase {
|
||||
type: "session";
|
||||
/** All session entries (including pre-compaction history) */
|
||||
entries: SessionEntry[];
|
||||
|
|
@ -102,10 +100,32 @@ export interface SessionEvent {
|
|||
sessionFile: string | null;
|
||||
/** Previous session file path, or null for "start" and "clear" */
|
||||
previousSessionFile: string | null;
|
||||
/** Reason for the session event */
|
||||
reason: "start" | "switch" | "clear";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for session events.
|
||||
* Discriminated union based on reason.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - start: Initial session load
|
||||
* - before_switch / switch: Session switch (e.g., /session command)
|
||||
* - before_clear / clear: Session clear (e.g., /clear command)
|
||||
* - before_branch / branch: Session branch (e.g., /branch command)
|
||||
* - shutdown: Process exit (SIGINT/SIGTERM)
|
||||
*
|
||||
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
|
||||
* Other events fire after the action completes.
|
||||
*/
|
||||
export type SessionEvent =
|
||||
| (SessionEventBase & {
|
||||
reason: "start" | "switch" | "clear" | "before_switch" | "before_clear" | "shutdown";
|
||||
})
|
||||
| (SessionEventBase & {
|
||||
reason: "branch" | "before_branch";
|
||||
/** Index of the turn to branch from */
|
||||
targetTurnIndex: number;
|
||||
});
|
||||
|
||||
/**
|
||||
* Event data for agent_start event.
|
||||
* Fired when an agent loop starts (once per user prompt).
|
||||
|
|
@ -256,17 +276,6 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
|||
return e.toolName === "ls";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
@ -277,8 +286,7 @@ export type HookEvent =
|
|||
| TurnStartEvent
|
||||
| TurnEndEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| BranchEvent;
|
||||
| ToolResultEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Event Results
|
||||
|
|
@ -309,12 +317,12 @@ export interface ToolResultEventResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type for branch event handlers.
|
||||
* Allows hooks to control branch behavior.
|
||||
* Return type for session event handlers.
|
||||
* Allows hooks to cancel "before_*" actions.
|
||||
*/
|
||||
export interface BranchEventResult {
|
||||
/** If true, skip restoring the conversation (only restore code) */
|
||||
skipConversationRestore?: boolean;
|
||||
export interface SessionEventResult {
|
||||
/** If true, cancel the pending action (switch, clear, or branch) */
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -331,14 +339,14 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
|
|||
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
|
||||
*/
|
||||
export interface HookAPI {
|
||||
on(event: "session", handler: HookHandler<SessionEvent>): void;
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
||||
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
|
||||
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: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
|
||||
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
|
||||
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
|
||||
|
||||
/**
|
||||
* Send a message to the agent.
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
|
||||
// Read before edit guideline
|
||||
if (hasRead && hasEdit) {
|
||||
guidelinesList.push("Use read to examine files before editing");
|
||||
guidelinesList.push("Use read to examine files before editing. You must use this tool instead of cat or sed.");
|
||||
}
|
||||
|
||||
// Edit guideline
|
||||
|
|
|
|||
|
|
@ -40,8 +40,6 @@ export type {
|
|||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
FindToolResultEvent,
|
||||
|
|
@ -54,6 +52,7 @@ export type {
|
|||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
SessionEventResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -1162,8 +1162,7 @@ export class InteractiveMode {
|
|||
private handleCtrlC(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastSigintTime < 500) {
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
void this.shutdown();
|
||||
} else {
|
||||
this.clearEditor();
|
||||
this.lastSigintTime = now;
|
||||
|
|
@ -1172,6 +1171,27 @@ export class InteractiveMode {
|
|||
|
||||
private handleCtrlD(): void {
|
||||
// Only called when editor is empty (enforced by CustomEditor)
|
||||
void this.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the agent.
|
||||
* Emits shutdown event to hooks, then exits.
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
// Emit shutdown event to hooks
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (hookRunner?.hasHandlers("session")) {
|
||||
const entries = this.sessionManager.loadEntries();
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: this.session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "shutdown",
|
||||
});
|
||||
}
|
||||
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1496,8 +1516,8 @@ export class InteractiveMode {
|
|||
userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
|
||||
async (entryIndex) => {
|
||||
const result = await this.session.branch(entryIndex);
|
||||
if (result.skipped) {
|
||||
// Hook requested to skip conversation restore
|
||||
if (result.cancelled) {
|
||||
// Hook cancelled the branch
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
|
|
@ -1533,8 +1553,7 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
},
|
||||
() => {
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
void this.shutdown();
|
||||
},
|
||||
);
|
||||
return { component: selector, focus: selector.getSessionList() };
|
||||
|
|
|
|||
|
|
@ -186,9 +186,11 @@ export class RpcClient {
|
|||
|
||||
/**
|
||||
* Reset session (clear all messages).
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the reset
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
await this.send({ type: "reset" });
|
||||
async reset(): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "reset" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -311,15 +313,18 @@ export class RpcClient {
|
|||
|
||||
/**
|
||||
* Switch to a different session file.
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the switch
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<void> {
|
||||
await this.send({ type: "switch_session", sessionPath });
|
||||
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "switch_session", sessionPath });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch from a specific message.
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
|
||||
*/
|
||||
async branch(entryIndex: number): Promise<{ text: string }> {
|
||||
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryIndex });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
}
|
||||
|
||||
case "reset": {
|
||||
await session.reset();
|
||||
return success(id, "reset");
|
||||
const cancelled = !(await session.reset());
|
||||
return success(id, "reset", { cancelled });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
|
|
@ -339,13 +339,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
}
|
||||
|
||||
case "switch_session": {
|
||||
await session.switchSession(command.sessionPath);
|
||||
return success(id, "switch_session");
|
||||
const cancelled = !(await session.switchSession(command.sessionPath));
|
||||
return success(id, "switch_session", { cancelled });
|
||||
}
|
||||
|
||||
case "branch": {
|
||||
const result = await session.branch(command.entryIndex);
|
||||
return success(id, "branch", { text: result.selectedText, skipped: result.skipped });
|
||||
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
case "get_branch_messages": {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export type RpcResponse =
|
|||
| { id?: string; type: "response"; command: "prompt"; success: true }
|
||||
| { id?: string; type: "response"; command: "queue_message"; success: true }
|
||||
| { id?: string; type: "response"; command: "abort"; success: true }
|
||||
| { id?: string; type: "response"; command: "reset"; success: true }
|
||||
| { id?: string; type: "response"; command: "reset"; success: true; data: { cancelled: boolean } }
|
||||
|
||||
// State
|
||||
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
||||
|
|
@ -142,8 +142,8 @@ export type RpcResponse =
|
|||
// Session
|
||||
| { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats }
|
||||
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
|
||||
| { id?: string; type: "response"; command: "switch_session"; success: true }
|
||||
| { id?: string; type: "response"; command: "branch"; success: true; data: { text: string } }
|
||||
| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
|
||||
| { id?: string; type: "response"; command: "branch"; success: true; data: { text: string; cancelled: boolean } }
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue