pi-code 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Claude Hooks Extension
3
+ *
4
+ * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
+ * a project's existing hooks work under pi:
6
+ * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
7
+ * pi `user_bash` for a `!`/`!!` command the user runs directly (the
8
+ * model never issues these, so a deny-list guard would otherwise miss
9
+ * them). No pi tool call exists there, so the payload reports the
10
+ * Claude name "Bash"; a deny hands pi a synthetic failed result so
11
+ * the command never runs. UserBashEvent carries no execution result
12
+ * and fires only before the command runs, so it has no PostToolUse
13
+ * counterpart (pi never delivers the output to observe).
14
+ * - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
15
+ * appended next to the tool result, as Claude documents)
16
+ * - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
17
+ * context before the first prompt via `before_agent_start`)
18
+ * - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
19
+ * additional context by transforming the submitted text)
20
+ * - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
21
+ * with stop_hook_active as the loop guard)
22
+ * - PreCompact -> pi `session_before_compact` (fire-and-forget)
23
+ * - PostCompact -> pi `session_compact` (fire-and-forget)
24
+ * - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
25
+ * appended to the failed result; it cannot block, the tool failed)
26
+ * - SessionEnd -> pi `session_shutdown` (fire-and-forget)
27
+ * - InstructionsLoaded -> bridged from the shared instruction-events bus:
28
+ * context-imports publishes session_start for the context
29
+ * files that survived claudeMdExcludes (it owns exclusion,
30
+ * so a file it removed from the prompt never announces)
31
+ * and include for resolved @imports; claude-rules publishes
32
+ * path_glob_match. Strictly observational: exit codes and
33
+ * JSON output, systemMessage included, are ignored.
34
+ *
35
+ * Every payload carries session_id, transcript_path (pi's session file), cwd,
36
+ * permission_mode (plan-mode state off the shared bus) and effort; tool events add
37
+ * tool_use_id. Every event honors the universal `systemMessage` output (a
38
+ * user-facing warning).
39
+ * `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
40
+ * transcript in the first place.
41
+ * `async`/`asyncRewake` (command hooks only, as Claude documents) run in the
42
+ * background on every event: they never block or delay the event that fired them
43
+ * and render no decision. An asyncRewake hook exiting 2 wakes the model with its
44
+ * stderr (stdout when stderr is empty) as a new turn; any other background
45
+ * completion delivers the JSON response's systemMessage/additionalContext to the
46
+ * model on the next turn, shown to nobody else. No timeout is enforced on `async`
47
+ * (asyncRewake keeps its own), and hooks still running at session end are killed,
48
+ * as Claude does at teardown.
49
+ *
50
+ * SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
51
+ * child-run lifecycle on the shared bus (notify-style: a child has already exited by
52
+ * the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
53
+ *
54
+ * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
55
+ * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
56
+ * `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
57
+ * `{"decision": "block"}`).
58
+ *
59
+ * Config is merged from ~/.claude/settings.json (always) plus the project's
60
+ * .claude/settings.json and settings.local.json (only when the project is
61
+ * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
62
+ * setting (managed settings or any honored file in that chain) short-circuits
63
+ * the load entirely, so no event fires any hook; /hooks prints the resolved
64
+ * chain per event with each entry's source settings file. Matchers follow Claude's rule:
65
+ * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
66
+ * anything with other regex characters is an unanchored regex. Claude matchers
67
+ * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
68
+ * is case-insensitive and folds `-` to `_`.
69
+ *
70
+ * Docs: https://code.claude.com/docs/en/hooks.md
71
+ */
72
+
73
+ import * as os from 'node:os'
74
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
75
+ import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
76
+ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
77
+ import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
78
+ import { installedPlugins } from '../internal/plugins.js'
79
+ import { isProjectApproved } from '../internal/project-approval.js'
80
+ import { repoRoot } from '../internal/project-root.js'
81
+ import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
82
+ import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
83
+ import { blockedToolCall, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
84
+ import { matchingCommands } from './matcher.js'
85
+ import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
86
+
87
+ export * from './config.js'
88
+ export * from './decisions.js'
89
+ export * from './matcher.js'
90
+ export * from './runners.js'
91
+
92
+ /** The text of the last assistant message in a turn, for Claude's Stop-hook
93
+ * `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
94
+ * content is returned as-is. */
95
+ export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const message = messages[i]
98
+ if (message.role !== 'assistant') continue
99
+ if (typeof message.content === 'string') return message.content
100
+ if (!Array.isArray(message.content)) return ''
101
+ return message.content
102
+ .filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
103
+ .map((part) => part.text)
104
+ .join('')
105
+ }
106
+ return ''
107
+ }
108
+
109
+ /** Claude overrides a Stop hook after it blocks this many times in a row with no user
110
+ * progress, ending the turn with a warning rather than looping forever. */
111
+ const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
112
+
113
+ /** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
114
+ * is a positive integer, else the default. A non-positive or malformed value falls back
115
+ * to the default rather than capping at zero (which would suppress the very first block). */
116
+ export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
117
+ const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
118
+ return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
119
+ }
120
+
121
+ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
122
+ return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
123
+ }
124
+
125
+ /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
126
+ * offered both spellings so existing configs keep firing either way, and the payload
127
+ * reports the Claude value, which is what a Claude-written hook script parses. */
128
+ const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
129
+ const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
130
+ const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
131
+
132
+ /** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
133
+ function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
134
+ const value = map[raw] ?? raw
135
+ return { names: value === raw ? [raw] : [raw, value], value }
136
+ }
137
+
138
+ export default function hooksExtension(pi: ExtensionAPI) {
139
+ let config: HooksConfig = {}
140
+ let projectDir = ''
141
+ /** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
142
+ let allowedHttpHookUrls: string[] | undefined
143
+ let pendingSessionContext: string[] = []
144
+ let stopHookActive = false
145
+ /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
146
+ * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
147
+ let stopHookBlockCount = 0
148
+ let sessionCtx: ExtensionContext | undefined
149
+ /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
150
+ let hooksDisabled = false
151
+ /** Which settings file each resolved entry came from, for the /hooks viewer. */
152
+ const hookSources = new Map<HookMatcher, string>()
153
+ /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
154
+ const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
155
+ const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
156
+ const transcript = ctx.sessionManager.getSessionFile()
157
+ if (transcript) common.transcript_path = transcript
158
+ if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
159
+ return common
160
+ }
161
+ /** Kills for background hooks still running; Claude kills async hooks at teardown,
162
+ * so session_shutdown reaps anything left rather than let a hung hook pin the
163
+ * event loop past a one-shot run's end. */
164
+ const backgroundKills = new Set<() => void>()
165
+ /** Claude's background delivery: an asyncRewake exit 2 wakes the model with the
166
+ * hook's stderr (stdout when stderr is empty) as a new turn; any other completion
167
+ * feeds the JSON response's systemMessage/additionalContext to the model on the
168
+ * next turn, shown to nobody else. A timeout kill discards the output, like a
169
+ * canceled synchronous hook; it resolves with code 124, so it never reads as a wake. */
170
+ const deliverBackgroundResult = (hook: HookCommand, result: HookRunResult): void => {
171
+ if (result.timedOut) return
172
+ if (hook.asyncRewake === true && result.code === 2) {
173
+ const detail = result.stderr.trim() || result.stdout.trim()
174
+ const content = detail ? `Async hook requested attention (exit 2):\n${detail}` : 'Async hook requested attention (exit 2)'
175
+ pi.sendMessage({ customType: 'claude-async-hook', content, display: true }, { triggerTurn: true })
176
+ return
177
+ }
178
+ const parsed = tryParseJson(result.stdout)
179
+ // The typeof guard doubles as Claude's schema validation: a wrong-typed field is
180
+ // dropped rather than delivered.
181
+ const parts = [parsed?.systemMessage, parsed?.hookSpecificOutput?.additionalContext].filter((part): part is string => typeof part === 'string' && part.length > 0)
182
+ if (parts.length === 0) return
183
+ pi.sendMessage({ customType: 'claude-async-hook', content: parts.join('\n'), display: false }, { deliverAs: 'nextTurn' })
184
+ }
185
+ /** A runner bound to the firing context, filling the common fields into each
186
+ * payload and dispatching on the entry's type. A background hook (see
187
+ * isBackgroundHook) is fired and the caller immediately gets a no-verdict result,
188
+ * so it can neither block nor delay the event that fired it; its completion is
189
+ * delivered by deliverBackgroundResult whenever it lands. */
190
+ const boundRunner =
191
+ (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
192
+ (hook, payload, ms) => {
193
+ const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
194
+ const dispatch = (onChild?: (kill: () => void) => void): Promise<HookRunResult> => {
195
+ if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
196
+ if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
197
+ if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
198
+ if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
199
+ return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
200
+ }
201
+ if (!isBackgroundHook(hook)) return dispatch()
202
+ let kill: (() => void) | undefined
203
+ void dispatch((registered) => {
204
+ kill = registered
205
+ backgroundKills.add(registered)
206
+ })
207
+ .then((result) => deliverBackgroundResult(hook, result))
208
+ .catch(() => {
209
+ // The hook may outlive the session (/new, shutdown): sendMessage asserts
210
+ // liveness, and nothing awaits this chain, so a throw would otherwise
211
+ // escape as an unhandled rejection.
212
+ })
213
+ .finally(() => {
214
+ if (kill) backgroundKills.delete(kill)
215
+ })
216
+ return Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false })
217
+ }
218
+ // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
219
+ // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
220
+ const mcpAliases = new Map<string, string>()
221
+ pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
222
+ if (!isMcpToolAliases(data)) return
223
+ mcpAliases.clear()
224
+ for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
225
+ })
226
+ // Claude's permission_mode: pi has no permission system, but pi-code's plan mode is
227
+ // the documented "plan" mode; its extension publishes the state on the shared bus.
228
+ let permissionMode = 'default'
229
+ pi.events.on(PLAN_MODE_CHANNEL, (data) => {
230
+ if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
231
+ })
232
+ // Claude's InstructionsLoaded hook has NO decision control: exit codes are
233
+ // ignored and every JSON output field (systemMessage included) is discarded, so
234
+ // dispatch is fire-and-forget on all paths. Two documented load reasons can
235
+ // never fire honestly and are deliberate gaps, not approximations:
236
+ // `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
237
+ // entry) and `compact` (pi does not re-load instruction files after compaction).
238
+ const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
239
+ if (!sessionCtx) return
240
+ const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
241
+ if (commands.length === 0) return
242
+ void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
243
+ }
244
+ // Every load rides the shared bus: context-imports publishes session_start for
245
+ // the context files that survived claudeMdExcludes and include for resolved
246
+ // @imports (deduped there, once per file per session); claude-rules publishes
247
+ // path_glob_match when a scoped rule attaches. Consuming the bus rather than
248
+ // iterating raw contextFiles keeps this extension from announcing a file the
249
+ // exclusion removed from the prompt; bus emit is synchronous, so the events
250
+ // arrive regardless of extension load order.
251
+ pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
252
+ if (!isInstructionLoadEvent(data)) return
253
+ fireInstructionsLoaded({ ...data })
254
+ })
255
+
256
+ // Subagent lifecycle arrives over the bus without a pi context; the session context
257
+ // captured at session_start supplies the common payload fields.
258
+ pi.events.on(SUBAGENT_CHANNEL, async (data) => {
259
+ if (!isSubagentPhaseEvent(data) || !sessionCtx) return
260
+ const ctx = sessionCtx
261
+ const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
262
+ const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
263
+ try {
264
+ const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
265
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
266
+ } catch {
267
+ // The bus outlives the session: an event landing between /new disposing this
268
+ // ctx and the next session_start hits disposed getters, and nothing awaits a
269
+ // bus listener, so a throw here would escape as an unhandled rejection.
270
+ }
271
+ })
272
+
273
+ pi.on('session_start', async (event, ctx) => {
274
+ sessionCtx = ctx
275
+ // One extension instance serves every session. A mid-turn /new fires session_start on
276
+ // the same instance while a Stop-hook continuation streak is in flight; it must not
277
+ // carry into the next session, so reset before any early return (disableAllHooks below).
278
+ stopHookActive = false
279
+ stopHookBlockCount = 0
280
+ const trusted = await isProjectApproved(ctx)
281
+ // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
282
+ // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
283
+ // subdirectory session too.
284
+ projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
285
+ const files = hookFiles(ctx.cwd, os.homedir(), trusted)
286
+ hookSources.clear()
287
+ allowedHttpHookUrls = readAllowedHttpHookUrls(files)
288
+ // The disableAllHooks escape hatch, checked before any config loads: with no
289
+ // config resolved, no event, plugin hooks included, can fire a hook.
290
+ hooksDisabled = readDisableAllHooks(files)
291
+ if (hooksDisabled) {
292
+ config = {}
293
+ pendingSessionContext = []
294
+ return
295
+ }
296
+ config = loadHooks(files, hookSources)
297
+ // Plugins are user-installed and enabled by user settings (see installedPlugins),
298
+ // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
299
+ loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
300
+ // "reload" re-fires in-process with the same conversation and would double-run hooks;
301
+ // a fork is a genuine session begin, which Claude reports as source "fork".
302
+ if (event.reason === 'reload') return
303
+ const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
304
+ const commands = matchingCommands(config.SessionStart, source.names)
305
+ const payload = { hook_event_name: 'SessionStart', source: source.value }
306
+ const run = boundRunner(ctx)
307
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
308
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
309
+ pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
310
+ })
311
+
312
+ // Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
313
+ // conversation before the first prompt; pi's seam for that is a message injected
314
+ // on the next agent start. The session_start InstructionsLoaded events arrive
315
+ // over the bus from context-imports, which owns claudeMdExcludes; announcing
316
+ // the raw contextFiles here would fire for a file the exclusion removed.
317
+ pi.on('before_agent_start', async () => {
318
+ if (pendingSessionContext.length === 0) return
319
+ const content = pendingSessionContext.join('\n')
320
+ pendingSessionContext = []
321
+ return { message: { customType: 'claude-hook-context', content, display: false } }
322
+ })
323
+
324
+ pi.on('tool_call', async (event, ctx) => {
325
+ const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'))
326
+ if (!decision.block) return undefined
327
+ // Claude's "ask": prompt the user and let the call through if they approve.
328
+ // With no UI (headless) the block stands, which is the safe default.
329
+ if (decision.ask && ctx.hasUI) {
330
+ const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
331
+ return approved ? undefined : blockedToolCall(decision.reason)
332
+ }
333
+ return blockedToolCall(decision.reason)
334
+ })
335
+
336
+ // Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
337
+ // hook's output back next to the tool result: a decision:block reason (or exit-2
338
+ // stderr) and additionalContext are appended, which is where Claude documents they
339
+ // land. The failure branch shows the hook's stderr to the model too ("Shows stderr
340
+ // to Claude; the tool already failed"), it just cannot block a call that failed.
341
+ pi.on('tool_result', async (event, ctx) => {
342
+ const alias = mcpAliases.get(event.toolName)
343
+ const names = alias ? [event.toolName, alias] : [event.toolName]
344
+ const response = { content: event.content, details: event.details, isError: event.isError }
345
+ const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
346
+ const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
347
+ if (commands.length === 0) return
348
+ const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
349
+ const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
350
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
351
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
352
+ const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
353
+ if (feedback.length === 0) return
354
+ return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
355
+ })
356
+
357
+ // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
358
+ // `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
359
+ // so without this a guard that blocks `git push -f` from the model would not stop the
360
+ // same command typed by hand. There is no pi tool call, so the matcher sees both pi's
361
+ // "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
362
+ // reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
363
+ // payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
364
+ // exposes no block flag: a deny is enforced through `result` ("extension handled
365
+ // execution, use this result"), a synthetic failed BashResult that stands in for the
366
+ // command so it never runs and its deny reason shows as the output. The event delivers
367
+ // no execution result and fires only before the command runs, so there is deliberately
368
+ // no PostToolUse for it.
369
+ pi.on('user_bash', async (event, ctx) => {
370
+ const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
371
+ if (!decision.block) return undefined
372
+ // Claude's "ask": prompt before running and let the command through on approval; with
373
+ // no UI (headless) the block stands, the same safe default as the tool_call path.
374
+ if (decision.ask && ctx.hasUI) {
375
+ const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
376
+ if (approved) return undefined
377
+ }
378
+ const reason = decision.reason ?? 'Command blocked by hook'
379
+ return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
380
+ })
381
+
382
+ pi.on('input', async (event, ctx) => {
383
+ // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
384
+ // prompts the user submitted.
385
+ if (event.source === 'extension') return { action: 'continue' }
386
+ // Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
387
+ // block cap counts only consecutive blocks with nothing from the user in between.
388
+ stopHookBlockCount = 0
389
+ const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
390
+ if (decision.block) {
391
+ // pi's input result has no reason channel, so surface why before consuming it.
392
+ ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
393
+ return { action: 'handled' }
394
+ }
395
+ // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
396
+ // pi's seam for rewriting the submitted text.
397
+ if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
398
+ return { action: 'continue' }
399
+ })
400
+
401
+ // Claude's Stop hook can prevent stopping: a block feeds its reason back as a new
402
+ // turn, and stop_hook_active in the payload tells the next firing it is already
403
+ // continuing from a stop hook, which is the hook script's documented loop guard.
404
+ // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
405
+ //
406
+ // On agent_end rather than agent_settled: agent_settled is only emitted after every
407
+ // agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
408
+ // handler on a UI dialog, which would starve the Stop hook and idle notification
409
+ // until the user answers it. agent_end can fire slightly early before a rare
410
+ // automatic retry or compaction; that is the better tradeoff.
411
+ pi.on('agent_end', async (event, ctx) => {
412
+ // Claude's Notification event, for the one type pi can honestly source: the
413
+ // agent finished and is waiting for input (idle_prompt). Observational only;
414
+ // exit codes and JSON output are ignored, as Claude documents for this event.
415
+ const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
416
+ if (notifyCommands.length > 0) {
417
+ void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
418
+ }
419
+
420
+ const commands = matchingCommands(config.Stop, 'Stop')
421
+ if (commands.length === 0) {
422
+ stopHookActive = false
423
+ return
424
+ }
425
+ // Claude's Stop payload carries the turn's final assistant text so a hook need
426
+ // not re-read the transcript; included only when there is one.
427
+ const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
428
+ const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
429
+ const run = boundRunner(ctx)
430
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
431
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
432
+ const block = results
433
+ .filter((result) => !result.timedOut)
434
+ .map((result) => {
435
+ if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
436
+ const parsed = tryParseJson(result.stdout)
437
+ if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
438
+ return { block: false, reason: '' }
439
+ })
440
+ .find((verdict) => verdict.block)
441
+ if (!block) {
442
+ // A non-blocking Stop breaks the streak: the next block starts a fresh count.
443
+ stopHookActive = false
444
+ stopHookBlockCount = 0
445
+ return
446
+ }
447
+ stopHookBlockCount += 1
448
+ const cap = stopHookBlockCap()
449
+ if (stopHookBlockCount >= cap) {
450
+ // Claude overrides a Stop hook that has blocked cap times in a row with no user
451
+ // progress: suppress the continuation, warn, and let the turn end so the loop cannot
452
+ // run forever. Reset the count so a later run (or user turn) starts clean.
453
+ stopHookActive = false
454
+ stopHookBlockCount = 0
455
+ ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
456
+ return
457
+ }
458
+ stopHookActive = true
459
+ pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
460
+ })
461
+
462
+ pi.on('session_before_compact', async (event, ctx) => {
463
+ const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
464
+ const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
465
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
466
+ })
467
+
468
+ pi.on('session_compact', async (event, ctx) => {
469
+ const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
470
+ const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
471
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
472
+ })
473
+
474
+ pi.on('session_shutdown', async (event, ctx) => {
475
+ const reason = claudeSpelling(SESSION_END_REASON, event.reason)
476
+ const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
477
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
478
+ // Claude kills async hooks still running at teardown; the session that spawned
479
+ // these is over, and their delivery would target a disposed context anyway.
480
+ for (const kill of backgroundKills) kill()
481
+ backgroundKills.clear()
482
+ })
483
+
484
+ // Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
485
+ // are otherwise opaque, so showing the resolved chain per event, with the settings
486
+ // file each entry came from, is the debugging surface.
487
+ pi.registerCommand('hooks', {
488
+ description: 'Show the hook configuration resolved from settings',
489
+ handler: async (_args, ctx) => {
490
+ if (hooksDisabled) {
491
+ ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
492
+ return
493
+ }
494
+ ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
495
+ },
496
+ })
497
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The matcher engine: compiling a Claude matcher string (exact-name list or regex)
3
+ * to a memoized form, testing it against tool/source names, and resolving the hook
4
+ * commands an event fires. Owns the module-level compiled-matcher cache.
5
+ */
6
+
7
+ import type { HookCommand, HookMatcher } from './config.js'
8
+
9
+ /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
10
+ * is a list of exact names; anything else is an unanchored regex. */
11
+ const EXACT_MATCHER = /^[\w\- ,|]*$/
12
+
13
+ /** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
14
+ * pi names are lowercase with underscores, so comparison folds both. */
15
+ function foldName(name: string): string {
16
+ return name.toLowerCase().replaceAll('-', '_')
17
+ }
18
+
19
+ /** A matcher string's compiled form: a set of folded exact names, or a regex. */
20
+ type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
21
+
22
+ function exactTokens(matcher: string): Set<string> {
23
+ return new Set(
24
+ matcher
25
+ .split(/[|,]/)
26
+ .map((token) => foldName(token.trim()))
27
+ .filter(Boolean),
28
+ )
29
+ }
30
+
31
+ /** Hook config is static per session and dispatch consults every matcher on every
32
+ * event, so each matcher string compiles once. Matchers are few; the bound is a
33
+ * safety net, clearing the (cheap to rebuild) cache rather than evicting. */
34
+ const compiledMatchers = new Map<string, CompiledMatcher>()
35
+ const COMPILED_MATCHER_BOUND = 1000
36
+
37
+ let matcherCompiles = 0
38
+
39
+ /** Test seam: matcher compilations performed, for asserting memoization. */
40
+ export function matcherCompileCount(): number {
41
+ return matcherCompiles
42
+ }
43
+
44
+ /** Test seam: drop compiled matchers so a test observes fresh compiles. */
45
+ export function resetMatcherCache(): void {
46
+ compiledMatchers.clear()
47
+ matcherCompiles = 0
48
+ }
49
+
50
+ function compileMatcher(matcher: string): CompiledMatcher {
51
+ const cached = compiledMatchers.get(matcher)
52
+ if (cached !== undefined) return cached
53
+ matcherCompiles += 1
54
+ let compiled: CompiledMatcher
55
+ if (EXACT_MATCHER.test(matcher)) {
56
+ compiled = { tokens: exactTokens(matcher) }
57
+ } else {
58
+ try {
59
+ compiled = { regex: new RegExp(matcher, 'i') }
60
+ } catch {
61
+ // An invalid regex matcher falls back to exact-name matching, as before.
62
+ compiled = { tokens: exactTokens(matcher) }
63
+ }
64
+ }
65
+ if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
66
+ compiledMatchers.set(matcher, compiled)
67
+ return compiled
68
+ }
69
+
70
+ function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
71
+ if (!matcher || matcher === '*') return true
72
+ const compiled = compileMatcher(matcher)
73
+ if ('regex' in compiled) {
74
+ const { regex } = compiled
75
+ return names.some((name) => regex.test(name))
76
+ }
77
+ const { tokens } = compiled
78
+ return names.some((name) => tokens.has(foldName(name)))
79
+ }
80
+
81
+ /** A hook entry pi-code can run: a shell command, an http POST, an in-process
82
+ * prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
83
+ * registered is still matched here and resolves non-blocking at run time, the same
84
+ * way a prompt hook with no model does. */
85
+ function isRunnableHook(hook: HookCommand): boolean {
86
+ if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
87
+ if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
88
+ if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
89
+ return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
90
+ }
91
+
92
+ /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
93
+ * entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
94
+ * (undefined or `command` type) already has one, so this is undefined. */
95
+ function syntheticCommand(hook: HookCommand): string | undefined {
96
+ if (hook.type === 'http') return hook.url
97
+ if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
98
+ if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
99
+ return undefined
100
+ }
101
+
102
+ /** A matched entry with its `command` filled in: mirroring the synthetic identity into
103
+ * `command` keeps dedup, timeout messages and display working for non-shell hooks. */
104
+ function withCommand(raw: HookCommand): HookCommand {
105
+ const identity = syntheticCommand(raw)
106
+ return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
107
+ }
108
+
109
+ /** Command specs whose matcher applies to any of the given tool/source names.
110
+ * Multiple candidates let one event offer both the pi name and its Claude alias. */
111
+ export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
112
+ const candidates = typeof names === 'string' ? [names] : names
113
+ const result: HookCommand[] = []
114
+ const seen = new Set<string>()
115
+ for (const entry of matchers ?? []) {
116
+ if (!matcherApplies(entry.matcher, candidates)) continue
117
+ for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
118
+ const hook = withCommand(raw)
119
+ // Claude runs a handler defined in more than one settings file once.
120
+ if (seen.has(hook.command)) continue
121
+ seen.add(hook.command)
122
+ result.push(hook)
123
+ }
124
+ }
125
+ return result
126
+ }