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:
Mario Zechner 2026-01-01 23:56:24 +01:00
parent 484d7e06bb
commit ccdd7bd283
9 changed files with 355 additions and 14 deletions

View file

@ -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 {

View file

@ -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,
};

View file

@ -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"

View file

@ -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 }>;
}
/**

View file

@ -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;
},
};
});
}

View file

@ -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,
});