feat(coding-agent): add hook API for CLI flags, shortcuts, and tool control

Hook API additions:
- pi.getTools() / pi.setTools(toolNames) - dynamically enable/disable tools
- pi.registerFlag(name, options) / pi.getFlag(name) - register custom CLI flags
- pi.registerShortcut(shortcut, options) - register keyboard shortcuts

Plan mode hook (examples/hooks/plan-mode.ts):
- /plan command or Shift+P shortcut to toggle
- --plan CLI flag to start in plan mode
- Read-only tools: read, bash, grep, find, ls
- Bash restricted to non-destructive commands (blocks rm, mv, git commit, etc.)
- Interactive prompt after each response: execute, stay, or refine
- Shows plan indicator in footer when active
- State persists across sessions
This commit is contained in:
Helmut Januschka 2026-01-03 09:52:13 +01:00
parent 57bba4e32b
commit db312d2eed
13 changed files with 636 additions and 46 deletions

View file

@ -35,6 +35,8 @@ export interface Args {
listModels?: string | true;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially hook flags) - map of flag name to value */
unknownFlags: Map<string, boolean | string>;
}
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
@ -43,10 +45,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}
export function parseArgs(args: string[]): Args {
export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boolean" | "string" }>): Args {
const result: Args = {
messages: [],
fileArgs: [],
unknownFlags: new Map(),
};
for (let i = 0; i < args.length; i++) {
@ -131,6 +134,18 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (arg.startsWith("--") && hookFlags) {
// Check if it's a hook-registered flag
const flagName = arg.slice(2);
const hookFlag = hookFlags.get(flagName);
if (hookFlag) {
if (hookFlag.type === "boolean") {
result.unknownFlags.set(flagName, true);
} else if (hookFlag.type === "string" && i + 1 < args.length) {
result.unknownFlags.set(flagName, args[++i]);
}
}
// Unknown flags without hookFlags are silently ignored (first pass)
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
@ -172,6 +187,8 @@ ${chalk.bold("Options:")}
--help, -h Show this help
--version, -v Show version number
Hooks can register additional flags (e.g., --plan from plan-mode hook).
${chalk.bold("Examples:")}
# Interactive mode
${APP_NAME}

View file

@ -5,6 +5,8 @@ export {
type AppendEntryHandler,
type BranchHandler,
type GetToolsHandler,
type HookFlag,
type HookShortcut,
type LoadedHook,
type LoadHooksResult,
type NavigateTreeHandler,

View file

@ -71,6 +71,36 @@ export type GetToolsHandler = () => string[];
*/
export type SetToolsHandler = (toolNames: string[]) => void;
/**
* CLI flag definition registered by a hook.
*/
export interface HookFlag {
/** Flag name (without --) */
name: string;
/** Description for --help */
description?: string;
/** Type: boolean or string */
type: "boolean" | "string";
/** Default value */
default?: boolean | string;
/** Hook path that registered this flag */
hookPath: string;
}
/**
* Keyboard shortcut registered by a hook.
*/
export interface HookShortcut {
/** Shortcut string (e.g., "shift+p", "ctrl+shift+x") */
shortcut: string;
/** Description for help */
description?: string;
/** Handler function */
handler: (ctx: import("./types.js").HookContext) => Promise<void> | void;
/** Hook path that registered this shortcut */
hookPath: string;
}
/**
* New session handler type for ctx.newSession() in HookCommandContext.
*/
@ -106,6 +136,12 @@ export interface LoadedHook {
messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** CLI flags registered by this hook */
flags: Map<string, HookFlag>;
/** Flag values (set after CLI parsing) */
flagValues: Map<string, boolean | string>;
/** Keyboard shortcuts registered by this hook */
shortcuts: Map<string, HookShortcut>;
/** Set the send message handler for this hook's pi.sendMessage() */
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
@ -114,6 +150,8 @@ export interface LoadedHook {
setGetToolsHandler: (handler: GetToolsHandler) => void;
/** Set the set tools handler for this hook's pi.setTools() */
setSetToolsHandler: (handler: SetToolsHandler) => void;
/** Set a flag value (called after CLI parsing) */
setFlagValue: (name: string, value: boolean | string) => void;
}
/**
@ -167,14 +205,19 @@ function resolveHookPath(hookPath: string, cwd: string): string {
function createHookAPI(
handlers: Map<string, HandlerFn[]>,
cwd: string,
hookPath: string,
): {
api: HookAPI;
messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, HookFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<string, HookShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetToolsHandler: (handler: GetToolsHandler) => void;
setSetToolsHandler: (handler: SetToolsHandler) => void;
setFlagValue: (name: string, value: boolean | string) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
@ -188,6 +231,9 @@ function createHookAPI(
};
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const flags = new Map<string, HookFlag>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<string, HookShortcut>();
// Cast to HookAPI - the implementation is more general (string event names)
// but the interface has specific overloads for type safety in hooks
@ -221,12 +267,37 @@ function createHookAPI(
setTools(toolNames: string[]): void {
setToolsHandler(toolNames);
},
registerFlag(
name: string,
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
): void {
flags.set(name, { name, hookPath, ...options });
// Set default value if provided
if (options.default !== undefined) {
flagValues.set(name, options.default);
}
},
getFlag(name: string): boolean | string | undefined {
return flagValues.get(name);
},
registerShortcut(
shortcut: string,
options: {
description?: string;
handler: (ctx: import("./types.js").HookContext) => Promise<void> | void;
},
): void {
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
},
} as HookAPI;
return {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
@ -239,6 +310,9 @@ function createHookAPI(
setSetToolsHandler: (handler: SetToolsHandler) => {
setToolsHandler = handler;
},
setFlagValue: (name: string, value: boolean | string) => {
flagValues.set(name, value);
},
};
}
@ -270,11 +344,15 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetToolsHandler,
setSetToolsHandler,
} = createHookAPI(handlers, cwd);
setFlagValue,
} = createHookAPI(handlers, cwd, hookPath);
// Call factory to register handlers
factory(api);
@ -286,10 +364,14 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
handlers,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetToolsHandler,
setSetToolsHandler,
setFlagValue,
},
error: null,
};

View file

@ -168,6 +168,43 @@ export class HookRunner {
return this.hooks.map((h) => h.path);
}
/**
* Get all CLI flags registered by hooks.
*/
getFlags(): Map<string, import("./loader.js").HookFlag> {
const allFlags = new Map<string, import("./loader.js").HookFlag>();
for (const hook of this.hooks) {
for (const [name, flag] of hook.flags) {
allFlags.set(name, flag);
}
}
return allFlags;
}
/**
* Set a flag value (after CLI parsing).
*/
setFlagValue(name: string, value: boolean | string): void {
for (const hook of this.hooks) {
if (hook.flags.has(name)) {
hook.setFlagValue(name, value);
}
}
}
/**
* Get all keyboard shortcuts registered by hooks.
*/
getShortcuts(): Map<string, import("./loader.js").HookShortcut> {
const allShortcuts = new Map<string, import("./loader.js").HookShortcut>();
for (const hook of this.hooks) {
for (const [key, shortcut] of hook.shortcuts) {
allShortcuts.set(key, shortcut);
}
}
return allShortcuts;
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function

View file

@ -771,6 +771,70 @@ export interface HookAPI {
* pi.setTools(["read", "bash", "edit", "write"]);
*/
setTools(toolNames: string[]): void;
/**
* Register a CLI flag for this hook.
* Flags are parsed from command line and values accessible via getFlag().
*
* @param name - Flag name (will be --name on CLI)
* @param options - Flag configuration
*
* @example
* pi.registerFlag("plan", {
* description: "Start in plan mode (read-only)",
* type: "boolean",
* });
*/
registerFlag(
name: string,
options: {
/** Description shown in --help */
description?: string;
/** Flag type: boolean (--flag) or string (--flag value) */
type: "boolean" | "string";
/** Default value */
default?: boolean | string;
},
): void;
/**
* Get the value of a CLI flag registered by this hook.
* Returns undefined if flag was not provided and has no default.
*
* @param name - Flag name (without --)
* @returns Flag value, or undefined
*
* @example
* if (pi.getFlag("plan")) {
* // plan mode enabled
* }
*/
getFlag(name: string): boolean | string | undefined;
/**
* Register a keyboard shortcut for this hook.
* The handler is called when the shortcut is pressed in interactive mode.
*
* @param shortcut - Shortcut definition (e.g., "shift+p", "ctrl+shift+x")
* @param options - Shortcut configuration
*
* @example
* pi.registerShortcut("shift+p", {
* description: "Toggle plan mode",
* handler: async (ctx) => {
* // toggle plan mode
* },
* });
*/
registerShortcut(
shortcut: string,
options: {
/** Description shown in help */
description?: string;
/** Handler called when shortcut is pressed */
handler: (ctx: HookContext) => Promise<void> | void;
},
): void;
}
/**

View file

@ -112,6 +112,8 @@ export interface CreateAgentSessionOptions {
hooks?: Array<{ path?: string; factory: HookFactory }>;
/** Additional hook paths to load (merged with discovery). */
additionalHookPaths?: string[];
/** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */
preloadedHooks?: LoadedHook[];
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
@ -341,9 +343,13 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
*/
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
return definitions.map((def) => {
const hookPath = def.path ?? "<inline>";
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
const flags = new Map<string, any>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<string, any>();
let sendMessageHandler: (
message: any,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
@ -375,6 +381,16 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
registerFlag: (name: string, options: any) => {
flags.set(name, { name, hookPath, ...options });
if (options.default !== undefined) {
flagValues.set(name, options.default);
}
},
getFlag: (name: string) => flagValues.get(name),
registerShortcut: (shortcut: string, options: any) => {
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
},
newSession: (options?: any) => newSessionHandler(options),
branch: (entryId: string) => branchHandler(entryId),
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
@ -385,11 +401,14 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
def.factory(api as any);
return {
path: def.path ?? "<inline>",
resolvedPath: def.path ?? "<inline>",
path: hookPath,
resolvedPath: hookPath,
handlers,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler: (
handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void,
) => {
@ -413,6 +432,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
setSetToolsHandler: (handler: (toolNames: string[]) => void) => {
setToolsHandler = handler;
},
setFlagValue: (name: string, value: boolean | string) => {
flagValues.set(name, value);
},
};
});
}
@ -566,7 +588,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
let hookRunner: HookRunner | undefined;
if (options.hooks !== undefined) {
if (options.preloadedHooks !== undefined && options.preloadedHooks.length > 0) {
// Use pre-loaded hooks (from early CLI flag discovery)
hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry);
} else if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);

View file

@ -18,6 +18,7 @@ import type { AgentSession } from "./core/agent-session.js";
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadHooks } from "./core/hooks/index.js";
import type { HookUIContext } from "./core/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
@ -212,6 +213,7 @@ function buildSessionOptions(
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
preloadedHooks?: import("./core/hooks/index.js").LoadedHook[],
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@ -270,9 +272,9 @@ function buildSessionOptions(
options.skills = [];
}
// Additional hook paths from CLI
if (parsed.hooks && parsed.hooks.length > 0) {
options.additionalHookPaths = parsed.hooks;
// Pre-loaded hooks (from early CLI flag discovery)
if (preloadedHooks && preloadedHooks.length > 0) {
options.preloadedHooks = preloadedHooks;
}
// Additional custom tool paths from CLI
@ -294,9 +296,38 @@ export async function main(args: string[]) {
const modelRegistry = discoverModels(authStorage);
time("discoverModels");
const parsed = parseArgs(args);
// First pass: parse args to get --hook paths
const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load hooks to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const hookPaths = firstPass.hooks ?? [];
const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir);
time("discoverHookFlags");
// Collect all hook flags
const hookFlags = new Map<string, { type: "boolean" | "string" }>();
for (const hook of loadedHooks) {
for (const [name, flag] of hook.flags) {
hookFlags.set(name, { type: flag.type });
}
}
// Second pass: parse args with hook flags
const parsed = parseArgs(args, hookFlags);
time("parseArgs");
// Pass flag values to hooks
for (const [name, value] of parsed.unknownFlags) {
for (const hook of loadedHooks) {
if (hook.flags.has(name)) {
hook.setFlagValue(name, value);
}
}
}
if (parsed.version) {
console.log(VERSION);
return;
@ -331,7 +362,6 @@ export async function main(args: string[]) {
process.exit(1);
}
const cwd = process.cwd();
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
@ -369,7 +399,7 @@ export async function main(args: string[]) {
sessionManager = SessionManager.open(selectedPath);
}
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry);
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;

View file

@ -12,6 +12,8 @@ export class CustomEditor extends Editor {
public onEscape?: () => void;
public onCtrlD?: () => void;
public onPasteImage?: () => void;
/** Handler for hook-registered shortcuts. Returns true if handled. */
public onHookShortcut?: (data: string) => boolean;
constructor(theme: EditorTheme, keybindings: KeybindingsManager) {
super(theme);
@ -26,6 +28,11 @@ export class CustomEditor extends Editor {
}
handleInput(data: string): void {
// Check hook-registered shortcuts first
if (this.onHookShortcut?.(data)) {
return;
}
// Check for Ctrl+V to handle clipboard image paste
if (matchesKey(data, "ctrl+v")) {
this.onPasteImage?.();

View file

@ -410,20 +410,7 @@ export class InteractiveMode {
}
// Create and set hook & tool UI context
const uiContext: HookUIContext = {
select: (title, options) => this.showHookSelector(title, options),
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
get theme() {
return theme;
},
};
const uiContext = this.createHookUIContext();
this.setToolUIContext(uiContext, true);
// Notify custom tools of session start
@ -536,6 +523,9 @@ export class InteractiveMode {
this.showHookError(error.hookPath, error.error);
});
// Set up hook-registered shortcuts
this.setupHookShortcuts(hookRunner);
// Show loaded hooks
const hookPaths = hookRunner.getHookPaths();
if (hookPaths.length > 0) {
@ -583,6 +573,82 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Set up keyboard shortcuts registered by hooks.
*/
private setupHookShortcuts(hookRunner: import("../../core/hooks/index.js").HookRunner): void {
const shortcuts = hookRunner.getShortcuts();
if (shortcuts.size === 0) return;
// Create a context for shortcut handlers
const createContext = (): import("../../core/hooks/types.js").HookContext => ({
ui: this.createHookUIContext(),
hasUI: true,
cwd: process.cwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
});
// Set up the hook shortcut handler on the editor
this.editor.onHookShortcut = (data: string) => {
for (const [shortcutStr, shortcut] of shortcuts) {
if (this.matchShortcut(data, shortcutStr)) {
// Run handler async, don't block input
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
});
return true;
}
}
return false;
};
}
/**
* Match a key input against a shortcut string like "shift+p" or "ctrl+shift+x".
*/
private matchShortcut(data: string, shortcut: string): boolean {
const parts = shortcut.toLowerCase().split("+");
const key = parts.pop() ?? "";
const modifiers = new Set(parts);
const hasShift = modifiers.has("shift");
const hasCtrl = modifiers.has("ctrl");
const hasAlt = modifiers.has("alt");
// Get the key codepoint
const keyCode = key.length === 1 ? key.charCodeAt(0) : 0;
if (keyCode === 0) return false;
// Calculate expected modifier bits for Kitty protocol
// Kitty modifier bits: 1=shift, 2=alt, 4=ctrl
let expectedMod = 0;
if (hasShift) expectedMod |= 1;
if (hasAlt) expectedMod |= 2;
if (hasCtrl) expectedMod |= 4;
// Try to match Kitty protocol: \x1b[<code>;<mod>u
// With modifier offset: mod in sequence = expectedMod + 1
const kittyPattern = new RegExp(`^\x1b\\[${keyCode};(\\d+)u$`);
const kittyMatch = data.match(kittyPattern);
if (kittyMatch) {
const actualMod = parseInt(kittyMatch[1], 10) - 1; // Subtract 1 for the offset
// Mask out lock bits (8=capslock, 16=numlock)
return (actualMod & 0x7) === expectedMod;
}
// Try uppercase letter for shift+letter (legacy terminals)
if (hasShift && !hasCtrl && !hasAlt && key.length === 1) {
return data === key.toUpperCase();
}
return false;
}
/**
* Set hook status text in the footer.
*/
@ -591,6 +657,26 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Create the HookUIContext for hooks and tools.
*/
private createHookUIContext(): HookUIContext {
return {
select: (title, options) => this.showHookSelector(title, options),
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
get theme() {
return theme;
},
};
}
/**
* Show a selector for hooks.
*/