WIP: Add hook API for dynamic tool control with plan-mode hook example

- Add pi.getTools() and pi.setTools(toolNames) to HookAPI
- Hooks can now enable/disable tools dynamically
- Changes take effect on next agent turn

New example hook: plan-mode.ts
- Claude Code-style read-only exploration mode
- /plan command toggles plan mode on/off
- Plan mode tools: read, bash, grep, find, ls
- Edit/write tools disabled in plan mode
- Injects context telling agent about restrictions
- After each response, prompts to execute/stay/refine
- State persists across sessions
This commit is contained in:
Helmut Januschka 2026-01-03 09:31:39 +01:00 committed by Mario Zechner
parent 5b95ccf830
commit 059292ead1
14 changed files with 304 additions and 8 deletions

View file

@ -37,6 +37,8 @@
### Added ### Added
- `$ARGUMENTS` syntax for custom slash commands as alternative to `$@` for all arguments joined. Aligns with patterns used by Claude, Codex, and OpenCode. Both syntaxes remain fully supported. ([#418](https://github.com/badlogic/pi-mono/pull/418) by [@skuridin](https://github.com/skuridin)) - `$ARGUMENTS` syntax for custom slash commands as alternative to `$@` for all arguments joined. Aligns with patterns used by Claude, Codex, and OpenCode. Both syntaxes remain fully supported. ([#418](https://github.com/badlogic/pi-mono/pull/418) by [@skuridin](https://github.com/skuridin))
- Hook API: `pi.getTools()` and `pi.setTools(toolNames)` for dynamically enabling/disabling tools from hooks
- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode with `/plan` command
### Changed ### Changed

View file

@ -752,6 +752,29 @@ const result = await pi.exec("git", ["status"], {
// result.stdout, result.stderr, result.code, result.killed // result.stdout, result.stderr, result.code, result.killed
``` ```
### pi.getTools()
Get the names of currently active tools:
```typescript
const toolNames = pi.getTools();
// ["read", "bash", "edit", "write"]
```
### pi.setTools(toolNames)
Set the active tools by name. Changes take effect on the next agent turn.
```typescript
// Switch to read-only mode (plan mode)
pi.setTools(["read", "bash", "grep", "find", "ls"]);
// Restore full access
pi.setTools(["read", "bash", "edit", "write"]);
```
Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored.
## Examples ## Examples
### Permission Gate ### Permission Gate

View file

@ -16,6 +16,7 @@ cp permission-gate.ts ~/.pi/agent/hooks/
| Hook | Description | | Hook | Description |
|------|-------------| |------|-------------|
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | | `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |

View file

@ -0,0 +1,119 @@
/**
* Plan Mode Hook
*
* Provides a Claude Code-style "plan mode" for safe code exploration.
* When enabled, the agent can only use read-only tools and cannot modify files.
*
* Features:
* - /plan command to toggle plan mode
* - In plan mode: only read, bash (read-only), grep, find, ls are available
* - Injects system context telling the agent about the restrictions
* - After each agent response, prompts to execute the plan or continue planning
*
* Usage:
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
* 2. Use /plan to toggle plan mode on/off
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
// Read-only tools for plan mode
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
// Full set of tools for normal mode
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
export default function planModeHook(pi: HookAPI) {
// Track plan mode state
let planModeEnabled = false;
// Register /plan command
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => {
planModeEnabled = !planModeEnabled;
if (planModeEnabled) {
// Switch to read-only tools
pi.setTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
// Switch back to normal tools
pi.setTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
},
});
// Inject plan mode context at the start of each turn via before_agent_start
pi.on("before_agent_start", async () => {
if (!planModeEnabled) return;
// Return a message to inject into context
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash (read-only commands), grep, find, ls
- You CANNOT use: edit, write (file modifications are disabled)
- Focus on analysis, planning, and understanding the codebase
Your task is to explore, analyze, and create a detailed plan.
Do NOT attempt to make changes - just describe what you would do.
When you have a complete plan, I will switch to normal mode to execute it.`,
display: false, // Don't show in TUI, just inject into context
},
};
});
// After agent finishes, offer to execute the plan
pi.on("agent_end", async (_event, ctx) => {
if (!planModeEnabled) return;
if (!ctx.hasUI) return;
const choice = await ctx.ui.select("Plan mode - what next?", [
"Execute the plan",
"Stay in plan mode",
"Refine the plan",
]);
if (choice === "Execute the plan") {
// Switch to normal mode
planModeEnabled = false;
pi.setTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Switched to normal mode. Full access restored.");
// Set editor text to prompt execution
ctx.ui.setEditorText("Execute the plan you just created. Proceed step by step.");
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.input("What should be refined?");
if (refinement) {
ctx.ui.setEditorText(refinement);
}
}
// "Stay in plan mode" - do nothing, just continue
});
// Persist plan mode state across sessions
pi.on("session_start", async (_event, ctx) => {
// Check if there's persisted plan mode state
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean } } | undefined;
if (planModeEntry?.data?.enabled) {
planModeEnabled = true;
pi.setTools(PLAN_MODE_TOOLS);
}
});
// Save state when plan mode changes (via tool_call or other events)
pi.on("turn_start", async () => {
// Persist current state
pi.appendEntry("plan-mode", { enabled: planModeEnabled });
});
}

View file

@ -13,7 +13,14 @@
* Modes use this class and add their own I/O layer on top. * Modes use this class and add their own I/O layer on top.
*/ */
import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type {
Agent,
AgentEvent,
AgentMessage,
AgentState,
AgentTool,
ThinkingLevel,
} from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import { getAuthPath } from "../config.js"; import { getAuthPath } from "../config.js";
@ -75,6 +82,8 @@ export interface AgentSessionConfig {
skillsSettings?: Required<SkillsSettings>; skillsSettings?: Required<SkillsSettings>;
/** Model registry for API key resolution and model discovery */ /** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry; modelRegistry: ModelRegistry;
/** Tool registry for hook getTools/setTools - maps name to tool */
toolRegistry?: Map<string, AgentTool>;
} }
/** Options for AgentSession.prompt() */ /** Options for AgentSession.prompt() */
@ -174,6 +183,9 @@ export class AgentSession {
// Model registry for API key resolution // Model registry for API key resolution
private _modelRegistry: ModelRegistry; private _modelRegistry: ModelRegistry;
// Tool registry for hook getTools/setTools
private _toolRegistry: Map<string, AgentTool>;
constructor(config: AgentSessionConfig) { constructor(config: AgentSessionConfig) {
this.agent = config.agent; this.agent = config.agent;
this.sessionManager = config.sessionManager; this.sessionManager = config.sessionManager;
@ -184,6 +196,7 @@ export class AgentSession {
this._customTools = config.customTools ?? []; this._customTools = config.customTools ?? [];
this._skillsSettings = config.skillsSettings; this._skillsSettings = config.skillsSettings;
this._modelRegistry = config.modelRegistry; this._modelRegistry = config.modelRegistry;
this._toolRegistry = config.toolRegistry ?? new Map();
// Always subscribe to agent events for internal handling // Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic) // (session persistence, hooks, auto-compaction, retry logic)
@ -417,6 +430,30 @@ export class AgentSession {
return this.agent.state.isStreaming; return this.agent.state.isStreaming;
} }
/**
* Get the names of currently active tools.
* Returns the names of tools currently set on the agent.
*/
getActiveToolNames(): string[] {
return this.agent.state.tools.map((t) => t.name);
}
/**
* Set active tools by name.
* Only tools in the registry can be enabled. Unknown tool names are ignored.
* Changes take effect on the next agent turn.
*/
setActiveToolsByName(toolNames: string[]): void {
const tools: AgentTool[] = [];
for (const name of toolNames) {
const tool = this._toolRegistry.get(name);
if (tool) {
tools.push(tool);
}
}
this.agent.setTools(tools);
}
/** Whether auto-compaction is currently running */ /** Whether auto-compaction is currently running */
get isCompacting(): boolean { get isCompacting(): boolean {
return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;

View file

@ -4,11 +4,13 @@ export {
loadHooks, loadHooks,
type AppendEntryHandler, type AppendEntryHandler,
type BranchHandler, type BranchHandler,
type GetToolsHandler,
type LoadedHook, type LoadedHook,
type LoadHooksResult, type LoadHooksResult,
type NavigateTreeHandler, type NavigateTreeHandler,
type NewSessionHandler, type NewSessionHandler,
type SendMessageHandler, type SendMessageHandler,
type SetToolsHandler,
} from "./loader.js"; } from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";

View file

@ -61,6 +61,16 @@ export type SendMessageHandler = <T = unknown>(
*/ */
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void; export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* Get tools handler type for pi.getTools().
*/
export type GetToolsHandler = () => string[];
/**
* Set tools handler type for pi.setTools().
*/
export type SetToolsHandler = (toolNames: string[]) => void;
/** /**
* New session handler type for ctx.newSession() in HookCommandContext. * New session handler type for ctx.newSession() in HookCommandContext.
*/ */
@ -100,6 +110,10 @@ export interface LoadedHook {
setSendMessageHandler: (handler: SendMessageHandler) => void; setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */ /** Set the append entry handler for this hook's pi.appendEntry() */
setAppendEntryHandler: (handler: AppendEntryHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void;
/** Set the get tools handler for this hook's pi.getTools() */
setGetToolsHandler: (handler: GetToolsHandler) => void;
/** Set the set tools handler for this hook's pi.setTools() */
setSetToolsHandler: (handler: SetToolsHandler) => void;
} }
/** /**
@ -159,6 +173,8 @@ function createHookAPI(
commands: Map<string, RegisteredCommand>; commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void; setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetToolsHandler: (handler: GetToolsHandler) => void;
setSetToolsHandler: (handler: SetToolsHandler) => void;
} { } {
let sendMessageHandler: SendMessageHandler = () => { let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler // Default no-op until mode sets the handler
@ -166,6 +182,10 @@ function createHookAPI(
let appendEntryHandler: AppendEntryHandler = () => { let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler // Default no-op until mode sets the handler
}; };
let getToolsHandler: GetToolsHandler = () => [];
let setToolsHandler: SetToolsHandler = () => {
// Default no-op until mode sets the handler
};
const messageRenderers = new Map<string, HookMessageRenderer>(); const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>(); const commands = new Map<string, RegisteredCommand>();
@ -195,6 +215,12 @@ function createHookAPI(
exec(command: string, args: string[], options?: ExecOptions) { exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options); return execCommand(command, args, options?.cwd ?? cwd, options);
}, },
getTools(): string[] {
return getToolsHandler();
},
setTools(toolNames: string[]): void {
setToolsHandler(toolNames);
},
} as HookAPI; } as HookAPI;
return { return {
@ -207,6 +233,12 @@ function createHookAPI(
setAppendEntryHandler: (handler: AppendEntryHandler) => { setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler; appendEntryHandler = handler;
}, },
setGetToolsHandler: (handler: GetToolsHandler) => {
getToolsHandler = handler;
},
setSetToolsHandler: (handler: SetToolsHandler) => {
setToolsHandler = handler;
},
}; };
} }
@ -234,10 +266,15 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API // Create handlers map and API
const handlers = new Map<string, HandlerFn[]>(); const handlers = new Map<string, HandlerFn[]>();
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( const {
handlers, api,
cwd, messageRenderers,
); commands,
setSendMessageHandler,
setAppendEntryHandler,
setGetToolsHandler,
setSetToolsHandler,
} = createHookAPI(handlers, cwd);
// Call factory to register handlers // Call factory to register handlers
factory(api); factory(api);
@ -251,6 +288,8 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
commands, commands,
setSendMessageHandler, setSendMessageHandler,
setAppendEntryHandler, setAppendEntryHandler,
setGetToolsHandler,
setSetToolsHandler,
}, },
error: null, error: null,
}; };

View file

@ -98,6 +98,10 @@ export class HookRunner {
sendMessageHandler: SendMessageHandler; sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */ /** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler; appendEntryHandler: AppendEntryHandler;
/** Handler for getting current tools */
getToolsHandler: () => string[];
/** Handler for setting tools */
setToolsHandler: (toolNames: string[]) => void;
/** Handler for creating new sessions (for HookCommandContext) */ /** Handler for creating new sessions (for HookCommandContext) */
newSessionHandler?: NewSessionHandler; newSessionHandler?: NewSessionHandler;
/** Handler for branching sessions (for HookCommandContext) */ /** Handler for branching sessions (for HookCommandContext) */
@ -132,10 +136,12 @@ export class HookRunner {
if (options.navigateTreeHandler) { if (options.navigateTreeHandler) {
this.navigateTreeHandler = options.navigateTreeHandler; this.navigateTreeHandler = options.navigateTreeHandler;
} }
// Set per-hook handlers for pi.sendMessage() and pi.appendEntry() // Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getTools(), pi.setTools()
for (const hook of this.hooks) { for (const hook of this.hooks) {
hook.setSendMessageHandler(options.sendMessageHandler); hook.setSendMessageHandler(options.sendMessageHandler);
hook.setAppendEntryHandler(options.appendEntryHandler); hook.setAppendEntryHandler(options.appendEntryHandler);
hook.setGetToolsHandler(options.getToolsHandler);
hook.setSetToolsHandler(options.setToolsHandler);
} }
this.uiContext = options.uiContext ?? noOpUIContext; this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false; this.hasUI = options.hasUI ?? false;

View file

@ -749,6 +749,28 @@ export interface HookAPI {
* Supports timeout and abort signal. * Supports timeout and abort signal.
*/ */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>; exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/**
* Get the list of currently active tool names.
* @returns Array of tool names (e.g., ["read", "bash", "edit", "write"])
*/
getTools(): string[];
/**
* Set the active tools by name.
* Only built-in tools can be enabled/disabled. Custom tools are always active.
* Changes take effect on the next agent turn.
*
* @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"])
*
* @example
* // Switch to read-only mode (plan mode)
* pi.setTools(["read", "bash", "grep", "find", "ls"]);
*
* // Restore full access
* pi.setTools(["read", "bash", "edit", "write"]);
*/
setTools(toolNames: string[]): void;
} }
/** /**

View file

@ -29,7 +29,7 @@
* ``` * ```
*/ */
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai"; import type { Model } from "@mariozechner/pi-ai";
import { join } from "path"; import { join } from "path";
import { getAgentDir } from "../config.js"; import { getAgentDir } from "../config.js";
@ -349,6 +349,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
) => void = () => {}; ) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {};
let getToolsHandler: () => string[] = () => [];
let setToolsHandler: (toolNames: string[]) => void = () => {};
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let branchHandler: (entryId: string) => 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 () => ({ let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
@ -376,6 +378,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
newSession: (options?: any) => newSessionHandler(options), newSession: (options?: any) => newSessionHandler(options),
branch: (entryId: string) => branchHandler(entryId), branch: (entryId: string) => branchHandler(entryId),
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
getTools: () => getToolsHandler(),
setTools: (toolNames: string[]) => setToolsHandler(toolNames),
}; };
def.factory(api as any); def.factory(api as any);
@ -403,6 +407,12 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
navigateTreeHandler = handler; navigateTreeHandler = handler;
}, },
setGetToolsHandler: (handler: () => string[]) => {
getToolsHandler = handler;
},
setSetToolsHandler: (handler: (toolNames: string[]) => void) => {
setToolsHandler = handler;
},
}; };
}); });
} }
@ -588,10 +598,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}, },
})); }));
// Create tool registry mapping name -> tool (for hook getTools/setTools)
// Cast to AgentTool since createCodingTools actually returns AgentTool[] (type is just Tool[])
const toolRegistry = new Map<string, AgentTool>();
for (const tool of builtInTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
for (const tool of wrappedCustomTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
time("combineTools"); time("combineTools");
// Wrap tools with hooks if available
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
if (hookRunner) { if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; allToolsArray = wrapToolsWithHooks(allToolsArray as AgentTool[], hookRunner);
// Also create a wrapped version of the registry for setTools
wrappedToolRegistry = new Map<string, AgentTool>();
for (const tool of allToolsArray as AgentTool[]) {
wrappedToolRegistry.set(tool.name, tool);
}
} }
let systemPrompt: string; let systemPrompt: string;
@ -670,6 +698,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
customTools: customToolsResult.tools, customTools: customToolsResult.tools,
skillsSettings: settingsManager.getSkillsSettings(), skillsSettings: settingsManager.getSkillsSettings(),
modelRegistry, modelRegistry,
toolRegistry: wrappedToolRegistry ?? toolRegistry,
}); });
time("createAgentSession"); time("createAgentSession");

