mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 05:02:14 +00:00
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:
parent
5b95ccf830
commit
059292ead1
14 changed files with 304 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/) |
|
||||||
|
|
|
||||||
119
packages/coding-agent/examples/hooks/plan-mode.ts
Normal file
119
packages/coding-agent/examples/hooks/plan-mode.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue