pi-code 1.0.3 → 1.0.5

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.
Files changed (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. package/package.json +1 -1
@@ -14,8 +14,16 @@
14
14
  * with stop_hook_active as the loop guard)
15
15
  * - PreCompact -> pi `session_before_compact` (fire-and-forget)
16
16
  * - PostCompact -> pi `session_compact` (fire-and-forget)
17
- * - PostToolUseFailure -> pi `tool_result` error branch (fire-and-forget)
17
+ * - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
18
+ * appended to the failed result; it cannot block, the tool failed)
18
19
  * - SessionEnd -> pi `session_shutdown` (fire-and-forget)
20
+ * - InstructionsLoaded -> bridged from the shared instruction-events bus:
21
+ * context-imports publishes session_start for the context
22
+ * files that survived claudeMdExcludes (it owns exclusion,
23
+ * so a file it removed from the prompt never announces)
24
+ * and include for resolved @imports; claude-rules publishes
25
+ * path_glob_match. Strictly observational: exit codes and
26
+ * JSON output, systemMessage included, are ignored.
19
27
  *
20
28
  * Every payload carries session_id, transcript_path (pi's session file), cwd,
21
29
  * permission_mode (plan-mode state off the shared bus) and effort; tool events add
@@ -48,19 +56,43 @@ import { type ChildProcess, spawn } from 'node:child_process'
48
56
  import * as fs from 'node:fs'
49
57
  import * as os from 'node:os'
50
58
  import * as path from 'node:path'
59
+ import type { Api, Model } from '@earendil-works/pi-ai'
51
60
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
52
-
61
+ import { runAgent } from './internal/agent-run.js'
62
+ import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
53
63
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
64
+ import { callMcpTool } from './internal/mcp-call.js'
65
+ import { completeText } from './internal/model-complete.js'
54
66
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
67
+ import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
55
68
  import { isProjectApproved } from './internal/project-approval.js'
69
+ import { findNearestFile, repoRoot } from './internal/project-root.js'
56
70
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
57
71
 
72
+ // Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
73
+ // PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
74
+ // to fall back on), so ten minutes of default budget would wedge the turn for
75
+ // ten minutes on a hung hook. Hooks that legitimately run long can raise their
76
+ // own per-hook `timeout`.
58
77
  const DEFAULT_TIMEOUT_S = 60
59
78
 
60
79
  interface HookCommand {
61
80
  type?: string
62
81
  command: string
63
82
  timeout?: number
83
+ /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
84
+ url?: string
85
+ headers?: Record<string, string>
86
+ allowedEnvVars?: string[]
87
+ /** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
88
+ prompt?: string
89
+ /** mcp_tool entries: the connected server and tool to call, with optional input. */
90
+ server?: string
91
+ tool?: string
92
+ input?: Record<string, unknown>
93
+ /** prompt/agent entries: an optional model override; agent adds a system prompt. */
94
+ model?: string
95
+ systemPrompt?: string
64
96
  }
65
97
  interface HookMatcher {
66
98
  matcher?: string
@@ -71,6 +103,9 @@ export type HooksConfig = Record<string, HookMatcher[]>
71
103
  export interface HookDecision {
72
104
  block: boolean
73
105
  reason?: string
106
+ /** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
107
+ * block only on decline. `block` stays true as the no-UI fallback. */
108
+ ask?: boolean
74
109
  }
75
110
  export interface HookRunResult {
76
111
  code: number
@@ -78,38 +113,80 @@ export interface HookRunResult {
78
113
  stderr: string
79
114
  /** The hook was killed at its timeout, so its exit code carries no verdict. */
80
115
  timedOut: boolean
116
+ /** The process errored before delivering a verdict (spawn failure, EIO). */
117
+ spawnFailed?: boolean
81
118
  }
82
- export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
83
-
84
- /** Settings files to read, newest-winning. Project files load only when trusted. */
119
+ /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
120
+ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
121
+ /** The shell path specifically; the statusline reuses it for its own command. */
122
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
123
+
124
+ /** Settings files to read, newest-winning. Project files load only when trusted, each
125
+ * the nearest of its name at or above cwd (bounded at the repository root, matching
126
+ * the approval walk), so a subdirectory session reads the settings that gated it. */
85
127
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
86
128
  const files = [path.join(home, '.claude', 'settings.json')]
87
- if (trusted) files.push(path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json'))
129
+ if (!trusted) return files
130
+ for (const name of ['settings.json', 'settings.local.json']) {
131
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
132
+ }
88
133
  return files
89
134
  }
90
135
 
91
136
  export function loadHooks(files: string[]): HooksConfig {
92
137
  const config: HooksConfig = {}
93
138
  for (const file of files) {
94
- let parsed: { hooks?: HooksConfig }
139
+ let raw: string
95
140
  try {
96
- parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
141
+ raw = fs.readFileSync(file, 'utf-8')
97
142
  } catch {
98
143
  continue
99
144
  }
100
- for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
101
- if (!Array.isArray(matchers)) continue
102
- // Entries are validated here rather than where they run: a hand-edited settings
103
- // file that writes `hooks` as an object instead of a list used to throw out of
104
- // the tool_call handler, and pi turns that into an error result, so every tool
105
- // call for the rest of the session failed with an opaque type error.
106
- const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
107
- if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
108
- }
145
+ mergeHooksJson(config, raw, file)
109
146
  }
110
147
  return config
111
148
  }
112
149
 
150
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string): void {
151
+ let parsed: { hooks?: HooksConfig }
152
+ try {
153
+ parsed = JSON.parse(raw)
154
+ } catch {
155
+ return
156
+ }
157
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
158
+ if (!Array.isArray(matchers)) continue
159
+ // Entries are validated here rather than where they run: a hand-edited settings
160
+ // file that writes `hooks` as an object instead of a list used to throw out of
161
+ // the tool_call handler, and pi turns that into an error result, so every tool
162
+ // call for the rest of the session failed with an opaque type error.
163
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
164
+ if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
165
+ }
166
+ }
167
+
168
+ /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
169
+ * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
170
+ * hook can name its bundled scripts by real path. */
171
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[]): void {
172
+ for (const plugin of plugins) {
173
+ const declared = plugin.manifest.hooks
174
+ // An inline hooks object; an array is not a valid hooks map (it would parse to
175
+ // numeric event keys), so it falls through to the default path rather than
176
+ // silently registering nothing.
177
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
178
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`)
179
+ continue
180
+ }
181
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
182
+ try {
183
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file)
184
+ } catch {
185
+ // a plugin without hooks contributes nothing
186
+ }
187
+ }
188
+ }
189
+
113
190
  /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
114
191
  * is a list of exact names; anything else is an unanchored regex. */
115
192
  const EXACT_MATCHER = /^[\w\- ,|]*$/
@@ -161,9 +238,14 @@ function matcherApplies(matcher: string | undefined, names: readonly string[]):
161
238
  }
162
239
  }
163
240
 
164
- /** Claude settings may carry prompt/agent hook types with no command; running one
165
- * through `sh -c undefined` would throw out of the tool_call handler. */
241
+ /** A hook entry pi-code can run: a shell command, an http POST, an in-process
242
+ * prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
243
+ * registered is still matched here and resolves non-blocking at run time, the same
244
+ * way a prompt hook with no model does. */
166
245
  function isRunnableHook(hook: HookCommand): boolean {
246
+ if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
247
+ if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
248
+ if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
167
249
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
168
250
  }
169
251
 
@@ -175,7 +257,12 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
175
257
  const seen = new Set<string>()
176
258
  for (const entry of matchers ?? []) {
177
259
  if (!matcherApplies(entry.matcher, candidates)) continue
178
- for (const hook of (entry.hooks ?? []).filter(isRunnableHook)) {
260
+ for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
261
+ // An http/prompt/agent/mcp_tool entry has no `command`; its identity is the
262
+ // url / prompt / server:tool. Mirroring it into `command` keeps dedup, timeout
263
+ // messages and display working.
264
+ const identity = raw.type === 'http' ? raw.url : raw.type === 'prompt' || raw.type === 'agent' ? raw.prompt : raw.type === 'mcp_tool' ? `${raw.server}:${raw.tool}` : undefined
265
+ const hook = identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
179
266
  // Claude runs a handler defined in more than one settings file once.
180
267
  if (seen.has(hook.command)) continue
181
268
  seen.add(hook.command)
@@ -198,9 +285,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
198
285
  if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
199
286
  const parsed = tryParseJson(stdout)
200
287
  const specific = parsed?.hookSpecificOutput
201
- // pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
202
- // rather than a silent allow, which is the least-safe reading on a trust-gated path.
203
- if (specific?.permissionDecision === 'deny' || specific?.permissionDecision === 'ask') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
288
+ // Claude's "ask" prompts the user; the tool_call handler turns this into a
289
+ // ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
290
+ // headless run with no dialog to show, which is the safe reading on a gated path.
291
+ if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
292
+ if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
204
293
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
205
294
  if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
206
295
  return { block: false }
@@ -229,7 +318,7 @@ function killTree(child: ChildProcess): void {
229
318
  child.kill('SIGKILL')
230
319
  }
231
320
 
232
- export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectDir) =>
321
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
233
322
  new Promise((resolve) => {
234
323
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
235
324
  // `detached` makes the shell its own process group leader so the timeout can kill
@@ -265,13 +354,168 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
265
354
  if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
266
355
  })
267
356
  child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
268
- child.on('error', () => finish({ code: 0, stdout, stderr, timedOut: false }))
357
+ // Marked rather than silently read as a clean run: under fd exhaustion a
358
+ // deny-list guard that never spawned would otherwise pass as an allow.
359
+ child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
269
360
  // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
270
361
  // so ignore EPIPE on this write rather than crashing the host process.
271
362
  child.stdin?.on('error', () => {})
272
363
  child.stdin?.end(JSON.stringify(payload))
273
364
  })
274
365
 
366
+ /** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
367
+ * to an unlisted variable becomes an empty string, as Claude documents. */
368
+ function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
369
+ const allowedSet = new Set(allowed ?? [])
370
+ const out: Record<string, string> = {}
371
+ for (const [key, value] of Object.entries(headers ?? {})) {
372
+ out[key] = value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_token, braced?: string, bare?: string) => {
373
+ const name = braced ?? bare ?? ''
374
+ return allowedSet.has(name) ? (process.env[name] ?? '') : ''
375
+ })
376
+ }
377
+ return out
378
+ }
379
+
380
+ /**
381
+ * Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
382
+ * with a valid JSON body renders a decision, read exactly like command stdout.
383
+ * Everything else, including non-2xx statuses, connection failures and timeouts,
384
+ * is a non-blocking error by contract, so none of these outcomes ever reports
385
+ * `timedOut`, which PreToolUse fails closed on. The user wrote the URL into their
386
+ * own settings, so it carries the same trust as a command hook's shell string and
387
+ * gets no SSRF screening.
388
+ */
389
+ export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
390
+ const url = hook.url ?? hook.command
391
+ try {
392
+ const response = await fetch(url, {
393
+ method: 'POST',
394
+ headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
395
+ body: JSON.stringify(payload),
396
+ signal: AbortSignal.timeout(timeoutMs),
397
+ })
398
+ const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
399
+ if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
400
+ if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
401
+ try {
402
+ JSON.parse(body)
403
+ } catch {
404
+ return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
405
+ }
406
+ return { code: 0, stdout: body, stderr: '', timedOut: false }
407
+ } catch (error) {
408
+ return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
409
+ }
410
+ }
411
+
412
+ /** System prompt turning a prompt hook into a structured decision, so its reply
413
+ * flows through interpretHookResult exactly like a command hook's stdout. */
414
+ const PROMPT_HOOK_SYSTEM = [
415
+ 'You are a Claude Code hook evaluating whether an action should proceed.',
416
+ 'Respond with ONLY a JSON object and nothing else:',
417
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
418
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
419
+ ].join('\n')
420
+
421
+ /**
422
+ * Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
423
+ * event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
424
+ * in-process via completeText and returns the reply as stdout so the existing
425
+ * decision parser handles it. No model (headless) or a provider error is
426
+ * non-blocking; only an abort at the timeout fails closed, like the other hooks.
427
+ */
428
+ /** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
429
+ * in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
430
+ * `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
431
+ function substituteArguments(prompt: string | undefined, payload: unknown): string {
432
+ const json = JSON.stringify(payload)
433
+ return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
434
+ }
435
+
436
+ /** Classify a model/agent failure: the deadline is authoritative via the signal (the
437
+ * subagent runner rejects with a plain Error on abort, so an error-name check alone
438
+ * fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
439
+ * produced no verdict and is non-blocking. */
440
+ function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
441
+ const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
442
+ return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
443
+ }
444
+
445
+ export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
446
+ if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
447
+ // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
448
+ // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
449
+ const prompt = substituteArguments(hook.prompt, payload)
450
+ const signal = AbortSignal.timeout(timeoutMs)
451
+ try {
452
+ const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
453
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
454
+ } catch (error) {
455
+ return abortAwareFailure(signal, error)
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
461
+ * and treat its text output like command stdout. pi reaches the server through the
462
+ * mcp-call seam the mcp extension registers. Like http, it never fails closed: a
463
+ * missing server, a tool error, or the deadline is non-blocking.
464
+ */
465
+ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
466
+ if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
467
+ const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
468
+ let timer: ReturnType<typeof setTimeout> | undefined
469
+ const deadline = new Promise<HookRunResult>((resolve) => {
470
+ timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
471
+ })
472
+ const call = callMcpTool(hook.server, hook.tool, input)
473
+ .then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
474
+ .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
475
+ try {
476
+ return await Promise.race([call, deadline])
477
+ } finally {
478
+ // Left running, the deadline timer pins the event loop for the full timeout
479
+ // after the call resolves, delaying exit in a one-shot headless run.
480
+ clearTimeout(timer)
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
486
+ * verify a condition, then return its final text as a JSON decision, parsed by the
487
+ * same interpreter as a command hook. pi reaches the subagent through the agent-run
488
+ * seam the subagent extension registers. Like the prompt hook, only an abort at the
489
+ * deadline fails closed; a missing runner or a crashed agent is non-blocking.
490
+ */
491
+ export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
492
+ const prompt = substituteArguments(hook.prompt, payload)
493
+ const signal = AbortSignal.timeout(timeoutMs)
494
+ try {
495
+ const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
496
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
497
+ } catch (error) {
498
+ return abortAwareFailure(signal, error)
499
+ }
500
+ }
501
+
502
+ /** The text of the last assistant message in a turn, for Claude's Stop-hook
503
+ * `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
504
+ * content is returned as-is. */
505
+ export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
506
+ for (let i = messages.length - 1; i >= 0; i--) {
507
+ const message = messages[i]
508
+ if (message.role !== 'assistant') continue
509
+ if (typeof message.content === 'string') return message.content
510
+ if (!Array.isArray(message.content)) return ''
511
+ return message.content
512
+ .filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
513
+ .map((part) => part.text)
514
+ .join('')
515
+ }
516
+ return ''
517
+ }
518
+
275
519
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
276
520
  const MAX_TIMEOUT_S = 2_147_483
277
521
 
@@ -294,29 +538,54 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
294
538
  Object.assign(target, next)
295
539
  }
296
540
 
297
- /** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
298
- * matcher sees both the pi name and the Claude alias, and the payload reports the alias,
299
- * which is the name a Claude-written hook script expects in tool_name. A hook's
300
- * hookSpecificOutput.updatedInput replaces the tool input in place before the permission
301
- * decision applies, and later hooks see the rewritten input in their payload. */
541
+ /** Claude surfaces a hook error notice and the action proceeds; silence would read a
542
+ * guard that never ran as a clean allow. */
543
+ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
544
+ if (!notify) return
545
+ for (const [i, result] of results.entries()) {
546
+ if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
547
+ }
548
+ }
549
+
550
+ /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
551
+ * verdict in config order wins. For MCP tools the matcher sees both the pi name and
552
+ * the Claude alias, and the payload reports the alias, which is the name a
553
+ * Claude-written hook script expects in tool_name. Every hook sees the original
554
+ * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
555
+ * hook completes, so with several rewrites the last to finish takes effect, which
556
+ * is Claude's documented (non-deterministic) behavior. */
302
557
  export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
303
558
  const names = claudeName ? [toolName, claudeName] : [toolName]
304
- for (const command of matchingCommands(config.PreToolUse, names)) {
305
- const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
559
+ const commands = matchingCommands(config.PreToolUse, names)
560
+ const results = await Promise.all(
561
+ commands.map((command) =>
562
+ runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
563
+ const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
564
+ if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
565
+ return result
566
+ }),
567
+ ),
568
+ )
569
+ surfaceHookFailures(commands, results, onSystemMessage)
570
+ for (const [i, result] of results.entries()) {
306
571
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
307
572
  // would otherwise read as a clean allow. Fail closed instead.
308
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
309
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
310
- const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
311
- if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
573
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
574
+ }
575
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
576
+ // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
577
+ // scan for any deny first, and only fall back to the first ask.
578
+ let ask: HookDecision | undefined
579
+ for (const result of results) {
312
580
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
313
- if (decision.block) return decision
581
+ if (decision.block && !decision.ask) return decision
582
+ if (decision.ask && ask === undefined) ask = decision
314
583
  }
