mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 22:03:45 +00:00
Add session management and agent state methods to hooks API
HookAPI additions: - pi.newSession(options?) - create new session with optional setup callback - pi.branch(entryId) - branch from a specific entry - pi.navigateTree(targetId, options?) - navigate the session tree HookContext additions: - ctx.isIdle() - check if agent is streaming - ctx.waitForIdle() - wait for agent to finish - ctx.abort() - abort current operation - ctx.hasQueuedMessages() - check for queued user messages These enable hooks to programmatically manage sessions (handoff, templates) and check agent state before showing interactive UI. Fixes #388
This commit is contained in:
parent
484d7e06bb
commit
ccdd7bd283
9 changed files with 355 additions and 14 deletions
|
|
@ -558,6 +558,10 @@ export class AgentSession {
|
|||
sessionManager: this.sessionManager,
|
||||
modelRegistry: this._modelRegistry,
|
||||
model: this.model,
|
||||
isIdle: () => !this.isStreaming,
|
||||
waitForIdle: () => this.agent.waitForIdle(),
|
||||
abort: () => this.abort(),
|
||||
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { createJiti } from "jiti";
|
||||
import { getAgentDir } from "../../config.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { SessionManager } from "../session-manager.js";
|
||||
import { execCommand } from "./runner.js";
|
||||
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js";
|
||||
|
||||
|
|
@ -60,6 +61,27 @@ export type SendMessageHandler = <T = unknown>(
|
|||
*/
|
||||
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||
|
||||
/**
|
||||
* New session handler type for pi.newSession().
|
||||
*/
|
||||
export type NewSessionHandler = (options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Branch handler type for pi.branch().
|
||||
*/
|
||||
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Navigate tree handler type for pi.navigateTree().
|
||||
*/
|
||||
export type NavigateTreeHandler = (
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean },
|
||||
) => Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Registered handlers for a loaded hook.
|
||||
*/
|
||||
|
|
@ -78,6 +100,12 @@ export interface LoadedHook {
|
|||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
/** Set the append entry handler for this hook's pi.appendEntry() */
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
/** Set the new session handler for this hook's pi.newSession() */
|
||||
setNewSessionHandler: (handler: NewSessionHandler) => void;
|
||||
/** Set the branch handler for this hook's pi.branch() */
|
||||
setBranchHandler: (handler: BranchHandler) => void;
|
||||
/** Set the navigate tree handler for this hook's pi.navigateTree() */
|
||||
setNavigateTreeHandler: (handler: NavigateTreeHandler) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,7 +154,7 @@ function resolveHookPath(hookPath: string, cwd: string): string {
|
|||
|
||||
/**
|
||||
* Create a HookAPI instance that collects handlers, renderers, and commands.
|
||||
* Returns the API, maps, and a function to set the send message handler later.
|
||||
* Returns the API, maps, and functions to set handlers later.
|
||||
*/
|
||||
function createHookAPI(
|
||||
handlers: Map<string, HandlerFn[]>,
|
||||
|
|
@ -137,6 +165,9 @@ function createHookAPI(
|
|||
commands: Map<string, RegisteredCommand>;
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
setNewSessionHandler: (handler: NewSessionHandler) => void;
|
||||
setBranchHandler: (handler: BranchHandler) => void;
|
||||
setNavigateTreeHandler: (handler: NavigateTreeHandler) => void;
|
||||
} {
|
||||
let sendMessageHandler: SendMessageHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
|
|
@ -144,6 +175,18 @@ function createHookAPI(
|
|||
let appendEntryHandler: AppendEntryHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
let newSessionHandler: NewSessionHandler = async () => {
|
||||
// Default no-op until mode sets the handler
|
||||
return { cancelled: false };
|
||||
};
|
||||
let branchHandler: BranchHandler = async () => {
|
||||
// Default no-op until mode sets the handler
|
||||
return { cancelled: false };
|
||||
};
|
||||
let navigateTreeHandler: NavigateTreeHandler = async () => {
|
||||
// Default no-op until mode sets the handler
|
||||
return { cancelled: false };
|
||||
};
|
||||
const messageRenderers = new Map<string, HookMessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
|
||||
|
|
@ -170,6 +213,15 @@ function createHookAPI(
|
|||
exec(command: string, args: string[], options?: ExecOptions) {
|
||||
return execCommand(command, args, options?.cwd ?? cwd, options);
|
||||
},
|
||||
newSession(options) {
|
||||
return newSessionHandler(options);
|
||||
},
|
||||
branch(entryId) {
|
||||
return branchHandler(entryId);
|
||||
},
|
||||
navigateTree(targetId, options) {
|
||||
return navigateTreeHandler(targetId, options);
|
||||
},
|
||||
} as HookAPI;
|
||||
|
||||
return {
|
||||
|
|
@ -182,6 +234,15 @@ function createHookAPI(
|
|||
setAppendEntryHandler: (handler: AppendEntryHandler) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
setNewSessionHandler: (handler: NewSessionHandler) => {
|
||||
newSessionHandler = handler;
|
||||
},
|
||||
setBranchHandler: (handler: BranchHandler) => {
|
||||
branchHandler = handler;
|
||||
},
|
||||
setNavigateTreeHandler: (handler: NavigateTreeHandler) => {
|
||||
navigateTreeHandler = handler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -209,10 +270,16 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
|||
|
||||
// Create handlers map and API
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
|
||||
handlers,
|
||||
cwd,
|
||||
);
|
||||
const {
|
||||
api,
|
||||
messageRenderers,
|
||||
commands,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setNewSessionHandler,
|
||||
setBranchHandler,
|
||||
setNavigateTreeHandler,
|
||||
} = createHookAPI(handlers, cwd);
|
||||
|
||||
// Call factory to register handlers
|
||||
factory(api);
|
||||
|
|
@ -226,6 +293,9 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
|||
commands,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
setNewSessionHandler,
|
||||
setBranchHandler,
|
||||
setNavigateTreeHandler,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import type { Model } from "@mariozechner/pi-ai";
|
|||
import { theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { SessionManager } from "../session-manager.js";
|
||||
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
|
||||
import type {
|
||||
AppendEntryHandler,
|
||||
BranchHandler,
|
||||
LoadedHook,
|
||||
NavigateTreeHandler,
|
||||
NewSessionHandler,
|
||||
SendMessageHandler,
|
||||
} from "./loader.js";
|
||||
import type {
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
|
|
@ -61,6 +68,10 @@ export class HookRunner {
|
|||
private modelRegistry: ModelRegistry;
|
||||
private errorListeners: Set<HookErrorListener> = new Set();
|
||||
private getModel: () => Model<any> | undefined = () => undefined;
|
||||
private isIdleFn: () => boolean = () => true;
|
||||
private waitForIdleFn: () => Promise<void> = async () => {};
|
||||
private abortFn: () => Promise<void> = async () => {};
|
||||
private hasQueuedMessagesFn: () => boolean = () => false;
|
||||
|
||||
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
|
||||
this.hooks = hooks;
|
||||
|
|
@ -82,15 +93,42 @@ export class HookRunner {
|
|||
sendMessageHandler: SendMessageHandler;
|
||||
/** Handler for hooks to append entries */
|
||||
appendEntryHandler: AppendEntryHandler;
|
||||
/** Handler for hooks to create new sessions */
|
||||
newSessionHandler?: NewSessionHandler;
|
||||
/** Handler for hooks to branch sessions */
|
||||
branchHandler?: BranchHandler;
|
||||
/** Handler for hooks to navigate the session tree */
|
||||
navigateTreeHandler?: NavigateTreeHandler;
|
||||
/** Function to check if agent is idle */
|
||||
isIdle?: () => boolean;
|
||||
/** Function to wait for agent to be idle */
|
||||
waitForIdle?: () => Promise<void>;
|
||||
/** Function to abort current operation */
|
||||
abort?: () => Promise<void>;
|
||||
/** Function to check if there are queued messages */
|
||||
hasQueuedMessages?: () => boolean;
|
||||
/** UI context for interactive prompts */
|
||||
uiContext?: HookUIContext;
|
||||
/** Whether UI is available */
|
||||
hasUI?: boolean;
|
||||
}): void {
|
||||
this.getModel = options.getModel;
|
||||
this.isIdleFn = options.isIdle ?? (() => true);
|
||||
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
|
||||
this.abortFn = options.abort ?? (async () => {});
|
||||
this.hasQueuedMessagesFn = options.hasQueuedMessages ?? (() => false);
|
||||
for (const hook of this.hooks) {
|
||||
hook.setSendMessageHandler(options.sendMessageHandler);
|
||||
hook.setAppendEntryHandler(options.appendEntryHandler);
|
||||
if (options.newSessionHandler) {
|
||||
hook.setNewSessionHandler(options.newSessionHandler);
|
||||
}
|
||||
if (options.branchHandler) {
|
||||
hook.setBranchHandler(options.branchHandler);
|
||||
}
|
||||
if (options.navigateTreeHandler) {
|
||||
hook.setNavigateTreeHandler(options.navigateTreeHandler);
|
||||
}
|
||||
}
|
||||
this.uiContext = options.uiContext ?? noOpUIContext;
|
||||
this.hasUI = options.hasUI ?? false;
|
||||
|
|
@ -203,6 +241,10 @@ export class HookRunner {
|
|||
sessionManager: this.sessionManager,
|
||||
modelRegistry: this.modelRegistry,
|
||||
model: this.getModel(),
|
||||
isIdle: () => this.isIdleFn(),
|
||||
waitForIdle: () => this.waitForIdleFn(),
|
||||
abort: () => this.abortFn(),
|
||||
hasQueuedMessages: () => this.hasQueuedMessagesFn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -211,15 +253,9 @@ export class HookRunner {
|
|||
*/
|
||||
private isSessionBeforeEvent(
|
||||
type: string,
|
||||
): type is
|
||||
| "session_before_switch"
|
||||
| "session_before_new"
|
||||
| "session_before_branch"
|
||||
| "session_before_compact"
|
||||
| "session_before_tree" {
|
||||
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
|
||||
return (
|
||||
type === "session_before_switch" ||
|
||||
type === "session_before_new" ||
|
||||
type === "session_before_branch" ||
|
||||
type === "session_before_compact" ||
|
||||
type === "session_before_tree"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ import type { CompactionPreparation, CompactionResult } from "../compaction/inde
|
|||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
ReadonlySessionManager,
|
||||
SessionEntry,
|
||||
SessionManager,
|
||||
} from "../session-manager.js";
|
||||
|
||||
import type { EditToolDetails } from "../tools/edit.js";
|
||||
import type {
|
||||
|
|
@ -140,6 +146,14 @@ export interface HookContext {
|
|||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** Wait for the agent to finish streaming */
|
||||
waitForIdle(): Promise<void>;
|
||||
/** Abort the current agent operation */
|
||||
abort(): Promise<void>;
|
||||
/** Whether there are queued messages waiting to be processed */
|
||||
hasQueuedMessages(): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -673,6 +687,45 @@ export interface HookAPI {
|
|||
* Supports timeout and abort signal.
|
||||
*/
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
|
||||
/**
|
||||
* Start a new session, optionally with a setup callback to initialize it.
|
||||
* The setup callback receives a writable SessionManager for the new session.
|
||||
*
|
||||
* @param options.parentSession - Path to parent session for lineage tracking
|
||||
* @param options.setup - Async callback to initialize the new session (e.g., append messages)
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the new session
|
||||
*
|
||||
* @example
|
||||
* // Handoff: summarize current session and start fresh with context
|
||||
* await pi.newSession({
|
||||
* parentSession: ctx.sessionManager.getSessionFile(),
|
||||
* setup: async (sm) => {
|
||||
* sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] });
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
newSession(options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Branch from a specific entry, creating a new session file.
|
||||
*
|
||||
* @param entryId - ID of the entry to branch from
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the branch
|
||||
*/
|
||||
branch(entryId: string): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/**
|
||||
* Navigate to a different point in the session tree (in-place).
|
||||
*
|
||||
* @param targetId - ID of the entry to navigate to
|
||||
* @param options.summarize - Whether to summarize the abandoned branch
|
||||
* @returns Object with `cancelled: true` if a hook cancelled the navigation
|
||||
*/
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -345,6 +345,11 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
const commands = new Map<string, any>();
|
||||
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
|
||||
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
|
||||
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
|
||||
let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
|
||||
let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
|
||||
cancelled: false,
|
||||
});
|
||||
|
||||
const api = {
|
||||
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
|
|
@ -364,6 +369,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
registerCommand: (name: string, options: any) => {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
newSession: (options?: any) => newSessionHandler(options),
|
||||
branch: (entryId: string) => branchHandler(entryId),
|
||||
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
|
||||
};
|
||||
|
||||
def.factory(api as any);
|
||||
|
|
@ -380,6 +388,15 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => {
|
||||
newSessionHandler = handler;
|
||||
},
|
||||
setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => {
|
||||
branchHandler = handler;
|
||||
},
|
||||
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
|
||||
navigateTreeHandler = handler;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,6 +412,72 @@ export class InteractiveMode {
|
|||
appendEntryHandler: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
newSessionHandler: async (options) => {
|
||||
// Stop any loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Create new session
|
||||
const success = await this.session.newSession({ parentSession: options?.parentSession });
|
||||
if (!success) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Call setup callback if provided
|
||||
if (options?.setup) {
|
||||
await options.setup(this.sessionManager);
|
||||
}
|
||||
|
||||
// Clear UI state
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
|
||||
this.ui.requestRender();
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
branchHandler: async (entryId) => {
|
||||
const result = await this.session.branch(entryId);
|
||||
if (result.cancelled) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
this.editor.setText(result.selectedText);
|
||||
this.showStatus("Branched to new session");
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
navigateTreeHandler: async (targetId, options) => {
|
||||
const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
|
||||
if (result.cancelled) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
if (result.editorText) {
|
||||
this.editor.setText(result.editorText);
|
||||
}
|
||||
this.showStatus("Navigated to selected point");
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||
abort: () => this.session.abort(),
|
||||
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
|
||||
uiContext,
|
||||
hasUI: true,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue