pi-code 1.0.4 → 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 (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +171 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +125 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -15
  35. 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
@@ -81,37 +116,77 @@ export interface HookRunResult {
81
116
  /** The process errored before delivering a verdict (spawn failure, EIO). */
82
117
  spawnFailed?: boolean
83
118
  }
84
- export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
85
-
86
- /** 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. */
87
127
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
88
128
  const files = [path.join(home, '.claude', 'settings.json')]
89
- 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
+ }
90
133
  return files
91
134
  }
92
135
 
93
136
  export function loadHooks(files: string[]): HooksConfig {
94
137
  const config: HooksConfig = {}
95
138
  for (const file of files) {
96
- let parsed: { hooks?: HooksConfig }
139
+ let raw: string
97
140
  try {
98
- parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
141
+ raw = fs.readFileSync(file, 'utf-8')
99
142
  } catch {
100
143
  continue
101
144
  }
102
- for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
103
- if (!Array.isArray(matchers)) continue
104
- // Entries are validated here rather than where they run: a hand-edited settings
105
- // file that writes `hooks` as an object instead of a list used to throw out of
106
- // the tool_call handler, and pi turns that into an error result, so every tool
107
- // call for the rest of the session failed with an opaque type error.
108
- const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
109
- if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
110
- }
145
+ mergeHooksJson(config, raw, file)
111
146
  }
112
147
  return config
113
148
  }
114
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
+
115
190
  /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
116
191
  * is a list of exact names; anything else is an unanchored regex. */
117
192
  const EXACT_MATCHER = /^[\w\- ,|]*$/
@@ -163,9 +238,14 @@ function matcherApplies(matcher: string | undefined, names: readonly string[]):
163
238
  }
164
239
  }
165
240
 
166
- /** Claude settings may carry prompt/agent hook types with no command; running one
167
- * 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. */
168
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'
169
249
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
170
250
  }
171
251
 
@@ -177,7 +257,12 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
177
257
  const seen = new Set<string>()
178
258
  for (const entry of matchers ?? []) {
179
259
  if (!matcherApplies(entry.matcher, candidates)) continue
180
- 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
181
266
  // Claude runs a handler defined in more than one settings file once.
182
267
  if (seen.has(hook.command)) continue
183
268
  seen.add(hook.command)
@@ -200,9 +285,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
200
285
  if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
201
286
  const parsed = tryParseJson(stdout)
202
287
  const specific = parsed?.hookSpecificOutput
203
- // pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
204
- // rather than a silent allow, which is the least-safe reading on a trust-gated path.
205
- 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' }
206
293
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
207
294
  if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
208
295
  return { block: false }
@@ -231,7 +318,7 @@ function killTree(child: ChildProcess): void {
231
318
  child.kill('SIGKILL')
232
319
  }
233
320
 
234
- export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectDir) =>
321
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
235
322
  new Promise((resolve) => {
236
323
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
237
324
  // `detached` makes the shell its own process group leader so the timeout can kill
@@ -276,6 +363,159 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
276
363
  child.stdin?.end(JSON.stringify(payload))
277
364
  })
278
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
+
279
519
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
280
520
  const MAX_TIMEOUT_S = 2_147_483
281
521
 
@@ -319,7 +559,7 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
319
559
  const commands = matchingCommands(config.PreToolUse, names)
320
560
  const results = await Promise.all(
321
561
  commands.map((command) =>
322
- runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
562
+ runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
323
563
  const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
324
564
  if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
325
565
  return result
@@ -333,15 +573,19 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
333
573
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
334
574
  }
335
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
336
579
  for (const result of results) {
337
580
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
338
- if (decision.block) return decision
581
+ if (decision.block && !decision.ask) return decision
582
+ if (decision.ask && ask === undefined) ask = decision
339
583
  }
340
- return { block: false }
584
+ return ask ?? { block: false }
341
585
  }
342
586
 
343
587
  async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
344
- 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))))
345
589
  }
346
590
 
347
591
  type SystemMessageSink = (message: string) => void
@@ -373,7 +617,7 @@ function promptContext(stdout: string): string {
373
617
  * in config order for injection ahead of the prompt. */
374
618
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
375
619
  const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
376
- const results = await Promise.all(commands.map((command) => runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
620
+ const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
377
621
  surfaceHookFailures(commands, results, onSystemMessage)
378
622
  for (const [i, result] of results.entries()) {
379
623
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
@@ -416,11 +660,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
416
660
  if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
417
661
  return common
418
662
  }
419
- /** 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. */
420
665
  const boundRunner =
421
666
  (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
422
- (command, payload, ms) =>
423
- 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
+ }
424
675
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
425
676
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
426
677
  const mcpAliases = new Map<string, string>()
@@ -435,6 +686,30 @@ export default function hooksExtension(pi: ExtensionAPI) {
435
686
  pi.events.on(PLAN_MODE_CHANNEL, (data) => {
436
687
  if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
437
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
+
438
713
  // Subagent lifecycle arrives over the bus without a pi context; the session context
439
714
  // captured at session_start supplies the common payload fields.
440
715
  pi.events.on(SUBAGENT_CHANNEL, async (data) => {
@@ -449,8 +724,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
449
724
  pi.on('session_start', async (event, ctx) => {
450
725
  sessionCtx = ctx
451
726
  const trusted = await isProjectApproved(ctx)
452
- 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
453
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()))
454
735
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
455
736
  // a fork is a genuine session begin, which Claude reports as source "fork".
456
737
  if (event.reason === 'reload') return
@@ -458,14 +739,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
458
739
  const commands = matchingCommands(config.SessionStart, source.names)
459
740
  const payload = { hook_event_name: 'SessionStart', source: source.value }
460
741
  const run = boundRunner(ctx)
461
- 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))))
462
743
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
463
744
  pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
464
745
  })
465
746
 
466
747
  // Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
467
748
  // conversation before the first prompt; pi's seam for that is a message injected
468
- // 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.
469
752
  pi.on('before_agent_start', async () => {
470
753
  if (pendingSessionContext.length === 0) return
471
754
  const content = pendingSessionContext.join('\n')
@@ -476,44 +759,38 @@ export default function hooksExtension(pi: ExtensionAPI) {
476
759
  pi.on('tool_call', async (event, ctx) => {
477
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'))
478
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
+ }
479
768
  return { block: true, reason: decision.reason }
480
769
  })
481
770
 
482
- // Claude's PostToolUse runs after a successful call and feeds back into the result:
483
- // a decision:block reason (or exit-2 stderr) and additionalContext are appended next
484
- // to the tool result, which is where Claude documents they land. Failed executions
485
- // 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.
486
776
  pi.on('tool_result', async (event, ctx) => {
487
777
  const alias = mcpAliases.get(event.toolName)
488
778
  const names = alias ? [event.toolName, alias] : [event.toolName]
489
779
  const response = { content: event.content, details: event.details, isError: event.isError }
490
- // A failed execution fires Claude's PostToolUseFailure instead: notify-style, no
491
- // result patch, since the error content is already what the model sees.
492
- if (event.isError) {
493
- const failCommands = matchingCommands(config.PostToolUseFailure, names)
494
- if (failCommands.length === 0) return
495
- const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
496
- const failPayload = { hook_event_name: 'PostToolUseFailure', tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
497
- const failResults = await Promise.all(failCommands.map((command) => run(command.command, failPayload, timeoutMs(command))))
498
- surfaceSystemMessages(failResults, (message) => ctx.ui.notify(message, 'warning'))
499
- return
500
- }
501
- const commands = matchingCommands(config.PostToolUse, names)
780
+ const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
781
+ const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
502
782
  if (commands.length === 0) return
503
- const payload = {
504
- hook_event_name: 'PostToolUse',
505
- tool_name: alias ?? event.toolName,
506
- tool_input: event.input,
507
- tool_response: response,
508
- }
783
+ const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
509
784
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
510
- 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))))
511
786
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
512
787
  const feedback: string[] = []
513
788
  for (const result of results) {
514
789
  const parsed = tryParseJson(result.stdout)
515
- if (!result.timedOut && result.code === 2) feedback.push(`PostToolUse hook: ${result.stderr.trim() || 'Blocked by hook'}`)
516
- 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'}`)
517
794
  const context = parsed?.hookSpecificOutput?.additionalContext
518
795
  if (context) feedback.push(context)
519
796
  }
@@ -541,15 +818,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
541
818
  // turn, and stop_hook_active in the payload tells the next firing it is already
542
819
  // continuing from a stop hook, which is the hook script's documented loop guard.
543
820
  // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
544
- 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
+
545
830
  const commands = matchingCommands(config.Stop, 'Stop')
546
831
  if (commands.length === 0) {
547
832
  stopHookActive = false
548
833
  return
549
834
  }
550
- 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 } : {}) }
551
839
  const run = boundRunner(ctx)
552
- 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))))
553
841
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
554
842
  const block = results
555
843
  .filter((result) => !result.timedOut)