315
- return { block: false }
584
+ return ask ?? { block: false }
316
585
  }
317
586
 
318
587
  async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
319
- return await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
588
+ return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
320
589
  }
321
590
 
322
591
  type SystemMessageSink = (message: string) => void
@@ -343,14 +612,19 @@ function promptContext(stdout: string): string {
343
612
  return stdout.trim()
344
613
  }
345
614
 
346
- /** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
347
- * additional context is concatenated for injection ahead of the prompt. */
615
+ /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
616
+ * verdict in config order wins; otherwise their additional context is concatenated
617
+ * in config order for injection ahead of the prompt. */
348
618
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
619
+ const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
620
+ const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
621
+ surfaceHookFailures(commands, results, onSystemMessage)
622
+ for (const [i, result] of results.entries()) {
623
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
624
+ }
625
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
349
626
  const contexts: string[] = []
350
- for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
351
- const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
352
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
353
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
627
+ for (const result of results) {
354
628
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
355
629
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
356
630
  const context = promptContext(result.stdout)
@@ -386,11 +660,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
386
660
  if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
387
661
  return common
388
662
  }
389
- /** A runner bound to the firing context, filling the common fields into each stdin. */
663
+ /** A runner bound to the firing context, filling the common fields into each
664
+ * payload and dispatching on the entry's type. */
390
665
  const boundRunner =
391
666
  (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
392
- (command, payload, ms) =>
393
- runHookCommand(command, { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }, ms, projectDir)
667
+ (hook, payload, ms) => {
668
+ const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
669
+ if (hook.type === 'http') return runHttpHook(hook, merged, ms)
670
+ if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
671
+ if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
672
+ if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
673
+ return runHookCommand(hook.command, merged, ms, projectDir)
674
+ }
394
675
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
395
676
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
396
677
  const mcpAliases = new Map<string, string>()
@@ -405,6 +686,30 @@ export default function hooksExtension(pi: ExtensionAPI) {
405
686
  pi.events.on(PLAN_MODE_CHANNEL, (data) => {
406
687
  if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
407
688
  })
689
+ // Claude's InstructionsLoaded hook has NO decision control: exit codes are
690
+ // ignored and every JSON output field (systemMessage included) is discarded, so
691
+ // dispatch is fire-and-forget on all paths. Two documented load reasons can
692
+ // never fire honestly and are deliberate gaps, not approximations:
693
+ // `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
694
+ // entry) and `compact` (pi does not re-load instruction files after compaction).
695
+ const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
696
+ if (!sessionCtx) return
697
+ const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
698
+ if (commands.length === 0) return
699
+ void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
700
+ }
701
+ // Every load rides the shared bus: context-imports publishes session_start for
702
+ // the context files that survived claudeMdExcludes and include for resolved
703
+ // @imports (deduped there, once per file per session); claude-rules publishes
704
+ // path_glob_match when a scoped rule attaches. Consuming the bus rather than
705
+ // iterating raw contextFiles keeps this extension from announcing a file the
706
+ // exclusion removed from the prompt; bus emit is synchronous, so the events
707
+ // arrive regardless of extension load order.
708
+ pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
709
+ if (!isInstructionLoadEvent(data)) return
710
+ fireInstructionsLoaded({ ...data })
711
+ })
712
+
408
713
  // Subagent lifecycle arrives over the bus without a pi context; the session context
409
714
  // captured at session_start supplies the common payload fields.
410
715
  pi.events.on(SUBAGENT_CHANNEL, async (data) => {
@@ -419,8 +724,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
419
724
  pi.on('session_start', async (event, ctx) => {
420
725
  sessionCtx = ctx
421
726
  const trusted = await isProjectApproved(ctx)
422
- projectDir = ctx.cwd
727
+ // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
728
+ // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
729
+ // subdirectory session too.
730
+ projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
423
731
  config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
732
+ // Plugins are user-installed and enabled by user settings (see installedPlugins),
733
+ // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
734
+ loadPluginHooks(config, installedPlugins(os.homedir()))
424
735
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
425
736
  // a fork is a genuine session begin, which Claude reports as source "fork".
426
737
  if (event.reason === 'reload') return
@@ -428,14 +739,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
428
739
  const commands = matchingCommands(config.SessionStart, source.names)
429
740
  const payload = { hook_event_name: 'SessionStart', source: source.value }
430
741
  const run = boundRunner(ctx)
431
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
742
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
432
743
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
433
744
  pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
434
745
  })
435
746
 
436
747
  // Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
437
748
  // conversation before the first prompt; pi's seam for that is a message injected
438
- // on the next agent start.
749
+ // on the next agent start. The session_start InstructionsLoaded events arrive
750
+ // over the bus from context-imports, which owns claudeMdExcludes; announcing
751
+ // the raw contextFiles here would fire for a file the exclusion removed.
439
752
  pi.on('before_agent_start', async () => {
440
753
  if (pendingSessionContext.length === 0) return
441
754
  const content = pendingSessionContext.join('\n')
@@ -446,44 +759,38 @@ export default function hooksExtension(pi: ExtensionAPI) {
446
759
  pi.on('tool_call', async (event, ctx) => {
447
760
  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'))
448
761
  if (!decision.block) return undefined
762
+ // Claude's "ask": prompt the user and let the call through if they approve.
763
+ // With no UI (headless) the block stands, which is the safe default.
764
+ if (decision.ask && ctx.hasUI) {
765
+ const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
766
+ return approved ? undefined : { block: true, reason: decision.reason }
767
+ }
449
768
  return { block: true, reason: decision.reason }
450
769
  })
451
770
 
452
- // Claude's PostToolUse runs after a successful call and feeds back into the result:
453
- // a decision:block reason (or exit-2 stderr) and additionalContext are appended next
454
- // to the tool result, which is where Claude documents they land. Failed executions
455
- // are skipped (Claude routes those to PostToolUseFailure, not bridged yet).
771
+ // Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
772
+ // hook's output back next to the tool result: a decision:block reason (or exit-2
773
+ // stderr) and additionalContext are appended, which is where Claude documents they
774
+ // land. The failure branch shows the hook's stderr to the model too ("Shows stderr
775
+ // to Claude; the tool already failed"), it just cannot block a call that failed.
456
776
  pi.on('tool_result', async (event, ctx) => {
457
777
  const alias = mcpAliases.get(event.toolName)
458
778
  const names = alias ? [event.toolName, alias] : [event.toolName]
459
779
  const response = { content: event.content, details: event.details, isError: event.isError }
460
- // A failed execution fires Claude's PostToolUseFailure instead: notify-style, no
461
- // result patch, since the error content is already what the model sees.
462
- if (event.isError) {
463
- const failCommands = matchingCommands(config.PostToolUseFailure, names)
464
- if (failCommands.length === 0) return
465
- const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
466
- const failPayload = { hook_event_name: 'PostToolUseFailure', tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
467
- const failResults = await Promise.all(failCommands.map((command) => run(command.command, failPayload, timeoutMs(command))))
468
- surfaceSystemMessages(failResults, (message) => ctx.ui.notify(message, 'warning'))
469
- return
470
- }
471
- const commands = matchingCommands(config.PostToolUse, names)
780
+ const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
781
+ const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
472
782
  if (commands.length === 0) return
473
- const payload = {
474
- hook_event_name: 'PostToolUse',
475
- tool_name: alias ?? event.toolName,
476
- tool_input: event.input,
477
- tool_response: response,
478
- }
783
+ const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
479
784
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
480
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
785
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
481
786
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
482
787
  const feedback: string[] = []
483
788
  for (const result of results) {
484
789
  const parsed = tryParseJson(result.stdout)
485
- if (!result.timedOut && result.code === 2) feedback.push(`PostToolUse hook: ${result.stderr.trim() || 'Blocked by hook'}`)
486
- else if (parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
790
+ // A failed tool cannot be blocked, but the hook's stderr is still shown; on
791
+ // success, exit-2 / decision:block feed back as a block notice.
792
+ if (!result.timedOut && result.code === 2) feedback.push(`${eventName} hook: ${result.stderr.trim() || (event.isError ? 'hook reported an error' : 'Blocked by hook')}`)
793
+ else if (!event.isError && parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
487
794
  const context = parsed?.hookSpecificOutput?.additionalContext
488
795
  if (context) feedback.push(context)
489
796
  }
@@ -511,15 +818,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
511
818
  // turn, and stop_hook_active in the payload tells the next firing it is already
512
819
  // continuing from a stop hook, which is the hook script's documented loop guard.
513
820
  // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
514
- pi.on('agent_end', async (_event, ctx) => {
821
+ pi.on('agent_end', async (event, ctx) => {
822
+ // Claude's Notification event, for the one type pi can honestly source: the
823
+ // agent finished and is waiting for input (idle_prompt). Observational only;
824
+ // exit codes and JSON output are ignored, as Claude documents for this event.
825
+ const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
826
+ if (notifyCommands.length > 0) {
827
+ void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
828
+ }
829
+
515
830
  const commands = matchingCommands(config.Stop, 'Stop')
516
831
  if (commands.length === 0) {
517
832
  stopHookActive = false
518
833
  return
519
834
  }
520
- const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive }
835
+ // Claude's Stop payload carries the turn's final assistant text so a hook need
836
+ // not re-read the transcript; included only when there is one.
837
+ const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
838
+ const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
521
839
  const run = boundRunner(ctx)
522
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
840
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
523
841
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
524
842
  const block = results
525
843
  .filter((result) => !result.timedOut)