pi-code 1.0.5 → 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.
@@ -159,34 +159,60 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
159
159
  }
160
160
  }
161
161
 
162
- /**
163
- * Collect the contents of every file transitively imported via `@path`, in
164
- * discovery order. Imports are resolved through symlinks and kept within
165
- * `allowedRoots` (which must already be realpath'd).
166
- */
167
- export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0, importer?: string, isExcluded?: (realPath: string) => boolean): ImportedFile[] {
162
+ /** Optional controls for a collection run: the byte/file budget shared across the
163
+ * whole run, the path of the file this content came from (seeds each top-level
164
+ * import's `parent`), and the exclusion predicate. Recursion depth is internal. */
165
+ export interface CollectImportsOptions {
166
+ budget?: ImportBudget
167
+ importer?: string
168
+ isExcluded?: (realPath: string) => boolean
169
+ }
170
+
171
+ /** The parts of a collection run that stay fixed across the recursion: resolution
172
+ * roots, the seen/budget accumulators, and the exclusion predicate. Only content,
173
+ * fromDir, depth and the parent path change from one level to the next. */
174
+ interface ImportScan {
175
+ home: string
176
+ allowedRoots: string[]
177
+ seen: Set<string>
178
+ budget: ImportBudget
179
+ isExcluded?: (realPath: string) => boolean
180
+ }
181
+
182
+ /** One recursion level: read the imports named in `content`, then recurse into each. */
183
+ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth: number, importer?: string): ImportedFile[] {
168
184
  if (depth >= MAX_IMPORT_DEPTH) return []
169
185
  const out: ImportedFile[] = []
170
186
  for (const target of importTargets(content)) {
171
187
  // Checked before the read so an exhausted budget costs no I/O.
172
- if (budget.files === 0 || budget.bytes === 0) {
173
- budget.dropped += 1
188
+ if (scan.budget.files === 0 || scan.budget.bytes === 0) {
189
+ scan.budget.dropped += 1
174
190
  continue
175
191
  }
176
- const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
192
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
177
193
  if (!file) continue
178
- budget.files -= 1
179
- const kept = file.body.slice(0, budget.bytes)
180
- budget.bytes -= kept.length
194
+ scan.budget.files -= 1
195
+ const kept = file.body.slice(0, scan.budget.bytes)
196
+ scan.budget.bytes -= kept.length
181
197
  const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
182
198
  // Comments are stripped before the scan for further imports, so a
183
199
  // commented-out @import stays dead at every depth, matching the top level
184
200
  // (whose bodies arrive here already stripped by the caller).
185
- out.push({ path: file.real, body, parent: importer }, ...collectImports(stripBlockComments(kept), path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1, file.real, isExcluded))
201
+ out.push({ path: file.real, body, parent: importer }, ...collectFrom(scan, stripBlockComments(kept), path.dirname(file.real), depth + 1, file.real))
186
202
  }
187
203
  return out
188
204
  }
189
205
 
206
+ /**
207
+ * Collect the contents of every file transitively imported via `@path`, in
208
+ * discovery order. Imports are resolved through symlinks and kept within
209
+ * `allowedRoots` (which must already be realpath'd).
210
+ */
211
+ export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, options: CollectImportsOptions = {}): ImportedFile[] {
212
+ const scan: ImportScan = { home, allowedRoots, seen, budget: options.budget ?? createImportBudget(), isExcluded: options.isExcluded }
213
+ return collectFrom(scan, content, fromDir, 0, options.importer)
214
+ }
215
+
190
216
  /**
191
217
  * Roots an importing file may pull from.
192
218
  *
@@ -357,6 +383,114 @@ export function isExcludedPath(absPath: string, globs: string[], home: string):
357
383
  })
358
384
  }
359
385
 
386
+ /** Apply claudeMdExcludes and comment-stripping to pi's native context blocks,
387
+ * rewriting the assembled prompt by exact substring: an excluded file's block is
388
+ * removed, a surviving file's block is replaced with its comment-stripped body. A
389
+ * wrapper not found in the prompt is skipped rather than risk corrupting it.
390
+ * Returns the rewritten prompt and the files that survived exclusion (stripped). */
391
+ function rewriteNativeBlocks(prompt: string, native: Array<{ path: string; content: string }>, excluded: (absPath: string) => boolean): { prompt: string; changed: boolean; kept: Array<{ path: string; content: string }> } {
392
+ let changed = false
393
+ const kept: Array<{ path: string; content: string }> = []
394
+ for (const file of native) {
395
+ const wrapper = instructionsBlock(file.path, file.content)
396
+ if (excluded(file.path)) {
397
+ const removed = removeBlock(prompt, wrapper)
398
+ if (removed !== null) {
399
+ prompt = removed
400
+ changed = true
401
+ }
402
+ continue
403
+ }
404
+ const stripped = stripBlockComments(file.content)
405
+ if (stripped !== file.content) {
406
+ const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
407
+ if (replaced !== null) {
408
+ prompt = replaced
409
+ changed = true
410
+ }
411
+ }
412
+ kept.push({ path: file.path, content: stripped })
413
+ }
414
+ return { prompt, changed, kept }
415
+ }
416
+
417
+ /** Claude's --add-dir memory files, minus any pi already loaded natively (added to
418
+ * `seenSet` here so an @import cannot pull one in twice) and any the excludes drop
419
+ * or that strip to nothing. Each survivor is comment-stripped and tagged with its
420
+ * additional dir, so its relative imports can resolve from there. */
421
+ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded: (absPath: string) => boolean, includeLocal: boolean): Array<{ path: string; content: string; dir: string }> {
422
+ const extras: Array<{ path: string; content: string; dir: string }> = []
423
+ for (const dir of addDirs) {
424
+ for (const file of additionalDirContextFiles(dir, includeLocal)) {
425
+ const [real] = realRoots([file.path])
426
+ const key = real ?? file.path
427
+ if (seenSet.has(key)) continue // pi already loaded it natively
428
+ seenSet.add(key)
429
+ if (excluded(file.path)) continue
430
+ const stripped = stripBlockComments(file.content)
431
+ if (stripped.trim().length === 0) continue
432
+ extras.push({ path: file.path, content: stripped, dir })
433
+ }
434
+ }
435
+ return extras
436
+ }
437
+
438
+ /** Resolve every context and additional-dir file's @imports through the one shared
439
+ * budget, each with roots scoped to the importing file so a project file never
440
+ * reaches user config. */
441
+ function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, home: string, cwd: string, seenSet: Set<string>, excluded: (absPath: string) => boolean, budget: ImportBudget): ImportedFile[] {
442
+ const imported: ImportedFile[] = []
443
+ for (const file of contextFiles) {
444
+ const allowedRoots = rootsForImporter(file.path, home, cwd)
445
+ imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
446
+ }
447
+ for (const extra of extras) {
448
+ // The additional dir itself is an allowed root, so its files' relative imports
449
+ // resolve even from .claude/rules two levels down.
450
+ const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
451
+ imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
452
+ }
453
+ return imported
454
+ }
455
+
456
+ /** The CLAUDE.local.md bodies appended after the native context, announced as they
457
+ * are added (only the non-empty ones, matching what actually reaches the prompt). */
458
+ function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
459
+ let addition = ''
460
+ for (const local of keptLocals) {
461
+ if (local.content.trim().length > 0) {
462
+ addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
463
+ announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
464
+ }
465
+ }
466
+ return addition
467
+ }
468
+
469
+ /** The --add-dir memory files appended as extra project_instructions blocks.
470
+ * Additional dirs are extra working directories, so their files are Project-typed
471
+ * regardless of where the dir sits (Local for a CLAUDE.local.md). */
472
+ function additionalDirsAddition(extras: Array<{ path: string; content: string; dir: string }>, announce: (event: InstructionLoadEvent) => void): string {
473
+ let addition = ''
474
+ for (const extra of extras) {
475
+ addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
476
+ announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
477
+ }
478
+ return addition
479
+ }
480
+
481
+ /** The `## Imported context (@)` section for every resolved @import, with the
482
+ * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
483
+ * was imported. */
484
+ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
485
+ if (imported.length === 0) return ''
486
+ const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
487
+ const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
488
+ for (const entry of imported) {
489
+ announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
490
+ }
491
+ return `\n\n## Imported context (@)\n\n${section}${notice}`
492
+ }
493
+
360
494
  export default function contextImportsExtension(pi: ExtensionAPI) {
361
495
  let localContexts: Array<{ path: string; content: string }> = []
362
496
  // Whether project settings may contribute claudeMdExcludes; decided at session
@@ -416,37 +550,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
416
550
  const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
417
551
  const projectRoot = repoRoot(cwd) ?? cwd
418
552
 
419
- let prompt = event.systemPrompt
420
- let changed = false
421
-
422
553
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
423
554
  // from import expansion; surviving blocks get block-level comments stripped.
424
- // Both rewrite by exact substring: a wrapper that is not found in the prompt is
425
- // skipped rather than risk corrupting it.
426
- const keptNative: Array<{ path: string; content: string }> = []
427
- for (const file of native) {
428
- const wrapper = instructionsBlock(file.path, file.content)
429
- if (excluded(file.path)) {
430
- const removed = removeBlock(prompt, wrapper)
431
- if (removed !== null) {
432
- prompt = removed
433
- changed = true
434
- }
435
- continue
436
- }
437
- const stripped = stripBlockComments(file.content)
438
- if (stripped !== file.content) {
439
- const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
440
- if (replaced !== null) {
441
- prompt = replaced
442
- changed = true
443
- }
444
- }
445
- keptNative.push({ path: file.path, content: stripped })
446
- // Exclusion is owned here, so the session_start InstructionsLoaded events
447
- // for pi's native context files are published here too, only for files
448
- // that actually survived it: Claude fires no event for a file it never
449
- // loaded. The hooks extension consumes them off the shared bus.
555
+ const rewrite = rewriteNativeBlocks(event.systemPrompt, native, excluded)
556
+ let prompt = rewrite.prompt
557
+ let changed = rewrite.changed
558
+ // Exclusion is owned here, so the session_start InstructionsLoaded events for
559
+ // pi's native context files are published here too, only for files that
560
+ // survived it: Claude fires no event for a file it never loaded. The hooks
561
+ // extension consumes them off the shared bus.
562
+ for (const file of rewrite.kept) {
450
563
  announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
451
564
  }
452
565
 
@@ -460,69 +573,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
460
573
  }
461
574
 
462
575
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
463
- const contextFiles = [...keptNative, ...keptLocals]
576
+ const contextFiles = [...rewrite.kept, ...keptLocals]
464
577
 
465
578
  // Seed with every loaded context file path, excluded ones included, so pi's own
466
579
  // files are never re-imported and an excluded file cannot return as an import.
467
- const seen = realRoots([...native, ...localContexts].map((file) => file.path))
468
- const seenSet = new Set(seen)
580
+ const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
469
581
 
470
582
  // Claude's --add-dir memory loading, env-gated. The files join the seen set
471
583
  // before import expansion so an @import cannot pull one in twice, and they get
472
584
  // the same exclude and comment-strip treatment as native context files.
473
585
  const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
474
- const extras: Array<{ path: string; content: string; dir: string }> = []
475
- for (const dir of addDirs) {
476
- for (const file of additionalDirContextFiles(dir, projectApproved)) {
477
- const [real] = realRoots([file.path])
478
- const key = real ?? file.path
479
- if (seenSet.has(key)) continue // pi already loaded it natively
480
- seenSet.add(key)
481
- if (excluded(file.path)) continue
482
- const stripped = stripBlockComments(file.content)
483
- if (stripped.trim().length === 0) continue
484
- extras.push({ path: file.path, content: stripped, dir })
485
- }
486
- }
586
+ const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
487
587
 
488
- const imported: ImportedFile[] = []
489
588
  // One budget for the whole run, so N context files cannot each spend a full one.
490
589
  // Exclusion applies inside the recursion: an excluded @import is skipped before
491
590
  // it is read, so its transitive imports never load and it spends no budget.
492
591
  const budget = createImportBudget()
493
- for (const file of contextFiles) {
494
- // Roots are scoped per importing file: a project file never reaches user config.
495
- const allowedRoots = rootsForImporter(file.path, home, cwd)
496
- imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget, 0, file.path, excluded))
497
- }
498
- for (const extra of extras) {
499
- // The additional dir itself is an allowed root, so its files' relative
500
- // imports resolve even from .claude/rules two levels down.
501
- const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
502
- imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, budget, 0, extra.path, excluded))
503
- }
592
+ const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
504
593
 
505
- let addition = ''
506
- for (const local of keptLocals) {
507
- if (local.content.trim().length > 0) {
508
- addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
509
- announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
510
- }
511
- }
512
- for (const extra of extras) {
513
- addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
514
- // Additional dirs are extra working directories, so their memory files are
515
- // Project-typed regardless of where the dir sits (Local for CLAUDE.local.md).
516
- announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
517
- }
518
- if (imported.length > 0) {
519
- const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
520
- const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
521
- addition += `\n\n## Imported context (@)\n\n${section}${notice}`
522
- for (const entry of imported) {
523
- announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
524
- }
525
- }
594
+ let addition = localContextAddition(keptLocals, announce)
595
+ addition += additionalDirsAddition(extras, announce)
596
+ addition += importedAddition(imported, budget, home, projectRoot, announce)
526
597
  if (!changed && addition.length === 0) return
527
598
 
528
599
  return { systemPrompt: prompt + addition }
@@ -43,7 +43,10 @@
43
43
  *
44
44
  * Config is merged from ~/.claude/settings.json (always) plus the project's
45
45
  * .claude/settings.json and settings.local.json (only when the project is
46
- * 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:
47
50
  * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
48
51
  * anything with other regex characters is an unanchored regex. Claude matchers
49
52
  * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
@@ -60,6 +63,7 @@ import type { Api, Model } from '@earendil-works/pi-ai'
60
63
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
61
64
  import { runAgent } from './internal/agent-run.js'
62
65
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
66
+ import { readManagedSettings } from './internal/managed-settings.js'
63
67
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
64
68
  import { callMcpTool } from './internal/mcp-call.js'
65
69
  import { completeText } from './internal/model-complete.js'
@@ -94,7 +98,7 @@ interface HookCommand {
94
98
  model?: string
95
99
  systemPrompt?: string
96
100
  }
97
- interface HookMatcher {
101
+ export interface HookMatcher {
98
102
  matcher?: string
99
103
  hooks: HookCommand[]
100
104
  }
@@ -133,7 +137,26 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
133
137
  return files
134
138
  }
135
139
 
136
- 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 {
137
160
  const config: HooksConfig = {}
138
161
  for (const file of files) {
139
162
  let raw: string
@@ -142,12 +165,12 @@ export function loadHooks(files: string[]): HooksConfig {
142
165
  } catch {
143
166
  continue
144
167
  }
145
- mergeHooksJson(config, raw, file)
168
+ mergeHooksJson(config, raw, file, sources)
146
169
  }
147
170
  return config
148
171
  }
149
172
 
150
- function mergeHooksJson(config: HooksConfig, raw: string, source: string): void {
173
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
151
174
  let parsed: { hooks?: HooksConfig }
152
175
  try {
153
176
  parsed = JSON.parse(raw)
@@ -161,26 +184,30 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string): void
161
184
  // the tool_call handler, and pi turns that into an error result, so every tool
162
185
  // call for the rest of the session failed with an opaque type error.
163
186
  const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
164
- if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
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)
165
192
  }
166
193
  }
167
194
 
168
195
  /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
169
196
  * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
170
197
  * hook can name its bundled scripts by real path. */
171
- export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[]): void {
198
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
172
199
  for (const plugin of plugins) {
173
200
  const declared = plugin.manifest.hooks
174
201
  // An inline hooks object; an array is not a valid hooks map (it would parse to
175
202
  // numeric event keys), so it falls through to the default path rather than
176
203
  // silently registering nothing.
177
204
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
178
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`)
205
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
179
206
  continue
180
207
  }
181
208
  const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
182
209
  try {
183
- mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file)
210
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
184
211
  } catch {
185
212
  // a plugin without hooks contributes nothing
186
213
  }
@@ -249,6 +276,23 @@ function isRunnableHook(hook: HookCommand): boolean {
249
276
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
250
277
  }
251
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
+
252
296
  /** Command specs whose matcher applies to any of the given tool/source names.
253
297
  * Multiple candidates let one event offer both the pi name and its Claude alias. */
254
298
  export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
@@ -258,11 +302,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
258
302
  for (const entry of matchers ?? []) {
259
303
  if (!matcherApplies(entry.matcher, candidates)) continue
260
304
  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
305
+ const hook = withCommand(raw)
266
306
  // Claude runs a handler defined in more than one settings file once.
267
307
  if (seen.has(hook.command)) continue
268
308
  seen.add(hook.command)
@@ -272,6 +312,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
272
312
  return result
273
313
  }
274
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
+
275
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 {
276
353
  try {
277
354
  return JSON.parse(text)
@@ -369,7 +446,7 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
369
446
  const allowedSet = new Set(allowed ?? [])
370
447
  const out: Record<string, string> = {}
371
448
  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) => {
449
+ out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
373
450
  const name = braced ?? bare ?? ''
374
451
  return allowedSet.has(name) ? (process.env[name] ?? '') : ''
375
452
  })
@@ -646,12 +723,32 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
646
723
  return { names: value === raw ? [raw] : [raw, value], value }
647
724
  }
648
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
+
649
742
  export default function hooksExtension(pi: ExtensionAPI) {
650
743
  let config: HooksConfig = {}
651
744
  let projectDir = ''
652
745
  let pendingSessionContext: string[] = []
653
746
  let stopHookActive = false
654
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>()
655
752
  /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
656
753
  const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
657
754
  const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
@@ -728,10 +825,20 @@ export default function hooksExtension(pi: ExtensionAPI) {
728
825
  // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
729
826
  // subdirectory session too.
730
827
  projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
731
- config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
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)
732
839
  // Plugins are user-installed and enabled by user settings (see installedPlugins),
733
840
  // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
734
- loadPluginHooks(config, installedPlugins(os.homedir()))
841
+ loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
735
842
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
736
843
  // a fork is a genuine session begin, which Claude reports as source "fork".
737
844
  if (event.reason === 'reload') return
@@ -784,16 +891,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
784
891
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
785
892
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
786
893
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
787
- const feedback: string[] = []
788
- for (const result of results) {
789
- const parsed = tryParseJson(result.stdout)
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'}`)
794
- const context = parsed?.hookSpecificOutput?.additionalContext
795
- if (context) feedback.push(context)
796
- }
894
+ const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
797
895
  if (feedback.length === 0) return
798
896
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
799
897
  })
@@ -869,4 +967,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
869
967
  const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
870
968
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
871
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
+ })
872
984
  }