pi-code 1.0.4 → 1.0.6

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 +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  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 +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -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 +177 -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 +138 -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 +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -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 +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -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
@@ -35,7 +43,10 @@
35
43
  *
36
44
  * Config is merged from ~/.claude/settings.json (always) plus the project's
37
45
  * .claude/settings.json and settings.local.json (only when the project is
38
- * trusted, since hooks execute arbitrary shell). Matchers follow Claude's rule:
46
+ * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
47
+ * setting (managed settings or any honored file in that chain) short-circuits
48
+ * the load entirely, so no event fires any hook; /hooks prints the resolved
49
+ * chain per event with each entry's source settings file. Matchers follow Claude's rule:
39
50
  * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
40
51
  * anything with other regex characters is an unanchored regex. Claude matchers
41
52
  * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
@@ -48,21 +59,46 @@ import { type ChildProcess, spawn } from 'node:child_process'
48
59
  import * as fs from 'node:fs'
49
60
  import * as os from 'node:os'
50
61
  import * as path from 'node:path'
62
+ import type { Api, Model } from '@earendil-works/pi-ai'
51
63
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
52
-
64
+ import { runAgent } from './internal/agent-run.js'
65
+ import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
66
+ import { readManagedSettings } from './internal/managed-settings.js'
53
67
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
68
+ import { callMcpTool } from './internal/mcp-call.js'
69
+ import { completeText } from './internal/model-complete.js'
54
70
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
71
+ import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
55
72
  import { isProjectApproved } from './internal/project-approval.js'
73
+ import { findNearestFile, repoRoot } from './internal/project-root.js'
56
74
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
57
75
 
76
+ // Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
77
+ // PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
78
+ // to fall back on), so ten minutes of default budget would wedge the turn for
79
+ // ten minutes on a hung hook. Hooks that legitimately run long can raise their
80
+ // own per-hook `timeout`.
58
81
  const DEFAULT_TIMEOUT_S = 60
59
82
 
60
83
  interface HookCommand {
61
84
  type?: string
62
85
  command: string
63
86
  timeout?: number
87
+ /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
88
+ url?: string
89
+ headers?: Record<string, string>
90
+ allowedEnvVars?: string[]
91
+ /** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
92
+ prompt?: string
93
+ /** mcp_tool entries: the connected server and tool to call, with optional input. */
94
+ server?: string
95
+ tool?: string
96
+ input?: Record<string, unknown>
97
+ /** prompt/agent entries: an optional model override; agent adds a system prompt. */
98
+ model?: string
99
+ systemPrompt?: string
64
100
  }
65
- interface HookMatcher {
101
+ export interface HookMatcher {
66
102
  matcher?: string
67
103
  hooks: HookCommand[]
68
104
  }
@@ -71,6 +107,9 @@ export type HooksConfig = Record<string, HookMatcher[]>
71
107
  export interface HookDecision {
72
108
  block: boolean
73
109
  reason?: string
110
+ /** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
111
+ * block only on decline. `block` stays true as the no-UI fallback. */
112
+ ask?: boolean
74
113
  }
75
114
  export interface HookRunResult {
76
115
  code: number
@@ -81,37 +120,100 @@ export interface HookRunResult {
81
120
  /** The process errored before delivering a verdict (spawn failure, EIO). */
82
121
  spawnFailed?: boolean
83
122
  }
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. */
123
+ /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
124
+ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
125
+ /** The shell path specifically; the statusline reuses it for its own command. */
126
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
127
+
128
+ /** Settings files to read, newest-winning. Project files load only when trusted, each
129
+ * the nearest of its name at or above cwd (bounded at the repository root, matching
130
+ * the approval walk), so a subdirectory session reads the settings that gated it. */
87
131
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
88
132
  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'))
133
+ if (!trusted) return files
134
+ for (const name of ['settings.json', 'settings.local.json']) {
135
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
136
+ }
90
137
  return files
91
138
  }
92
139
 
93
- export function loadHooks(files: string[]): HooksConfig {
140
+ /** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
141
+ * hook misbehaves, so it is honored before any hook runs. Disabled when managed
142
+ * settings or ANY file in the settings chain sets it to `true`; deliberately not
143
+ * last-file-wins, since a repository file re-enabling the hooks the user just
144
+ * disabled in their own settings would defeat the escape hatch. The chain itself
145
+ * already gates project files on trust (see hookFiles). */
146
+ export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
147
+ if (managed.disableAllHooks === true) return true
148
+ for (const file of files) {
149
+ try {
150
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
151
+ if (isRecord(parsed) && parsed.disableAllHooks === true) return true
152
+ } catch {
153
+ // missing or invalid file: skip
154
+ }
155
+ }
156
+ return false
157
+ }
158
+
159
+ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
94
160
  const config: HooksConfig = {}
95
161
  for (const file of files) {
96
- let parsed: { hooks?: HooksConfig }
162
+ let raw: string
97
163
  try {
98
- parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
164
+ raw = fs.readFileSync(file, 'utf-8')
99
165
  } catch {
100
166
  continue
101
167
  }
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
- }
168
+ mergeHooksJson(config, raw, file, sources)
111
169
  }
112
170
  return config
113
171
  }
114
172
 
173
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
174
+ let parsed: { hooks?: HooksConfig }
175
+ try {
176
+ parsed = JSON.parse(raw)
177
+ } catch {
178
+ return
179
+ }
180
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
181
+ if (!Array.isArray(matchers)) continue
182
+ // Entries are validated here rather than where they run: a hand-edited settings
183
+ // file that writes `hooks` as an object instead of a list used to throw out of
184
+ // the tool_call handler, and pi turns that into an error result, so every tool
185
+ // call for the rest of the session failed with an opaque type error.
186
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
187
+ if (usable.length === 0) continue
188
+ config[event] = [...(config[event] ?? []), ...usable]
189
+ // Each parse produces fresh entry objects, so object identity keys the /hooks
190
+ // viewer's source attribution without touching the entries themselves.
191
+ for (const entry of usable) sources?.set(entry, source)
192
+ }
193
+ }
194
+
195
+ /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
196
+ * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
197
+ * hook can name its bundled scripts by real path. */
198
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
199
+ for (const plugin of plugins) {
200
+ const declared = plugin.manifest.hooks
201
+ // An inline hooks object; an array is not a valid hooks map (it would parse to
202
+ // numeric event keys), so it falls through to the default path rather than
203
+ // silently registering nothing.
204
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
205
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
206
+ continue
207
+ }
208
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
209
+ try {
210
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
211
+ } catch {
212
+ // a plugin without hooks contributes nothing
213
+ }
214
+ }
215
+ }
216
+
115
217
  /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
116
218
  * is a list of exact names; anything else is an unanchored regex. */
117
219
  const EXACT_MATCHER = /^[\w\- ,|]*$/
@@ -163,12 +265,34 @@ function matcherApplies(matcher: string | undefined, names: readonly string[]):
163
265
  }
164
266
  }
165
267
 
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. */
268
+ /** A hook entry pi-code can run: a shell command, an http POST, an in-process
269
+ * prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
270
+ * registered is still matched here and resolves non-blocking at run time, the same
271
+ * way a prompt hook with no model does. */
168
272
  function isRunnableHook(hook: HookCommand): boolean {
273
+ if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
274
+ if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
275
+ if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
169
276
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
170
277
  }
171
278
 
279
+ /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
280
+ * entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
281
+ * (undefined or `command` type) already has one, so this is undefined. */
282
+ function syntheticCommand(hook: HookCommand): string | undefined {
283
+ if (hook.type === 'http') return hook.url
284
+ if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
285
+ if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
286
+ return undefined
287
+ }
288
+
289
+ /** A matched entry with its `command` filled in: mirroring the synthetic identity into
290
+ * `command` keeps dedup, timeout messages and display working for non-shell hooks. */
291
+ function withCommand(raw: HookCommand): HookCommand {
292
+ const identity = syntheticCommand(raw)
293
+ return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
294
+ }
295
+
172
296
  /** Command specs whose matcher applies to any of the given tool/source names.
173
297
  * Multiple candidates let one event offer both the pi name and its Claude alias. */
174
298
  export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
@@ -177,7 +301,8 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
177
301
  const seen = new Set<string>()
178
302
  for (const entry of matchers ?? []) {
179
303
  if (!matcherApplies(entry.matcher, candidates)) continue
180
- for (const hook of (entry.hooks ?? []).filter(isRunnableHook)) {
304
+ for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
305
+ const hook = withCommand(raw)
181
306
  // Claude runs a handler defined in more than one settings file once.
182
307
  if (seen.has(hook.command)) continue
183
308
  seen.add(hook.command)
@@ -187,6 +312,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
187
312
  return result
188
313
  }
189
314
 
315
+ /** A hook entry's display identity for the /hooks viewer: the command for shell
316
+ * hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
317
+ * is named rather than hidden, since a misconfigured entry is exactly what the
318
+ * viewer exists to surface. */
319
+ function hookIdentity(hook: HookCommand | null | undefined): string {
320
+ // A hand-edited settings file can leave a null (or otherwise empty) entry in a
321
+ // hooks array; name it rather than let it crash the viewer that exists to surface
322
+ // exactly this kind of misconfiguration.
323
+ const record: Partial<HookCommand> = hook ?? {}
324
+ const type = record.type ?? 'command'
325
+ if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
326
+ if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
327
+ if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
328
+ return `command: ${record.command ?? '(missing command)'}`
329
+ }
330
+
331
+ /** Render the resolved hooks config as a readable per-event summary for /hooks:
332
+ * one line per configured hook with its matcher, identity and, when known, the
333
+ * settings file it came from. Pure formatting of already-resolved data. */
334
+ export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
335
+ const lines: string[] = []
336
+ for (const [event, matchers] of Object.entries(config)) {
337
+ const entryLines: string[] = []
338
+ for (const entry of matchers) {
339
+ const matcher = entry.matcher || '*'
340
+ const source = sources?.get(entry)
341
+ const suffix = source ? ` (${source})` : ''
342
+ for (const hook of entry.hooks ?? []) {
343
+ entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
344
+ }
345
+ }
346
+ if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
347
+ }
348
+ if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
349
+ return lines.join('\n')
350
+ }
351
+
190
352
  function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
191
353
  try {
192
354
  return JSON.parse(text)
@@ -200,9 +362,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
200
362
  if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
201
363
  const parsed = tryParseJson(stdout)
202
364
  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' }
365
+ // Claude's "ask" prompts the user; the tool_call handler turns this into a
366
+ // ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
367
+ // headless run with no dialog to show, which is the safe reading on a gated path.
368
+ if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
369
+ if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
206
370
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
207
371
  if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
208
372
  return { block: false }
@@ -231,7 +395,7 @@ function killTree(child: ChildProcess): void {
231
395
  child.kill('SIGKILL')
232
396
  }
233
397
 
234
- export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectDir) =>
398
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
235
399
  new Promise((resolve) => {
236
400
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
237
401
  // `detached` makes the shell its own process group leader so the timeout can kill
@@ -276,6 +440,159 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
276
440
  child.stdin?.end(JSON.stringify(payload))
277
441
  })
278
442
 
443
+ /** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
444
+ * to an unlisted variable becomes an empty string, as Claude documents. */
445
+ function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
446
+ const allowedSet = new Set(allowed ?? [])
447
+ const out: Record<string, string> = {}
448
+ for (const [key, value] of Object.entries(headers ?? {})) {
449
+ out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
450
+ const name = braced ?? bare ?? ''
451
+ return allowedSet.has(name) ? (process.env[name] ?? '') : ''
452
+ })
453
+ }
454
+ return out
455
+ }
456
+
457
+ /**
458
+ * Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
459
+ * with a valid JSON body renders a decision, read exactly like command stdout.
460
+ * Everything else, including non-2xx statuses, connection failures and timeouts,
461
+ * is a non-blocking error by contract, so none of these outcomes ever reports
462
+ * `timedOut`, which PreToolUse fails closed on. The user wrote the URL into their
463
+ * own settings, so it carries the same trust as a command hook's shell string and
464
+ * gets no SSRF screening.
465
+ */
466
+ export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
467
+ const url = hook.url ?? hook.command
468
+ try {
469
+ const response = await fetch(url, {
470
+ method: 'POST',
471
+ headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
472
+ body: JSON.stringify(payload),
473
+ signal: AbortSignal.timeout(timeoutMs),
474
+ })
475
+ const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
476
+ if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
477
+ if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
478
+ try {
479
+ JSON.parse(body)
480
+ } catch {
481
+ return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
482
+ }
483
+ return { code: 0, stdout: body, stderr: '', timedOut: false }
484
+ } catch (error) {
485
+ return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
486
+ }
487
+ }
488
+
489
+ /** System prompt turning a prompt hook into a structured decision, so its reply
490
+ * flows through interpretHookResult exactly like a command hook's stdout. */
491
+ const PROMPT_HOOK_SYSTEM = [
492
+ 'You are a Claude Code hook evaluating whether an action should proceed.',
493
+ 'Respond with ONLY a JSON object and nothing else:',
494
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
495
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
496
+ ].join('\n')
497
+
498
+ /**
499
+ * Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
500
+ * event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
501
+ * in-process via completeText and returns the reply as stdout so the existing
502
+ * decision parser handles it. No model (headless) or a provider error is
503
+ * non-blocking; only an abort at the timeout fails closed, like the other hooks.
504
+ */
505
+ /** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
506
+ * in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
507
+ * `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
508
+ function substituteArguments(prompt: string | undefined, payload: unknown): string {
509
+ const json = JSON.stringify(payload)
510
+ return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
511
+ }
512
+
513
+ /** Classify a model/agent failure: the deadline is authoritative via the signal (the
514
+ * subagent runner rejects with a plain Error on abort, so an error-name check alone
515
+ * fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
516
+ * produced no verdict and is non-blocking. */
517
+ function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
518
+ const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
519
+ return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
520
+ }
521
+
522
+ export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
523
+ if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
524
+ // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
525
+ // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
526
+ const prompt = substituteArguments(hook.prompt, payload)
527
+ const signal = AbortSignal.timeout(timeoutMs)
528
+ try {
529
+ const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
530
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
531
+ } catch (error) {
532
+ return abortAwareFailure(signal, error)
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
538
+ * and treat its text output like command stdout. pi reaches the server through the
539
+ * mcp-call seam the mcp extension registers. Like http, it never fails closed: a
540
+ * missing server, a tool error, or the deadline is non-blocking.
541
+ */
542
+ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
543
+ if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
544
+ const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
545
+ let timer: ReturnType<typeof setTimeout> | undefined
546
+ const deadline = new Promise<HookRunResult>((resolve) => {
547
+ timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
548
+ })
549
+ const call = callMcpTool(hook.server, hook.tool, input)
550
+ .then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
551
+ .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
552
+ try {
553
+ return await Promise.race([call, deadline])
554
+ } finally {
555
+ // Left running, the deadline timer pins the event loop for the full timeout
556
+ // after the call resolves, delaying exit in a one-shot headless run.
557
+ clearTimeout(timer)
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
563
+ * verify a condition, then return its final text as a JSON decision, parsed by the
564
+ * same interpreter as a command hook. pi reaches the subagent through the agent-run
565
+ * seam the subagent extension registers. Like the prompt hook, only an abort at the
566
+ * deadline fails closed; a missing runner or a crashed agent is non-blocking.
567
+ */
568
+ export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
569
+ const prompt = substituteArguments(hook.prompt, payload)
570
+ const signal = AbortSignal.timeout(timeoutMs)
571
+ try {
572
+ const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
573
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
574
+ } catch (error) {
575
+ return abortAwareFailure(signal, error)
576
+ }
577
+ }
578
+
579
+ /** The text of the last assistant message in a turn, for Claude's Stop-hook
580
+ * `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
581
+ * content is returned as-is. */
582
+ export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
583
+ for (let i = messages.length - 1; i >= 0; i--) {
584
+ const message = messages[i]
585
+ if (message.role !== 'assistant') continue
586
+ if (typeof message.content === 'string') return message.content
587
+ if (!Array.isArray(message.content)) return ''
588
+ return message.content
589
+ .filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
590
+ .map((part) => part.text)
591
+ .join('')
592
+ }
593
+ return ''
594
+ }
595
+
279
596
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
280
597
  const MAX_TIMEOUT_S = 2_147_483
281
598
 
@@ -319,7 +636,7 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
319
636
  const commands = matchingCommands(config.PreToolUse, names)
320
637
  const results = await Promise.all(
321
638
  commands.map((command) =>
322
- runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
639
+ runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
323
640
  const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
324
641
  if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
325
642
  return result
@@ -333,15 +650,19 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
333
650
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
334
651
  }
335
652
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
653
+ // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
654
+ // scan for any deny first, and only fall back to the first ask.
655
+ let ask: HookDecision | undefined
336
656
  for (const result of results) {
337
657
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
338
- if (decision.block) return decision
658
+ if (decision.block && !decision.ask) return decision
659
+ if (decision.ask && ask === undefined) ask = decision
339
660
  }
340
- return { block: false }
661
+ return ask ?? { block: false }
341
662
  }
342
663
 
343
664
  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))))
665
+ return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
345
666
  }
346
667
 
347
668
  type SystemMessageSink = (message: string) => void
@@ -373,7 +694,7 @@ function promptContext(stdout: string): string {
373
694
  * in config order for injection ahead of the prompt. */
374
695
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
375
696
  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))))
697
+ const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
377
698
  surfaceHookFailures(commands, results, onSystemMessage)
378
699
  for (const [i, result] of results.entries()) {
379
700
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
@@ -402,12 +723,32 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
402
723
  return { names: value === raw ? [raw] : [raw, value], value }
403
724
  }
404
725
 
726
+ /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
727
+ * tool result: a block notice (exit-2 stderr, or decision:block on success) followed
728
+ * by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
729
+ * but never a decision:block verdict. */
730
+ function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
731
+ const lines: string[] = []
732
+ const parsed = tryParseJson(result.stdout)
733
+ // A failed tool cannot be blocked, but the hook's stderr is still shown; on
734
+ // success, exit-2 / decision:block feed back as a block notice.
735
+ if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
736
+ else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
737
+ const context = parsed?.hookSpecificOutput?.additionalContext
738
+ if (context) lines.push(context)
739
+ return lines
740
+ }
741
+
405
742
  export default function hooksExtension(pi: ExtensionAPI) {
406
743
  let config: HooksConfig = {}
407
744
  let projectDir = ''
408
745
  let pendingSessionContext: string[] = []
409
746
  let stopHookActive = false
410
747
  let sessionCtx: ExtensionContext | undefined
748
+ /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
749
+ let hooksDisabled = false
750
+ /** Which settings file each resolved entry came from, for the /hooks viewer. */
751
+ const hookSources = new Map<HookMatcher, string>()
411
752
  /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
412
753
  const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
413
754
  const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
@@ -416,11 +757,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
416
757
  if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
417
758
  return common
418
759
  }
419
- /** A runner bound to the firing context, filling the common fields into each stdin. */
760
+ /** A runner bound to the firing context, filling the common fields into each
761
+ * payload and dispatching on the entry's type. */
420
762
  const boundRunner =
421
763
  (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
422
- (command, payload, ms) =>
423
- runHookCommand(command, { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }, ms, projectDir)
764
+ (hook, payload, ms) => {
765
+ const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
766
+ if (hook.type === 'http') return runHttpHook(hook, merged, ms)
767
+ if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
768
+ if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
769
+ if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
770
+ return runHookCommand(hook.command, merged, ms, projectDir)
771
+ }
424
772
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
425
773
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
426
774
  const mcpAliases = new Map<string, string>()
@@ -435,6 +783,30 @@ export default function hooksExtension(pi: ExtensionAPI) {
435
783
  pi.events.on(PLAN_MODE_CHANNEL, (data) => {
436
784
  if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
437
785
  })
786
+ // Claude's InstructionsLoaded hook has NO decision control: exit codes are
787
+ // ignored and every JSON output field (systemMessage included) is discarded, so
788
+ // dispatch is fire-and-forget on all paths. Two documented load reasons can
789
+ // never fire honestly and are deliberate gaps, not approximations:
790
+ // `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
791
+ // entry) and `compact` (pi does not re-load instruction files after compaction).
792
+ const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
793
+ if (!sessionCtx) return
794
+ const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
795
+ if (commands.length === 0) return
796
+ void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
797
+ }
798
+ // Every load rides the shared bus: context-imports publishes session_start for
799
+ // the context files that survived claudeMdExcludes and include for resolved
800
+ // @imports (deduped there, once per file per session); claude-rules publishes
801
+ // path_glob_match when a scoped rule attaches. Consuming the bus rather than
802
+ // iterating raw contextFiles keeps this extension from announcing a file the
803
+ // exclusion removed from the prompt; bus emit is synchronous, so the events
804
+ // arrive regardless of extension load order.
805
+ pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
806
+ if (!isInstructionLoadEvent(data)) return
807
+ fireInstructionsLoaded({ ...data })
808
+ })
809
+
438
810
  // Subagent lifecycle arrives over the bus without a pi context; the session context
439
811
  // captured at session_start supplies the common payload fields.
440
812
  pi.events.on(SUBAGENT_CHANNEL, async (data) => {
@@ -449,8 +821,24 @@ export default function hooksExtension(pi: ExtensionAPI) {
449
821
  pi.on('session_start', async (event, ctx) => {
450
822
  sessionCtx = ctx
451
823
  const trusted = await isProjectApproved(ctx)
452
- projectDir = ctx.cwd
453
- config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
824
+ // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
825
+ // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
826
+ // subdirectory session too.
827
+ projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
828
+ const files = hookFiles(ctx.cwd, os.homedir(), trusted)
829
+ hookSources.clear()
830
+ // The disableAllHooks escape hatch, checked before any config loads: with no
831
+ // config resolved, no event, plugin hooks included, can fire a hook.
832
+ hooksDisabled = readDisableAllHooks(files)
833
+ if (hooksDisabled) {
834
+ config = {}
835
+ pendingSessionContext = []
836
+ return
837
+ }
838
+ config = loadHooks(files, hookSources)
839
+ // Plugins are user-installed and enabled by user settings (see installedPlugins),
840
+ // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
841
+ loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
454
842
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
455
843
  // a fork is a genuine session begin, which Claude reports as source "fork".
456
844
  if (event.reason === 'reload') return
@@ -458,14 +846,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
458
846
  const commands = matchingCommands(config.SessionStart, source.names)
459
847
  const payload = { hook_event_name: 'SessionStart', source: source.value }
460
848
  const run = boundRunner(ctx)
461
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
849
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
462
850
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
463
851
  pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
464
852
  })
465
853
 
466
854
  // Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
467
855
  // conversation before the first prompt; pi's seam for that is a message injected
468
- // on the next agent start.
856
+ // on the next agent start. The session_start InstructionsLoaded events arrive
857
+ // over the bus from context-imports, which owns claudeMdExcludes; announcing
858
+ // the raw contextFiles here would fire for a file the exclusion removed.
469
859
  pi.on('before_agent_start', async () => {
470
860
  if (pendingSessionContext.length === 0) return
471
861
  const content = pendingSessionContext.join('\n')
@@ -476,47 +866,32 @@ export default function hooksExtension(pi: ExtensionAPI) {
476
866
  pi.on('tool_call', async (event, ctx) => {
477
867
  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
868
  if (!decision.block) return undefined
869
+ // Claude's "ask": prompt the user and let the call through if they approve.
870
+ // With no UI (headless) the block stands, which is the safe default.
871
+ if (decision.ask && ctx.hasUI) {
872
+ const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
873
+ return approved ? undefined : { block: true, reason: decision.reason }
874
+ }
479
875
  return { block: true, reason: decision.reason }
480
876
  })
481
877
 
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).
878
+ // Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
879
+ // hook's output back next to the tool result: a decision:block reason (or exit-2
880
+ // stderr) and additionalContext are appended, which is where Claude documents they
881
+ // land. The failure branch shows the hook's stderr to the model too ("Shows stderr
882
+ // to Claude; the tool already failed"), it just cannot block a call that failed.
486
883
  pi.on('tool_result', async (event, ctx) => {
487
884
  const alias = mcpAliases.get(event.toolName)
488
885
  const names = alias ? [event.toolName, alias] : [event.toolName]
489
886
  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)
887
+ const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
888
+ const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
502
889
  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
- }
890
+ const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
509
891
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
510
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
892
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
511
893
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
512
- const feedback: string[] = []
513
- for (const result of results) {
514
- 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'}`)
517
- const context = parsed?.hookSpecificOutput?.additionalContext
518
- if (context) feedback.push(context)
519
- }
894
+ const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
520
895
  if (feedback.length === 0) return
521
896
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
522
897
  })
@@ -541,15 +916,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
541
916
  // turn, and stop_hook_active in the payload tells the next firing it is already
542
917
  // continuing from a stop hook, which is the hook script's documented loop guard.
543
918
  // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
544
- pi.on('agent_end', async (_event, ctx) => {
919
+ pi.on('agent_end', async (event, ctx) => {
920
+ // Claude's Notification event, for the one type pi can honestly source: the
921
+ // agent finished and is waiting for input (idle_prompt). Observational only;
922
+ // exit codes and JSON output are ignored, as Claude documents for this event.
923
+ const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
924
+ if (notifyCommands.length > 0) {
925
+ void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
926
+ }
927
+
545
928
  const commands = matchingCommands(config.Stop, 'Stop')
546
929
  if (commands.length === 0) {
547
930
  stopHookActive = false
548
931
  return
549
932
  }
550
- const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive }
933
+ // Claude's Stop payload carries the turn's final assistant text so a hook need
934
+ // not re-read the transcript; included only when there is one.
935
+ const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
936
+ const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
551
937
  const run = boundRunner(ctx)
552
- const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
938
+ const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
553
939
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
554
940
  const block = results
555
941
  .filter((result) => !result.timedOut)
@@ -581,4 +967,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
581
967
  const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
582
968
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
583
969
  })
970
+
971
+ // Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
972
+ // are otherwise opaque, so showing the resolved chain per event, with the settings
973
+ // file each entry came from, is the debugging surface.
974
+ pi.registerCommand('hooks', {
975
+ description: 'Show the hook configuration resolved from settings',
976
+ handler: async (_args, ctx) => {
977
+ if (hooksDisabled) {
978
+ ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
979
+ return
980
+ }
981
+ ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
982
+ },
983
+ })
584
984
  }