View file

@ -453,6 +453,8 @@ export class InteractiveMode {
appendEntryHandler: (customType, data) => { appendEntryHandler: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data); this.sessionManager.appendCustomEntry(customType, data);
}, },
getToolsHandler: () => this.session.getActiveToolNames(),
setToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
newSessionHandler: async (options) => { newSessionHandler: async (options) => {
// Stop any loading animation // Stop any loading animation
if (this.loadingAnimation) { if (this.loadingAnimation) {

View file

@ -40,6 +40,8 @@ export async function runPrintMode(
appendEntryHandler: (customType, data) => { appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data); session.sessionManager.appendCustomEntry(customType, data);
}, },
getToolsHandler: () => session.getActiveToolNames(),
setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames),
}); });
hookRunner.onError((err) => { hookRunner.onError((err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`); console.error(`Hook error (${err.hookPath}): ${err.error}`);

View file

@ -189,6 +189,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
appendEntryHandler: (customType, data) => { appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data); session.sessionManager.appendCustomEntry(customType, data);
}, },
getToolsHandler: () => session.getActiveToolNames(),
setToolsHandler: (toolNames) => session.setActiveToolsByName(toolNames),
uiContext: createHookUIContext(), uiContext: createHookUIContext(),
hasUI: false, hasUI: false,
}); });

View file

@ -80,6 +80,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
setGetToolsHandler: () => {},
setSetToolsHandler: () => {},
}; };
} }
@ -104,6 +106,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
getModel: () => session.model, getModel: () => session.model,
sendMessageHandler: async () => {}, sendMessageHandler: async () => {},
appendEntryHandler: async () => {}, appendEntryHandler: async () => {},
getToolsHandler: () => [],
setToolsHandler: () => {},
uiContext: { uiContext: {
select: async () => undefined, select: async () => undefined,
confirm: async () => false, confirm: async () => false,
@ -267,6 +271,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
setGetToolsHandler: () => {},
setSetToolsHandler: () => {},
}; };
createSession([throwingHook]); createSession([throwingHook]);
@ -314,6 +320,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
setGetToolsHandler: () => {},
setSetToolsHandler: () => {},
}; };
const hook2: LoadedHook = { const hook2: LoadedHook = {
@ -343,6 +351,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
commands: new Map(), commands: new Map(),
setSendMessageHandler: () => {}, setSendMessageHandler: () => {},
setAppendEntryHandler: () => {}, setAppendEntryHandler: () => {},
setGetToolsHandler: () => {},
setSetToolsHandler: () => {},
}; };
createSession([hook1, hook2]); createSession([hook1, hook2]);