pi-code 1.0.13 → 1.0.14

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.
@@ -1,1179 +0,0 @@
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
- *
42
- * SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
43
- * child-run lifecycle on the shared bus (notify-style: a child has already exited by
44
- * the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
45
- *
46
- * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
47
- * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
48
- * `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
49
- * `{"decision": "block"}`).
50
- *
51
- * Config is merged from ~/.claude/settings.json (always) plus the project's
52
- * .claude/settings.json and settings.local.json (only when the project is
53
- * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
54
- * setting (managed settings or any honored file in that chain) short-circuits
55
- * the load entirely, so no event fires any hook; /hooks prints the resolved
56
- * chain per event with each entry's source settings file. Matchers follow Claude's rule:
57
- * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
58
- * anything with other regex characters is an unanchored regex. Claude matchers
59
- * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
60
- * is case-insensitive and folds `-` to `_`.
61
- *
62
- * Docs: https://code.claude.com/docs/en/hooks.md
63
- */
64
-
65
- import { type ChildProcess, spawn } from 'node:child_process'
66
- import * as fs from 'node:fs'
67
- import * as os from 'node:os'
68
- import * as path from 'node:path'
69
- import type { Api, Model } from '@earendil-works/pi-ai'
70
- import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from '@earendil-works/pi-coding-agent'
71
- import { runAgent } from './internal/agent-run.js'
72
- import { claudeConfigDir } from './internal/config-dir.js'
73
- import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
74
- import { readManagedSettings } from './internal/managed-settings.js'
75
- import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
76
- import { callMcpTool } from './internal/mcp-call.js'
77
- import { completeText } from './internal/model-complete.js'
78
- import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
79
- import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
80
- import { isProjectApproved } from './internal/project-approval.js'
81
- import { findNearestFile, repoRoot } from './internal/project-root.js'
82
- import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
83
-
84
- // Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
85
- // PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
86
- // to fall back on), so ten minutes of default budget would wedge the turn for
87
- // ten minutes on a hung hook. Hooks that legitimately run long can raise their
88
- // own per-hook `timeout`.
89
- const DEFAULT_TIMEOUT_S = 60
90
-
91
- interface HookCommand {
92
- type?: string
93
- command: string
94
- /** exec-form: spawn `command` directly with these args and no shell (shell-form when
95
- * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
96
- args?: string[]
97
- timeout?: number
98
- /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
99
- url?: string
100
- headers?: Record<string, string>
101
- allowedEnvVars?: string[]
102
- /** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
103
- prompt?: string
104
- /** mcp_tool entries: the connected server and tool to call, with optional input. */
105
- server?: string
106
- tool?: string
107
- input?: Record<string, unknown>
108
- /** prompt/agent entries: an optional model override; agent adds a system prompt. */
109
- model?: string
110
- systemPrompt?: string
111
- }
112
- export interface HookMatcher {
113
- matcher?: string
114
- hooks: HookCommand[]
115
- }
116
- export type HooksConfig = Record<string, HookMatcher[]>
117
-
118
- export interface HookDecision {
119
- block: boolean
120
- reason?: string
121
- /** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
122
- * block only on decline. `block` stays true as the no-UI fallback. */
123
- ask?: boolean
124
- }
125
- export interface HookRunResult {
126
- code: number
127
- stdout: string
128
- stderr: string
129
- /** The hook was killed at its timeout, so its exit code carries no verdict. */
130
- timedOut: boolean
131
- /** The process errored before delivering a verdict (spawn failure, EIO). */
132
- spawnFailed?: boolean
133
- }
134
- /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
135
- export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
136
- /** The shell path specifically; the statusline reuses it for its own command. With an
137
- * `args` array it becomes the exec path: `command` is spawned directly with those args. */
138
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
139
-
140
- /** Settings files to read, newest-winning. Project files load only when trusted, each
141
- * the nearest of its name at or above cwd (bounded at the repository root, matching
142
- * the approval walk), so a subdirectory session reads the settings that gated it. */
143
- export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
144
- const files = [path.join(claudeConfigDir(home), 'settings.json')]
145
- if (!trusted) return files
146
- for (const name of ['settings.json', 'settings.local.json']) {
147
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
148
- }
149
- return files
150
- }
151
-
152
- /** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
153
- * hook misbehaves, so it is honored before any hook runs. Disabled when managed
154
- * settings or ANY file in the settings chain sets it to `true`; deliberately not
155
- * last-file-wins, since a repository file re-enabling the hooks the user just
156
- * disabled in their own settings would defeat the escape hatch. The chain itself
157
- * already gates project files on trust (see hookFiles). */
158
- export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
159
- if (managed.disableAllHooks === true) return true
160
- for (const file of files) {
161
- try {
162
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
163
- if (isRecord(parsed) && parsed.disableAllHooks === true) return true
164
- } catch {
165
- // missing or invalid file: skip
166
- }
167
- }
168
- return false
169
- }
170
-
171
- /** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
172
- * `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
173
- * means no restrictions, an empty array blocks every http hook, and arrays merge
174
- * across settings sources. Merging is a union of managed settings plus every file in
175
- * the chain; the chain already gates project files on trust (see hookFiles), and a
176
- * trusted project can run arbitrary shell hooks anyway, so letting it extend the
177
- * allowlist is no escalation. */
178
- export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
179
- let found: string[] | undefined
180
- const collect = (value: unknown): void => {
181
- if (!Array.isArray(value)) return
182
- found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
183
- }
184
- collect(managed.allowedHttpHookUrls)
185
- for (const file of files) {
186
- try {
187
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
188
- if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
189
- } catch {
190
- // missing or invalid file: skip
191
- }
192
- }
193
- return found
194
- }
195
-
196
- /** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
197
- * of characters; everything else is literal and the whole URL must match. An
198
- * undefined allowlist means the setting is absent, so there are no restrictions. */
199
- export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
200
- if (allowlist === undefined) return true
201
- return allowlist.some((pattern) => {
202
- const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
203
- return new RegExp(`^${literal.join('.*')}$`).test(url)
204
- })
205
- }
206
-
207
- export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
208
- const config: HooksConfig = {}
209
- for (const file of files) {
210
- let raw: string
211
- try {
212
- raw = fs.readFileSync(file, 'utf-8')
213
- } catch {
214
- continue
215
- }
216
- mergeHooksJson(config, raw, file, sources)
217
- }
218
- return config
219
- }
220
-
221
- function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
222
- let parsed: { hooks?: HooksConfig }
223
- try {
224
- parsed = JSON.parse(raw)
225
- } catch {
226
- return
227
- }
228
- for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
229
- if (!Array.isArray(matchers)) continue
230
- // Entries are validated here rather than where they run: a hand-edited settings
231
- // file that writes `hooks` as an object instead of a list used to throw out of
232
- // the tool_call handler, and pi turns that into an error result, so every tool
233
- // call for the rest of the session failed with an opaque type error.
234
- const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
235
- if (usable.length === 0) continue
236
- config[event] = [...(config[event] ?? []), ...usable]
237
- // Each parse produces fresh entry objects, so object identity keys the /hooks
238
- // viewer's source attribution without touching the entries themselves.
239
- for (const entry of usable) sources?.set(entry, source)
240
- }
241
- }
242
-
243
- /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
244
- * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
245
- * hook can name its bundled scripts by real path. */
246
- export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
247
- for (const plugin of plugins) {
248
- const declared = plugin.manifest.hooks
249
- // An inline hooks object; an array is not a valid hooks map (it would parse to
250
- // numeric event keys), so it falls through to the default path rather than
251
- // silently registering nothing.
252
- if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
253
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
254
- continue
255
- }
256
- const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
257
- try {
258
- mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
259
- } catch {
260
- // a plugin without hooks contributes nothing
261
- }
262
- }
263
- }
264
-
265
- /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
266
- * is a list of exact names; anything else is an unanchored regex. */
267
- const EXACT_MATCHER = /^[\w\- ,|]*$/
268
-
269
- /** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
270
- * pi names are lowercase with underscores, so comparison folds both. */
271
- function foldName(name: string): string {
272
- return name.toLowerCase().replaceAll('-', '_')
273
- }
274
-
275
- /** A matcher string's compiled form: a set of folded exact names, or a regex. */
276
- type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
277
-
278
- function exactTokens(matcher: string): Set<string> {
279
- return new Set(
280
- matcher
281
- .split(/[|,]/)
282
- .map((token) => foldName(token.trim()))
283
- .filter(Boolean),
284
- )
285
- }
286
-
287
- /** Hook config is static per session and dispatch consults every matcher on every
288
- * event, so each matcher string compiles once. Matchers are few; the bound is a
289
- * safety net, clearing the (cheap to rebuild) cache rather than evicting. */
290
- const compiledMatchers = new Map<string, CompiledMatcher>()
291
- const COMPILED_MATCHER_BOUND = 1000
292
-
293
- /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
294
- * is reported by name and skipped, so one bad entry costs its own hooks, not the
295
- * session's tool calls. */
296
- function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
297
- const candidate = entry as HookMatcher | null
298
- if (candidate === null || typeof candidate !== 'object') {
299
- console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
300
- return false
301
- }
302
- if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
303
- console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
304
- return false
305
- }
306
- if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
307
- console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
308
- return false
309
- }
310
- return true
311
- }
312
-
313
- let matcherCompiles = 0
314
-
315
- /** Test seam: matcher compilations performed, for asserting memoization. */
316
- export function matcherCompileCount(): number {
317
- return matcherCompiles
318
- }
319
-
320
- /** Test seam: drop compiled matchers so a test observes fresh compiles. */
321
- export function resetMatcherCache(): void {
322
- compiledMatchers.clear()
323
- matcherCompiles = 0
324
- }
325
-
326
- function compileMatcher(matcher: string): CompiledMatcher {
327
- const cached = compiledMatchers.get(matcher)
328
- if (cached !== undefined) return cached
329
- matcherCompiles += 1
330
- let compiled: CompiledMatcher
331
- if (EXACT_MATCHER.test(matcher)) {
332
- compiled = { tokens: exactTokens(matcher) }
333
- } else {
334
- try {
335
- compiled = { regex: new RegExp(matcher, 'i') }
336
- } catch {
337
- // An invalid regex matcher falls back to exact-name matching, as before.
338
- compiled = { tokens: exactTokens(matcher) }
339
- }
340
- }
341
- if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
342
- compiledMatchers.set(matcher, compiled)
343
- return compiled
344
- }
345
-
346
- function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
347
- if (!matcher || matcher === '*') return true
348
- const compiled = compileMatcher(matcher)
349
- if ('regex' in compiled) {
350
- const { regex } = compiled
351
- return names.some((name) => regex.test(name))
352
- }
353
- const { tokens } = compiled
354
- return names.some((name) => tokens.has(foldName(name)))
355
- }
356
-
357
- /** A hook entry pi-code can run: a shell command, an http POST, an in-process
358
- * prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
359
- * registered is still matched here and resolves non-blocking at run time, the same
360
- * way a prompt hook with no model does. */
361
- function isRunnableHook(hook: HookCommand): boolean {
362
- if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
363
- if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
364
- if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
365
- return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
366
- }
367
-
368
- /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
369
- * entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
370
- * (undefined or `command` type) already has one, so this is undefined. */
371
- function syntheticCommand(hook: HookCommand): string | undefined {
372
- if (hook.type === 'http') return hook.url
373
- if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
374
- if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
375
- return undefined
376
- }
377
-
378
- /** A matched entry with its `command` filled in: mirroring the synthetic identity into
379
- * `command` keeps dedup, timeout messages and display working for non-shell hooks. */
380
- function withCommand(raw: HookCommand): HookCommand {
381
- const identity = syntheticCommand(raw)
382
- return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
383
- }
384
-
385
- /** Command specs whose matcher applies to any of the given tool/source names.
386
- * Multiple candidates let one event offer both the pi name and its Claude alias. */
387
- export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
388
- const candidates = typeof names === 'string' ? [names] : names
389
- const result: HookCommand[] = []
390
- const seen = new Set<string>()
391
- for (const entry of matchers ?? []) {
392
- if (!matcherApplies(entry.matcher, candidates)) continue
393
- for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
394
- const hook = withCommand(raw)
395
- // Claude runs a handler defined in more than one settings file once.
396
- if (seen.has(hook.command)) continue
397
- seen.add(hook.command)
398
- result.push(hook)
399
- }
400
- }
401
- return result
402
- }
403
-
404
- /** A hook entry's display identity for the /hooks viewer: the command for shell
405
- * hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
406
- * is named rather than hidden, since a misconfigured entry is exactly what the
407
- * viewer exists to surface. */
408
- function hookIdentity(hook: HookCommand | null | undefined): string {
409
- // A hand-edited settings file can leave a null (or otherwise empty) entry in a
410
- // hooks array; name it rather than let it crash the viewer that exists to surface
411
- // exactly this kind of misconfiguration.
412
- const record: Partial<HookCommand> = hook ?? {}
413
- const type = record.type ?? 'command'
414
- if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
415
- if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
416
- if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
417
- return `command: ${record.command ?? '(missing command)'}`
418
- }
419
-
420
- /** Render the resolved hooks config as a readable per-event summary for /hooks:
421
- * one line per configured hook with its matcher, identity and, when known, the
422
- * settings file it came from. Pure formatting of already-resolved data. */
423
- export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
424
- const lines: string[] = []
425
- for (const [event, matchers] of Object.entries(config)) {
426
- const entryLines: string[] = []
427
- for (const entry of matchers) {
428
- const matcher = entry.matcher || '*'
429
- const source = sources?.get(entry)
430
- const suffix = source ? ` (${source})` : ''
431
- for (const hook of entry.hooks ?? []) {
432
- entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
433
- }
434
- }
435
- if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
436
- }
437
- if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
438
- return lines.join('\n')
439
- }
440
-
441
- function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
442
- try {
443
- return JSON.parse(text)
444
- } catch {
445
- return undefined
446
- }
447
- }
448
-
449
- /** Map a hook's exit code / output to a block-or-allow decision. */
450
- export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
451
- if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
452
- const parsed = tryParseJson(stdout)
453
- const specific = parsed?.hookSpecificOutput
454
- // Claude's "ask" prompts the user; the tool_call handler turns this into a
455
- // ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
456
- // headless run with no dialog to show, which is the safe reading on a gated path.
457
- if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
458
- if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
459
- if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
460
- if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
461
- return { block: false }
462
- }
463
-
464
- /** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
465
- const MAX_HOOK_OUTPUT = 1_000_000
466
-
467
- /** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
468
- const TIMEOUT_EXIT_CODE = 124
469
-
470
- /**
471
- * Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
472
- * direct child alone leaves a grandchild alive holding stdout/stderr.
473
- */
474
- function killTree(child: ChildProcess): void {
475
- try {
476
- // Negative pid targets the whole process group, which `detached` gave the shell.
477
- if (child.pid) {
478
- process.kill(-child.pid, 'SIGKILL')
479
- return
480
- }
481
- } catch {
482
- // Group already reaped, or the platform refused it; fall through to the direct kill.
483
- }
484
- child.kill('SIGKILL')
485
- }
486
-
487
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
488
- new Promise((resolve) => {
489
- // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
490
- // `detached` makes the shell its own process group leader so the timeout can kill
491
- // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
492
- // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
493
- // subprocess Claude spawns, so it is set on the child unconditionally.
494
- const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
495
- if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
496
- // An exec-form hook (an `args` array) spawns the executable directly with those args
497
- // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
498
- // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
499
- // payload survive verbatim). Without args it stays the shell path. Both share the
500
- // same detached process group, so killTree reaches the descendants either way.
501
- const file = Array.isArray(args) ? command : '/bin/sh'
502
- const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
503
- const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
504
- let stdout = ''
505
- let stderr = ''
506
- let settled = false
507
- const finish = (result: HookRunResult): void => {
508
- if (settled) return
509
- settled = true
510
- clearTimeout(timer)
511
- resolve(result)
512
- }
513
- // Resolve from the timer itself rather than waiting for `close`: `close` fires only
514
- // once every stdio pipe is closed, and a grandchild that inherited them can hold the
515
- // promise pending long past the timeout, stalling the tool call that awaits it.
516
- const timer = setTimeout(() => {
517
- killTree(child)
518
- finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
519
- }, timeoutMs)
520
- // Decode on the stream: concatenating Buffers as strings mangles a multi-byte
521
- // character split across chunks, and a mangled byte in a hook's deny decision makes
522
- // it unparseable, which reads as an allow.
523
- child.stdout?.setEncoding('utf8')
524
- child.stderr?.setEncoding('utf8')
525
- child.stdout?.on('data', (chunk: string) => {
526
- if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
527
- })
528
- child.stderr?.on('data', (chunk: string) => {
529
- if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
530
- })
531
- child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
532
- // Marked rather than silently read as a clean run: under fd exhaustion a
533
- // deny-list guard that never spawned would otherwise pass as an allow.
534
- child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
535
- // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
536
- // so ignore EPIPE on this write rather than crashing the host process.
537
- child.stdin?.on('error', () => {})
538
- child.stdin?.end(JSON.stringify(payload))
539
- })
540
-
541
- /** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
542
- * to an unlisted variable becomes an empty string, as Claude documents. */
543
- function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
544
- const allowedSet = new Set(allowed ?? [])
545
- const out: Record<string, string> = {}
546
- for (const [key, value] of Object.entries(headers ?? {})) {
547
- out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
548
- const name = braced ?? bare ?? ''
549
- return allowedSet.has(name) ? (process.env[name] ?? '') : ''
550
- })
551
- }
552
- return out
553
- }
554
-
555
- /**
556
- * Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
557
- * with a valid JSON body renders a decision, read exactly like command stdout.
558
- * Everything else, including non-2xx statuses, connection failures and timeouts,
559
- * is a non-blocking error by contract, so none of these outcomes ever reports
560
- * `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
561
- * allowlist gates the fetch itself: a URL matching no entry is never contacted,
562
- * so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
563
- * the payload; when the setting is absent there are no restrictions, as Claude
564
- * documents. A blocked hook renders no decision, like every other http failure.
565
- */
566
- export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
567
- const url = hook.url ?? hook.command
568
- if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
569
- try {
570
- const response = await fetch(url, {
571
- method: 'POST',
572
- headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
573
- body: JSON.stringify(payload),
574
- signal: AbortSignal.timeout(timeoutMs),
575
- })
576
- const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
577
- if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
578
- if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
579
- try {
580
- JSON.parse(body)
581
- } catch {
582
- return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
583
- }
584
- return { code: 0, stdout: body, stderr: '', timedOut: false }
585
- } catch (error) {
586
- return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
587
- }
588
- }
589
-
590
- /** System prompt turning a prompt hook into a structured decision, so its reply
591
- * flows through interpretHookResult exactly like a command hook's stdout. */
592
- const PROMPT_HOOK_SYSTEM = [
593
- 'You are a Claude Code hook evaluating whether an action should proceed.',
594
- 'Respond with ONLY a JSON object and nothing else:',
595
- '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
596
- 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
597
- ].join('\n')
598
-
599
- /**
600
- * Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
601
- * event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
602
- * in-process via completeText and returns the reply as stdout so the existing
603
- * decision parser handles it. No model (headless) or a provider error is
604
- * non-blocking; only an abort at the timeout fails closed, like the other hooks.
605
- */
606
- /** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
607
- * in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
608
- * `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
609
- function substituteArguments(prompt: string | undefined, payload: unknown): string {
610
- const json = JSON.stringify(payload)
611
- return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
612
- }
613
-
614
- /** Classify a model/agent failure: the deadline is authoritative via the signal (the
615
- * subagent runner rejects with a plain Error on abort, so an error-name check alone
616
- * fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
617
- * produced no verdict and is non-blocking. */
618
- function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
619
- const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
620
- return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
621
- }
622
-
623
- export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
624
- if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
625
- // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
626
- // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
627
- const prompt = substituteArguments(hook.prompt, payload)
628
- const signal = AbortSignal.timeout(timeoutMs)
629
- try {
630
- const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
631
- return { code: 0, stdout: answer, stderr: '', timedOut: false }
632
- } catch (error) {
633
- return abortAwareFailure(signal, error)
634
- }
635
- }
636
-
637
- /**
638
- * Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
639
- * and treat its text output like command stdout. pi reaches the server through the
640
- * mcp-call seam the mcp extension registers. Like http, it never fails closed: a
641
- * missing server, a tool error, or the deadline is non-blocking.
642
- */
643
- export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
644
- if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
645
- const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
646
- let timer: ReturnType<typeof setTimeout> | undefined
647
- const deadline = new Promise<HookRunResult>((resolve) => {
648
- timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
649
- })
650
- const call = callMcpTool(hook.server, hook.tool, input)
651
- .then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
652
- .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
653
- try {
654
- return await Promise.race([call, deadline])
655
- } finally {
656
- // Left running, the deadline timer pins the event loop for the full timeout
657
- // after the call resolves, delaying exit in a one-shot headless run.
658
- clearTimeout(timer)
659
- }
660
- }
661
-
662
- /**
663
- * Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
664
- * verify a condition, then return its final text as a JSON decision, parsed by the
665
- * same interpreter as a command hook. pi reaches the subagent through the agent-run
666
- * seam the subagent extension registers. Like the prompt hook, only an abort at the
667
- * deadline fails closed; a missing runner or a crashed agent is non-blocking.
668
- */
669
- export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
670
- const prompt = substituteArguments(hook.prompt, payload)
671
- const signal = AbortSignal.timeout(timeoutMs)
672
- try {
673
- const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
674
- return { code: 0, stdout: answer, stderr: '', timedOut: false }
675
- } catch (error) {
676
- return abortAwareFailure(signal, error)
677
- }
678
- }
679
-
680
- /** The text of the last assistant message in a turn, for Claude's Stop-hook
681
- * `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
682
- * content is returned as-is. */
683
- export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
684
- for (let i = messages.length - 1; i >= 0; i--) {
685
- const message = messages[i]
686
- if (message.role !== 'assistant') continue
687
- if (typeof message.content === 'string') return message.content
688
- if (!Array.isArray(message.content)) return ''
689
- return message.content
690
- .filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
691
- .map((part) => part.text)
692
- .join('')
693
- }
694
- return ''
695
- }
696
-
697
- /** Claude overrides a Stop hook after it blocks this many times in a row with no user
698
- * progress, ending the turn with a warning rather than looping forever. */
699
- const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
700
-
701
- /** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
702
- * is a positive integer, else the default. A non-positive or malformed value falls back
703
- * to the default rather than capping at zero (which would suppress the very first block). */
704
- export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
705
- const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
706
- return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
707
- }
708
-
709
- /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
710
- const MAX_TIMEOUT_S = 2_147_483
711
-
712
- function timeoutMs(command: HookCommand): number {
713
- // Non-positive values fall back to the default: a 0ms timer would fire before the
714
- // hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
715
- const declared = command.timeout
716
- const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
717
- return seconds * 1000
718
- }
719
-
720
- function isRecord(value: unknown): value is Record<string, unknown> {
721
- return typeof value === 'object' && value !== null && !Array.isArray(value)
722
- }
723
-
724
- /** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
725
- * in-place mutation, so the target object is emptied and refilled rather than reassigned. */
726
- function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
727
- for (const key of Object.keys(target)) delete target[key]
728
- Object.assign(target, next)
729
- }
730
-
731
- /** Claude surfaces a hook error notice; on ungated events the action proceeds, while
732
- * PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
733
- * their spawnFailed checks). Silence would hide that a guard never ran. */
734
- function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
735
- if (!notify) return
736
- for (const [i, result] of results.entries()) {
737
- if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
738
- }
739
- }
740
-
741
- /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
742
- * verdict in config order wins. For MCP tools the matcher sees both the pi name and
743
- * the Claude alias, and the payload reports the alias, which is the name a
744
- * Claude-written hook script expects in tool_name. Every hook sees the original
745
- * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
746
- * hook completes, so with several rewrites the last to finish takes effect, which
747
- * is Claude's documented (non-deterministic) behavior. */
748
- export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
749
- const names = claudeName ? [toolName, claudeName] : [toolName]
750
- const commands = matchingCommands(config.PreToolUse, names)
751
- const results = await Promise.all(
752
- commands.map((command) =>
753
- runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
754
- const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
755
- if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
756
- return result
757
- }),
758
- ),
759
- )
760
- surfaceHookFailures(commands, results, onSystemMessage)
761
- for (const [i, result] of results.entries()) {
762
- // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
763
- // would otherwise read as a clean allow. Fail closed instead.
764
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
765
- // A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
766
- // its code 0 must fail closed like a timeout, not read as an allow exactly when
767
- // the machine is degraded.
768
- if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
769
- }
770
- if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
771
- // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
772
- // scan for any deny first, and only fall back to the first ask.
773
- let ask: HookDecision | undefined
774
- for (const result of results) {
775
- const decision = interpretHookResult(result.code, result.stdout, result.stderr)
776
- if (decision.block && !decision.ask) return decision
777
- if (decision.ask && ask === undefined) ask = decision
778
- }
779
- return ask ?? { block: false }
780
- }
781
-
782
- async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
783
- return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
784
- }
785
-
786
- type SystemMessageSink = (message: string) => void
787
-
788
- /** Claude's universal systemMessage output field: a warning surfaced to the user. */
789
- function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
790
- for (const result of results) {
791
- const message = tryParseJson(result.stdout)?.systemMessage
792
- if (message) notify(message)
793
- }
794
- }
795
-
796
- export interface PromptDecision {
797
- block: boolean
798
- reason?: string
799
- context: string
800
- }
801
-
802
- /** Additional context a UserPromptSubmit hook contributes: an explicit
803
- * hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
804
- function promptContext(stdout: string): string {
805
- const parsed = tryParseJson(stdout)
806
- if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
807
- return stdout.trim()
808
- }
809
-
810
- /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
811
- * verdict in config order wins; otherwise their additional context is concatenated
812
- * in config order for injection ahead of the prompt. */
813
- export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
814
- const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
815
- const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
816
- surfaceHookFailures(commands, results, onSystemMessage)
817
- for (const [i, result] of results.entries()) {
818
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
819
- // No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
820
- if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
821
- }
822
- if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
823
- const contexts: string[] = []
824
- for (const result of results) {
825
- const decision = interpretHookResult(result.code, result.stdout, result.stderr)
826
- if (decision.block) return { block: true, reason: decision.reason, context: '' }
827
- const context = promptContext(result.stdout)
828
- if (context) contexts.push(context)
829
- }
830
- return { block: false, context: contexts.join('\n') }
831
- }
832
-
833
- /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
834
- * offered both spellings so existing configs keep firing either way, and the payload
835
- * reports the Claude value, which is what a Claude-written hook script parses. */
836
- const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
837
- const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
838
- const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
839
-
840
- /** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
841
- function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
842
- const value = map[raw] ?? raw
843
- return { names: value === raw ? [raw] : [raw, value], value }
844
- }
845
-
846
- /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
847
- * tool result: a block notice (exit-2 stderr, or decision:block on success) followed
848
- * by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
849
- * but never a decision:block verdict. */
850
- function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
851
- const lines: string[] = []
852
- const parsed = tryParseJson(result.stdout)
853
- // A failed tool cannot be blocked, but the hook's stderr is still shown; on
854
- // success, exit-2 / decision:block feed back as a block notice.
855
- if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
856
- else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
857
- const context = parsed?.hookSpecificOutput?.additionalContext
858
- if (context) lines.push(context)
859
- return lines
860
- }
861
-
862
- /** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
863
- * an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
864
- * block would otherwise pay for. */
865
- function blockedToolCall(reason: string | undefined): ToolCallEventResult {
866
- return { block: true, reason, terminate: true }
867
- }
868
-
869
- export default function hooksExtension(pi: ExtensionAPI) {
870
- let config: HooksConfig = {}
871
- let projectDir = ''
872
- /** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
873
- let allowedHttpHookUrls: string[] | undefined
874
- let pendingSessionContext: string[] = []
875
- let stopHookActive = false
876
- /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
877
- * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
878
- let stopHookBlockCount = 0
879
- let sessionCtx: ExtensionContext | undefined
880
- /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
881
- let hooksDisabled = false
882
- /** Which settings file each resolved entry came from, for the /hooks viewer. */
883
- const hookSources = new Map<HookMatcher, string>()
884
- /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
885
- const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
886
- const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
887
- const transcript = ctx.sessionManager.getSessionFile()
888
- if (transcript) common.transcript_path = transcript
889
- if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
890
- return common
891
- }
892
- /** A runner bound to the firing context, filling the common fields into each
893
- * payload and dispatching on the entry's type. */
894
- const boundRunner =
895
- (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
896
- (hook, payload, ms) => {
897
- const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
898
- if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
899
- if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
900
- if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
901
- if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
902
- return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
903
- }
904
- // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
905
- // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
906
- const mcpAliases = new Map<string, string>()
907
- pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
908
- if (!isMcpToolAliases(data)) return
909
- mcpAliases.clear()
910
- for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
911
- })
912
- // Claude's permission_mode: pi has no permission system, but pi-code's plan mode is
913
- // the documented "plan" mode; its extension publishes the state on the shared bus.
914
- let permissionMode = 'default'
915
- pi.events.on(PLAN_MODE_CHANNEL, (data) => {
916
- if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
917
- })
918
- // Claude's InstructionsLoaded hook has NO decision control: exit codes are
919
- // ignored and every JSON output field (systemMessage included) is discarded, so
920
- // dispatch is fire-and-forget on all paths. Two documented load reasons can
921
- // never fire honestly and are deliberate gaps, not approximations:
922
- // `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
923
- // entry) and `compact` (pi does not re-load instruction files after compaction).
924
- const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
925
- if (!sessionCtx) return
926
- const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
927
- if (commands.length === 0) return
928
- void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
929
- }
930
- // Every load rides the shared bus: context-imports publishes session_start for
931
- // the context files that survived claudeMdExcludes and include for resolved
932
- // @imports (deduped there, once per file per session); claude-rules publishes
933
- // path_glob_match when a scoped rule attaches. Consuming the bus rather than
934
- // iterating raw contextFiles keeps this extension from announcing a file the
935
- // exclusion removed from the prompt; bus emit is synchronous, so the events
936
- // arrive regardless of extension load order.
937
- pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
938
- if (!isInstructionLoadEvent(data)) return
939
- fireInstructionsLoaded({ ...data })
940
- })
941
-
942
- // Subagent lifecycle arrives over the bus without a pi context; the session context
943
- // captured at session_start supplies the common payload fields.
944
- pi.events.on(SUBAGENT_CHANNEL, async (data) => {
945
- if (!isSubagentPhaseEvent(data) || !sessionCtx) return
946
- const ctx = sessionCtx
947
- const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
948
- const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
949
- try {
950
- const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
951
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
952
- } catch {
953
- // The bus outlives the session: an event landing between /new disposing this
954
- // ctx and the next session_start hits disposed getters, and nothing awaits a
955
- // bus listener, so a throw here would escape as an unhandled rejection.
956
- }
957
- })
958
-
959
- pi.on('session_start', async (event, ctx) => {
960
- sessionCtx = ctx
961
- // One extension instance serves every session. A mid-turn /new fires session_start on
962
- // the same instance while a Stop-hook continuation streak is in flight; it must not
963
- // carry into the next session, so reset before any early return (disableAllHooks below).
964
- stopHookActive = false
965
- stopHookBlockCount = 0
966
- const trusted = await isProjectApproved(ctx)
967
- // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
968
- // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
969
- // subdirectory session too.
970
- projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
971
- const files = hookFiles(ctx.cwd, os.homedir(), trusted)
972
- hookSources.clear()
973
- allowedHttpHookUrls = readAllowedHttpHookUrls(files)
974
- // The disableAllHooks escape hatch, checked before any config loads: with no
975
- // config resolved, no event, plugin hooks included, can fire a hook.
976
- hooksDisabled = readDisableAllHooks(files)
977
- if (hooksDisabled) {
978
- config = {}
979
- pendingSessionContext = []
980
- return
981
- }
982
- config = loadHooks(files, hookSources)
983
- // Plugins are user-installed and enabled by user settings (see installedPlugins),
984
- // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
985
- loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
986
- // "reload" re-fires in-process with the same conversation and would double-run hooks;
987
- // a fork is a genuine session begin, which Claude reports as source "fork".
988
- if (event.reason === 'reload') return
989
- const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
990
- const commands = matchingCommands(config.SessionStart, source.names)
991
- const payload = { hook_event_name: 'SessionStart', source: source.value }
992
- const run = boundRunner(ctx)
993
- const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
994
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
995
- pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
996
- })
997
-
998
- // Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
999
- // conversation before the first prompt; pi's seam for that is a message injected
1000
- // on the next agent start. The session_start InstructionsLoaded events arrive
1001
- // over the bus from context-imports, which owns claudeMdExcludes; announcing
1002
- // the raw contextFiles here would fire for a file the exclusion removed.
1003
- pi.on('before_agent_start', async () => {
1004
- if (pendingSessionContext.length === 0) return
1005
- const content = pendingSessionContext.join('\n')
1006
- pendingSessionContext = []
1007
- return { message: { customType: 'claude-hook-context', content, display: false } }
1008
- })
1009
-
1010
- pi.on('tool_call', async (event, ctx) => {
1011
- 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'))
1012
- if (!decision.block) return undefined
1013
- // Claude's "ask": prompt the user and let the call through if they approve.
1014
- // With no UI (headless) the block stands, which is the safe default.
1015
- if (decision.ask && ctx.hasUI) {
1016
- const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
1017
- return approved ? undefined : blockedToolCall(decision.reason)
1018
- }
1019
- return blockedToolCall(decision.reason)
1020
- })
1021
-
1022
- // Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
1023
- // hook's output back next to the tool result: a decision:block reason (or exit-2
1024
- // stderr) and additionalContext are appended, which is where Claude documents they
1025
- // land. The failure branch shows the hook's stderr to the model too ("Shows stderr
1026
- // to Claude; the tool already failed"), it just cannot block a call that failed.
1027
- pi.on('tool_result', async (event, ctx) => {
1028
- const alias = mcpAliases.get(event.toolName)
1029
- const names = alias ? [event.toolName, alias] : [event.toolName]
1030
- const response = { content: event.content, details: event.details, isError: event.isError }
1031
- const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
1032
- const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
1033
- if (commands.length === 0) return
1034
- const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
1035
- const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
1036
- const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
1037
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
1038
- const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
1039
- if (feedback.length === 0) return
1040
- return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
1041
- })
1042
-
1043
- // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
1044
- // `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
1045
- // so without this a guard that blocks `git push -f` from the model would not stop the
1046
- // same command typed by hand. There is no pi tool call, so the matcher sees both pi's
1047
- // "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
1048
- // reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
1049
- // payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
1050
- // exposes no block flag: a deny is enforced through `result` ("extension handled
1051
- // execution, use this result"), a synthetic failed BashResult that stands in for the
1052
- // command so it never runs and its deny reason shows as the output. The event delivers
1053
- // no execution result and fires only before the command runs, so there is deliberately
1054
- // no PostToolUse for it.
1055
- pi.on('user_bash', async (event, ctx) => {
1056
- const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
1057
- if (!decision.block) return undefined
1058
- // Claude's "ask": prompt before running and let the command through on approval; with
1059
- // no UI (headless) the block stands, the same safe default as the tool_call path.
1060
- if (decision.ask && ctx.hasUI) {
1061
- const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
1062
- if (approved) return undefined
1063
- }
1064
- const reason = decision.reason ?? 'Command blocked by hook'
1065
- return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
1066
- })
1067
-
1068
- pi.on('input', async (event, ctx) => {
1069
- // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
1070
- // prompts the user submitted.
1071
- if (event.source === 'extension') return { action: 'continue' }
1072
- // Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
1073
- // block cap counts only consecutive blocks with nothing from the user in between.
1074
- stopHookBlockCount = 0
1075
- const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
1076
- if (decision.block) {
1077
- // pi's input result has no reason channel, so surface why before consuming it.
1078
- ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
1079
- return { action: 'handled' }
1080
- }
1081
- // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
1082
- // pi's seam for rewriting the submitted text.
1083
- if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
1084
- return { action: 'continue' }
1085
- })
1086
-
1087
- // Claude's Stop hook can prevent stopping: a block feeds its reason back as a new
1088
- // turn, and stop_hook_active in the payload tells the next firing it is already
1089
- // continuing from a stop hook, which is the hook script's documented loop guard.
1090
- // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
1091
- //
1092
- // On agent_end rather than agent_settled: agent_settled is only emitted after every
1093
- // agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
1094
- // handler on a UI dialog, which would starve the Stop hook and idle notification
1095
- // until the user answers it. agent_end can fire slightly early before a rare
1096
- // automatic retry or compaction; that is the better tradeoff.
1097
- pi.on('agent_end', async (event, ctx) => {
1098
- // Claude's Notification event, for the one type pi can honestly source: the
1099
- // agent finished and is waiting for input (idle_prompt). Observational only;
1100
- // exit codes and JSON output are ignored, as Claude documents for this event.
1101
- const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
1102
- if (notifyCommands.length > 0) {
1103
- void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
1104
- }
1105
-
1106
- const commands = matchingCommands(config.Stop, 'Stop')
1107
- if (commands.length === 0) {
1108
- stopHookActive = false
1109
- return
1110
- }
1111
- // Claude's Stop payload carries the turn's final assistant text so a hook need
1112
- // not re-read the transcript; included only when there is one.
1113
- const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
1114
- const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
1115
- const run = boundRunner(ctx)
1116
- const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
1117
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
1118
- const block = results
1119
- .filter((result) => !result.timedOut)
1120
- .map((result) => {
1121
- if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
1122
- const parsed = tryParseJson(result.stdout)
1123
- if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
1124
- return { block: false, reason: '' }
1125
- })
1126
- .find((verdict) => verdict.block)
1127
- if (!block) {
1128
- // A non-blocking Stop breaks the streak: the next block starts a fresh count.
1129
- stopHookActive = false
1130
- stopHookBlockCount = 0
1131
- return
1132
- }
1133
- stopHookBlockCount += 1
1134
- const cap = stopHookBlockCap()
1135
- if (stopHookBlockCount >= cap) {
1136
- // Claude overrides a Stop hook that has blocked cap times in a row with no user
1137
- // progress: suppress the continuation, warn, and let the turn end so the loop cannot
1138
- // run forever. Reset the count so a later run (or user turn) starts clean.
1139
- stopHookActive = false
1140
- stopHookBlockCount = 0
1141
- ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
1142
- return
1143
- }
1144
- stopHookActive = true
1145
- pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
1146
- })
1147
-
1148
- pi.on('session_before_compact', async (event, ctx) => {
1149
- const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
1150
- const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
1151
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
1152
- })
1153
-
1154
- pi.on('session_compact', async (event, ctx) => {
1155
- const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
1156
- const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
1157
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
1158
- })
1159
-
1160
- pi.on('session_shutdown', async (event, ctx) => {
1161
- const reason = claudeSpelling(SESSION_END_REASON, event.reason)
1162
- const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
1163
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
1164
- })
1165
-
1166
- // Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
1167
- // are otherwise opaque, so showing the resolved chain per event, with the settings
1168
- // file each entry came from, is the debugging surface.
1169
- pi.registerCommand('hooks', {
1170
- description: 'Show the hook configuration resolved from settings',
1171
- handler: async (_args, ctx) => {
1172
- if (hooksDisabled) {
1173
- ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
1174
- return
1175
- }
1176
- ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
1177
- },
1178
- })
1179
- }