pi-code 1.0.56 → 1.0.58

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 (44) hide show
  1. package/extensions/claude-rules.ts +3 -4
  2. package/extensions/commands.ts +6 -12
  3. package/extensions/context-imports.ts +250 -22
  4. package/extensions/env-settings.ts +1 -5
  5. package/extensions/git-checkpoint.ts +4 -12
  6. package/extensions/goal.ts +2 -2
  7. package/extensions/hooks/config.ts +6 -21
  8. package/extensions/hooks/decisions.ts +3 -2
  9. package/extensions/hooks/index.ts +4 -16
  10. package/extensions/hooks/matcher.ts +2 -1
  11. package/extensions/hooks/runners.ts +4 -3
  12. package/extensions/internal/command-file.ts +7 -241
  13. package/extensions/internal/command-spans.ts +246 -0
  14. package/extensions/internal/managed-settings.ts +3 -5
  15. package/extensions/internal/plugins.ts +2 -2
  16. package/extensions/internal/project-approval.ts +14 -0
  17. package/extensions/internal/settings-chain.ts +19 -0
  18. package/extensions/internal/tool-target.ts +23 -0
  19. package/extensions/internal/values.ts +38 -0
  20. package/extensions/mcp/index.ts +6 -5
  21. package/extensions/mcp/listing.ts +2 -1
  22. package/extensions/mcp/oauth-flow.ts +2 -1
  23. package/extensions/mcp/policy.ts +9 -2
  24. package/extensions/memory.ts +10 -15
  25. package/extensions/output-styles.ts +4 -17
  26. package/extensions/plan-mode/index.ts +9 -9
  27. package/extensions/plan-mode/utils.ts +31 -0
  28. package/extensions/session-title.ts +2 -12
  29. package/extensions/skills.ts +6 -18
  30. package/extensions/status-line.ts +2 -8
  31. package/extensions/subagent/README.md +12 -2
  32. package/extensions/subagent/agents.ts +2 -2
  33. package/extensions/subagent/background.ts +2 -1
  34. package/extensions/subagent/child.ts +197 -0
  35. package/extensions/subagent/concurrency.ts +23 -0
  36. package/extensions/subagent/index.ts +36 -1426
  37. package/extensions/subagent/modes.ts +405 -0
  38. package/extensions/subagent/params.ts +56 -0
  39. package/extensions/subagent/registry-text.ts +105 -0
  40. package/extensions/subagent/render-result.ts +306 -0
  41. package/extensions/subagent/run.ts +375 -0
  42. package/extensions/subagent/types.ts +41 -0
  43. package/extensions/subagent/worktree.ts +2 -1
  44. package/package.json +1 -1
@@ -29,6 +29,7 @@ import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './interna
29
29
  import { isProjectApproved } from './internal/project-approval.js'
30
30
  import { findNearestDir } from './internal/project-root.js'
31
31
  import { stripBlockComments } from './internal/strip-comments.js'
32
+ import { fileToolTarget } from './internal/tool-target.js'
32
33
 
33
34
  export interface Frontmatter {
34
35
  paths: string[]
@@ -341,10 +342,8 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
341
342
  // read or edited rather than inlining it upfront.
342
343
  pi.on('tool_result', async (event, ctx) => {
343
344
  if (attachTargets.length === 0) return
344
- if (event.isError) return
345
- if (event.toolName !== 'read' && event.toolName !== 'edit' && event.toolName !== 'write') return
346
- const rel = (event.input as { path?: unknown } | undefined)?.path
347
- if (typeof rel !== 'string' || rel.length === 0) return
345
+ const rel = fileToolTarget(event)
346
+ if (rel === undefined) return
348
347
  // Realpath both sides (roots canonicalise at session_start): a tool reporting
349
348
  // the resolved real path in a symlinked checkout must still match.
350
349
  const abs = realpathOr(path.resolve(ctx.cwd, rel))
@@ -44,7 +44,8 @@ import * as path from 'node:path'
44
44
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
45
45
  import { Type } from 'typebox'
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
- import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
47
+ import { type DiscoveredCommand, discoverCommandFiles, type ParsedCommand, type PathRuleTool, parseCommandFile, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
+ import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanExec } from './internal/command-spans.js'
48
49
  import { claudeConfigDir } from './internal/config-dir.js'
49
50
  import { claudeEffortLevel } from './internal/effort.js'
50
51
  import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
@@ -55,6 +56,7 @@ import { isProjectApproved } from './internal/project-approval.js'
55
56
  import { ancestorDirs, repoRoot } from './internal/project-root.js'
56
57
  import { claudeSettingsChain } from './internal/settings-chain.js'
57
58
  import { createTurnOverride } from './internal/turn-override.js'
59
+ import { errorMessage, isDirectory } from './internal/values.js'
58
60
 
59
61
  /** Just enough of pi's Model to match and restore; getAvailable returns these. */
60
62
  interface ModelLike {
@@ -105,14 +107,6 @@ interface VarContext {
105
107
  modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
106
108
  }
107
109
 
108
- function isDirectory(target: string): boolean {
109
- try {
110
- return fs.statSync(target).isDirectory()
111
- } catch {
112
- return false
113
- }
114
- }
115
-
116
110
  /** Existing `.claude/commands` directories in Claude's precedence order (later
117
111
  * directories win in collectCommands): project first, then personal, then the
118
112
  * enterprise directory beside the managed settings file, per "enterprise
@@ -328,7 +322,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
328
322
  // A refused switch leaves the turn on the session model rather than the one the
329
323
  // command named, and the reply gives no sign of it, so the refusal is reported.
330
324
  void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
331
- console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${error instanceof Error ? error.message : String(error)}`)
325
+ console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
332
326
  })
333
327
  },
334
328
  })
@@ -455,7 +449,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
455
449
  } catch (error) {
456
450
  // A failed injected command aborts the invocation; the model never sees a
457
451
  // half-expanded body. The notify carries Claude's failure message format.
458
- ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
452
+ ctx.ui.notify(errorMessage(error), 'error')
459
453
  return
460
454
  }
461
455
 
@@ -513,7 +507,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
513
507
  // An unreadable file must not take down session start, but the command is then
514
508
  // absent from /help and unresolvable by the model, which looks like one that was
515
509
  // never written.
516
- console.warn(`pi-code-commands: ignoring ${command.filePath}: ${error instanceof Error ? error.message : String(error)}`)
510
+ console.warn(`pi-code-commands: ignoring ${command.filePath}: ${errorMessage(error)}`)
517
511
  continue
518
512
  }
519
513
  discovered.set(command.name, command)
@@ -72,13 +72,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
72
72
  import { claudeConfigDir } from './internal/config-dir.js'
73
73
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
74
74
  import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
75
- import { sliceBytes } from './internal/output-guard.js'
75
+ import { capForContext, sliceBytes } from './internal/output-guard.js'
76
76
  import { globToRegExpSource } from './internal/path-rules.js'
77
- import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
77
+ import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
78
78
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
79
- import { claudeSettingsChain } from './internal/settings-chain.js'
79
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
80
80
  import { statToken } from './internal/stat-token.js'
81
81
  import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
82
+ import { fileToolTarget } from './internal/tool-target.js'
82
83
 
83
84
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
84
85
  const MAX_IMPORT_DEPTH = 4
@@ -138,9 +139,12 @@ export interface ImportBudget {
138
139
  files: number
139
140
  bytes: number
140
141
  dropped: number
142
+ /** Existing files an @import named that resolve outside the importer's allowed
143
+ * roots. Collected so the refusal can be reported rather than left silent. */
144
+ refused: Set<string>
141
145
  }
142
146
 
143
- export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
147
+ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
144
148
 
145
149
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
146
150
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
@@ -162,16 +166,28 @@ function importTargets(content: string): string[] {
162
166
  }
163
167
 
164
168
  /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
165
- function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded?: (realPath: string) => boolean): { real: string; body: string } | null {
169
+ function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refused: Set<string>): { real: string; body: string } | null {
166
170
  const resolved = path.resolve(fromDir, expandHome(target, home))
171
+ // Always the path the importing file named, never where a symlink pointed: the
172
+ // notice would otherwise hand a repo the real name of whatever the link reaches,
173
+ // which is the disclosure the refusal exists to prevent. An excluded file is not
174
+ // named either, since exclusion removes it from every other surface too.
175
+ const refuse = (): null => {
176
+ if (isExcluded?.(resolved) !== true) refused.add(resolved)
177
+ return null
178
+ }
167
179
  let real: string
168
180
  try {
169
181
  real = fs.realpathSync(resolved)
170
182
  } catch {
171
- return null
183
+ // Not on disk. Still refused when it points outside, so that whether a path is
184
+ // reported never depends on whether it exists: a notice that named only the
185
+ // existing ones would enumerate the filesystem for any repo-controlled file
186
+ // willing to write one @line per guess.
187
+ return isUnder(resolved, allowedRoots) ? null : refuse()
172
188
  }
173
189
  if (seen.has(real)) return null
174
- if (!isUnder(real, allowedRoots)) return null
190
+ if (!isUnder(real, allowedRoots)) return refuse()
175
191
  // Checked before the read so an excluded file contributes nothing: no body, no
176
192
  // transitive imports, no budget spend, no announce. A post-collection filter
177
193
  // would drop the file itself but keep its children.
@@ -182,6 +198,9 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
182
198
  // Only a consumed file dedupes: marking a blocked or unreadable target seen
183
199
  // would let one reader's failure suppress the import for a later, allowed one.
184
200
  seen.add(real)
201
+ // A file another importer already refused is in context after all; the notice
202
+ // must not claim otherwise.
203
+ refused.delete(resolved)
185
204
  return { real, body }
186
205
  } catch {
187
206
  return null
@@ -218,7 +237,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
218
237
  scan.budget.dropped += 1
219
238
  continue
220
239
  }
221
- const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
240
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
222
241
  if (!file) continue
223
242
  scan.budget.files -= 1
224
243
  // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
@@ -406,15 +425,7 @@ export function readClaudeMdExcludes(files: string[], managed: Record<string, un
406
425
  if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
407
426
  }
408
427
  }
409
- for (const file of files) {
410
- try {
411
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
412
- if (settings === null || typeof settings !== 'object') continue
413
- collect((settings as Record<string, unknown>).claudeMdExcludes)
414
- } catch {
415
- // missing or invalid settings file: skip
416
- }
417
- }
428
+ for (const settings of readSettingsChain(files)) collect(settings.claudeMdExcludes)
418
429
  collect(managed.claudeMdExcludes)
419
430
  return globs
420
431
  }
@@ -505,6 +516,156 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
505
516
  return imported
506
517
  }
507
518
 
519
+ /** Every context file that could load on demand for a touched directory, shallowest
520
+ * first, so the deepest instructions are read last as they are at launch. */
521
+ function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
522
+ for (const dir of nestedContextDirs(touchedDir, realCwd)) {
523
+ for (const name of NESTED_CONTEXT_NAMES) yield { file: path.join(dir, name), dir, name }
524
+ }
525
+ }
526
+
527
+ /** Everything the on-demand load needs from the session and the tool call, grouped so
528
+ * the per-file worker stays a three-argument function. */
529
+ interface NestedLoadContext {
530
+ home: string
531
+ cwd: string
532
+ realCwd: string
533
+ projectRoot: string
534
+ excludeGlobs: string[]
535
+ /** The file whose access triggered the load, for trigger_file_path. */
536
+ touched: string
537
+ /** Paths already in the system prompt, so a nested @import does not repeat one. */
538
+ launchLoaded: string[]
539
+ }
540
+
541
+ /** One nested context file's block and the instruction loads it should announce, or
542
+ * nothing to attach: absent, excluded, empty, or reaching outside the project. `read`
543
+ * reports whether the file was there at all, so a file that exists is only ever
544
+ * attached once while a missing one can still appear later in the session. */
545
+ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext): { read: boolean; text?: string; events?: InstructionLoadEvent[] } {
546
+ if (isExcludedPath(file, load.excludeGlobs, load.home)) return { read: false }
547
+ // The file itself may be a link out of the project, whatever its directory is.
548
+ const [real] = realRoots([file])
549
+ if (real === undefined || !isUnder(real, [load.realCwd])) return { read: false }
550
+ const content = readContextFile(real)
551
+ if (content === undefined) return { read: false }
552
+ const body = stripBlockComments(content).trim()
553
+ if (body.length === 0) return { read: true }
554
+ // Its own @imports resolve at project roots, on a budget of their own: this load is
555
+ // outside the launch-time expansion the shared budget covers. The launch-time paths
556
+ // seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
557
+ // a body already in the system prompt.
558
+ const seen = new Set([...load.launchLoaded, real])
559
+ const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
560
+ importer: real,
561
+ isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
562
+ budget: createImportBudget(),
563
+ })
564
+ return {
565
+ read: true,
566
+ text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n'),
567
+ events: [
568
+ { file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
569
+ ...imports.map((entry) => ({
570
+ file_path: entry.path,
571
+ memory_type: memoryTypeForPath(entry.path, load.home, load.projectRoot),
572
+ load_reason: 'include',
573
+ parent_file_path: file,
574
+ })),
575
+ ],
576
+ }
577
+ }
578
+
579
+ /** The context files Claude loads on demand rather than at launch, in the order it
580
+ * reads them within a directory. */
581
+ const NESTED_CONTEXT_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
582
+
583
+ /** The directories between a touched file and cwd, nearest cwd first.
584
+ *
585
+ * Claude loads CLAUDE.md from cwd and every directory above it at launch, and the
586
+ * ones below "are included when Claude reads files in those directories"
587
+ * (memory.md). Only the strictly-below range belongs here; cwd's own file is
588
+ * already in the prompt. A file outside cwd contributes nothing.
589
+ */
590
+ function nestedContextDirs(from: string, cwd: string): string[] {
591
+ const dirs: string[] = []
592
+ let current = from
593
+ while (current !== cwd && current !== path.dirname(current)) {
594
+ dirs.unshift(current)
595
+ current = path.dirname(current)
596
+ }
597
+ // The walk reached the filesystem root without meeting cwd, so the file is outside
598
+ // it: nothing below cwd to load. A file in cwd itself ends the loop with no dirs.
599
+ return current === cwd ? dirs : []
600
+ }
601
+
602
+ /** The context-file names pi prefers over CLAUDE.md in the same directory. Mirrors
603
+ * pi's own lookup order (init.ts CONTEXT_FILE_CANDIDATES); the sibling search below
604
+ * keys off what pi actually loaded, so this is only used to recognize those files. */
605
+ const AGENTS_FILE_NAMES = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
606
+
607
+ /** The blocks and instruction loads for one touched directory. `loaded` is the
608
+ * session's set of already-attached files and is updated in place, so a file that
609
+ * exists is attached once and a missing one can still appear later. */
610
+ function nestedContextAttachments(touchedDir: string, load: NestedLoadContext, loaded: Set<string>, localsApproved: boolean): { bodies: string[]; events: InstructionLoadEvent[] } {
611
+ const bodies: string[] = []
612
+ const events: InstructionLoadEvent[] = []
613
+ for (const { file, dir, name } of nestedCandidates(touchedDir, load.realCwd)) {
614
+ if (loaded.has(file)) continue
615
+ // A CLAUDE.local.md needs a decision, never the "nothing here to gate" shortcut:
616
+ // the approval walk only looks at or above cwd, so this is the one door it cannot
617
+ // see (see isGatedFileApproved).
618
+ if (name === 'CLAUDE.local.md' && !localsApproved) continue
619
+ const block = nestedContextBlock(file, dir, load)
620
+ if (block.read) loaded.add(file)
621
+ if (block.text === undefined) continue
622
+ bodies.push(block.text)
623
+ events.push(...(block.events ?? []))
624
+ }
625
+ return { bodies, events }
626
+ }
627
+
628
+ /** The CLAUDE.md files pi passed over.
629
+ *
630
+ * Claude Code reads CLAUDE.md and never AGENTS.md, and its documented recipe for a
631
+ * repository that already has an AGENTS.md is a CLAUDE.md that imports it and adds
632
+ * Claude-specific instructions below (memory.md, "AGENTS.md"). pi loads one context
633
+ * file per directory and prefers AGENTS.md, so on exactly that layout the
634
+ * Claude-specific half never reaches the prompt. Each one found here is loaded like
635
+ * any other project file: exclude-checked, comment-stripped, imports expanded.
636
+ *
637
+ * The `@AGENTS.md` the recipe opens with costs nothing: pi's own file paths already
638
+ * seed the import seen-set, so the body it names is not injected a second time. */
639
+ function siblingClaudeMdFiles(native: Array<{ path: string; content: string }>): Array<{ path: string; content: string }> {
640
+ const loaded = new Set(realRoots(native.map((file) => file.path)))
641
+ const found: Array<{ path: string; content: string }> = []
642
+ for (const file of native) {
643
+ if (!AGENTS_FILE_NAMES.has(path.basename(file.path))) continue
644
+ // CLAUDE.md only: pi also answers to CLAUDE.MD, but Claude Code reads the one
645
+ // spelling, and on a case-insensitive filesystem looking for both finds the same
646
+ // file twice under two names.
647
+ const candidate = path.join(path.dirname(file.path), 'CLAUDE.md')
648
+ const [real] = realRoots([candidate])
649
+ if (real !== undefined && loaded.has(real)) continue
650
+ const content = readContextFile(candidate)
651
+ if (content === undefined) continue
652
+ found.push({ path: candidate, content })
653
+ }
654
+ return found
655
+ }
656
+
657
+ /** The CLAUDE.md files pi passed over, appended as project_instructions blocks in the
658
+ * ./CLAUDE.md slot, ahead of the ./.claude/CLAUDE.md alternate and the locals. */
659
+ function siblingClaudeMdAddition(siblings: Array<{ path: string; content: string }>, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
660
+ let addition = ''
661
+ for (const sibling of siblings) {
662
+ if (sibling.content.trim().length === 0) continue
663
+ announce({ file_path: sibling.path, memory_type: memoryTypeForPath(sibling.path, home, projectRoot), load_reason: 'session_start' })
664
+ addition += `\n\n${instructionsBlock(sibling.path, sibling.content.trim())}`
665
+ }
666
+ return addition
667
+ }
668
+
508
669
  /** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
509
670
  * announced as it is added (only when non-empty, matching what reaches the prompt).
510
671
  * Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
@@ -543,6 +704,19 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
543
704
  /** The `## Imported context (@)` section for every resolved @import, with the
544
705
  * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
545
706
  * was imported. */
707
+ /** The refusal notice: every existing file an @import named that its importer may not
708
+ * reach. Claude asks about these through an approval dialog and loads the ones you
709
+ * allow; pi-code refuses them, and this is what says so. Silence was the real defect:
710
+ * the importing file looks loaded and its instructions are simply not there. */
711
+ function refusedImportsAddition(refused: Set<string>): string {
712
+ if (refused.size === 0) return ''
713
+ const list = [...refused]
714
+ .sort((a, b) => a.localeCompare(b, 'en'))
715
+ .map((file) => `- ${file}`)
716
+ .join('\n')
717
+ return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
718
+ }
719
+
546
720
  function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
547
721
  if (imported.length === 0) return ''
548
722
  const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
@@ -621,9 +795,19 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
621
795
  // natively but not this one; repo-controlled, so it is approval-gated like the
622
796
  // locals and read once at session start.
623
797
  let projectDotClaude: { path: string; content: string } | undefined
798
+ // Whether a file that is itself the thing to gate may be read. Decided at session
799
+ // start like the flag below, from the session context, which is where the trust
800
+ // capability lives; a tool_result context carries no approval state.
801
+ let gatedFilesApproved = false
624
802
  // Whether project settings may contribute claudeMdExcludes; decided at session
625
803
  // start with the silent check, so no prompt fires mid-flight.
626
804
  let projectApproved = false
805
+ // Every context file the last turn put in the system prompt, so a nested file's
806
+ // @import cannot pay for a body that is already there.
807
+ let launchLoadedPaths: string[] = []
808
+ // Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
809
+ // read in the same subtree does not repeat them.
810
+ const nestedLoaded = new Set<string>()
627
811
  // Instruction loads already announced on the shared bus, keyed reason:path.
628
812
  // before_agent_start fires every turn, so without this a configured
629
813
  // InstructionsLoaded hook would fire once per file per turn.
@@ -668,6 +852,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
668
852
  memoKey: string,
669
853
  native: Array<{ path: string; content: string }>,
670
854
  contextFiles: Array<{ path: string; content: string }>,
855
+ siblings: Array<{ path: string; content: string }>,
671
856
  home: string,
672
857
  cwd: string,
673
858
  excluded: (absPath: string) => boolean,
@@ -680,7 +865,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
680
865
  // files are never re-imported and an excluded file cannot return as an import.
681
866
  // The user, project-.claude and managed-file additions join the seed too, so a
682
867
  // context file's @import cannot pull any of them in a second time.
683
- const ownPaths = [...native, ...localContexts].map((file) => file.path)
868
+ const ownPaths = [...native, ...localContexts, ...siblings].map((file) => file.path)
684
869
  if (userContext !== undefined) ownPaths.push(userContext.path)
685
870
  if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
686
871
  ownPaths.push(managedClaudeMdPath())
@@ -757,6 +942,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
757
942
  }
758
943
  // Read after the local-context flow so an approval it just recorded is honored.
759
944
  projectApproved = isProjectApprovedSilently(ctx)
945
+ gatedFilesApproved = isGatedFileApproved(ctx)
760
946
  })
761
947
 
762
948
  pi.on('before_agent_start', async (event) => {
@@ -800,33 +986,75 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
800
986
 
801
987
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
802
988
 
989
+ // Repo-controlled text, so gated on the same approval as the locals and the
990
+ // ./.claude/CLAUDE.md; read here rather than at session start because only the
991
+ // turn's contextFiles say which file pi actually chose per directory.
992
+ const keptSiblings = (projectApproved ? siblingClaudeMdFiles(native) : []).filter((sibling) => !excluded(sibling.path)).map((sibling) => ({ path: sibling.path, content: stripBlockComments(sibling.content) }))
993
+
803
994
  // ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
804
995
  // loads it too there is no double block, then exclude-checked and comment-stripped
805
996
  // like the rest. Its @imports resolve at project roots (rootsForImporter).
806
997
  const nativeReal = new Set(realRoots(native.map((file) => file.path)))
807
998
  const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
808
- const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
999
+ const siblingReal = new Set(realRoots(keptSiblings.map((sibling) => sibling.path)))
1000
+ const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && (nativeReal.has(dotReal) || siblingReal.has(dotReal))) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
809
1001
 
810
1002
  // The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
811
1003
  // @imports resolve (each at roots scoped to it, via rootsForImporter); their own
812
1004
  // bodies are placed separately, so expansion only surfaces what they import.
813
- const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
1005
+ const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...keptSiblings, ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
814
1006
 
815
1007
  // Everything the expansion depends on, hashed: a turn whose inputs match the memo
816
1008
  // and whose recorded mtimes are unchanged reuses the previous expansion outright.
817
1009
  const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
818
1010
  const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
819
1011
 
820
- const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
1012
+ const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, keptSiblings, home, cwd, excluded)
1013
+ launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
821
1014
 
822
1015
  // Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
823
1016
  // additions, ahead of the CLAUDE.local.md bodies.
824
- let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
1017
+ let addition = siblingClaudeMdAddition(keptSiblings, home, projectRoot, announce)
1018
+ addition += projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
825
1019
  addition += localContextAddition(keptLocals, announce)
826
1020
  addition += additionalDirsAddition(extras, announce)
827
1021
  addition += importedAddition(imported, budget, home, projectRoot, announce)
1022
+ addition += refusedImportsAddition(budget.refused)
828
1023
  if (!changed && addition.length === 0) return
829
1024
 
830
1025
  return { systemPrompt: prompt + addition }
831
1026
  })
1027
+
1028
+ // Claude's nested traversal: CLAUDE.md and CLAUDE.local.md below the working
1029
+ // directory are not loaded at launch but "are included when Claude reads files in
1030
+ // those subdirectories" (memory.md). pi's loader stops at cwd, so the whole
1031
+ // below-cwd range is missing; this attaches each one to the tool result that
1032
+ // touched its directory, which is the same seam claude-rules uses for a scoped
1033
+ // rule. Once per file per session, ordered shallowest first so the deepest
1034
+ // instructions are read last, matching the launch-time ordering.
1035
+ pi.on('tool_result', async (event, ctx) => {
1036
+ const rel = fileToolTarget(event)
1037
+ if (rel === undefined) return
1038
+ // Repo-controlled text, gated like every other project file this extension adds.
1039
+ if (!projectApproved) return
1040
+ // Realpath both sides: the walk below is lexical, so a symlinked subdirectory
1041
+ // would otherwise carry it straight out of the project.
1042
+ const [realCwd = ctx.cwd] = realRoots([ctx.cwd])
1043
+ const touched = path.resolve(ctx.cwd, rel)
1044
+ // The directory, not the file: a write creates its target, and a read of a path
1045
+ // that has since moved should still contribute what its directory holds.
1046
+ const [touchedDir] = realRoots([path.dirname(touched)])
1047
+ if (touchedDir === undefined) return
1048
+
1049
+ const home = os.homedir()
1050
+ const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
1051
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
1052
+ const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
1053
+ const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
1054
+ for (const loaded of events) announce(loaded)
1055
+ if (bodies.length === 0) return
1056
+ // Capped like every other tool output: one read in a deep subtree must not be
1057
+ // able to spend the context window on memory files.
1058
+ return { content: [...event.content, { type: 'text' as const, text: capForContext(bodies.join('\n\n')) }] }
1059
+ })
832
1060
  }
@@ -36,15 +36,11 @@ import * as fs from 'node:fs'
36
36
  import * as os from 'node:os'
37
37
  import * as path from 'node:path'
38
38
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
39
-
40
39
  import { claudeConfigDir } from './internal/config-dir.js'
41
40
  import { readManagedSettings } from './internal/managed-settings.js'
42
41
  import { isProjectApprovedSilently } from './internal/project-approval.js'
43
42
  import { claudeSettingsChain } from './internal/settings-chain.js'
44
-
45
- function isRecord(value: unknown): value is Record<string, unknown> {
46
- return typeof value === 'object' && value !== null && !Array.isArray(value)
47
- }
43
+ import { isRecord } from './internal/values.js'
48
44
 
49
45
  /** The `env` object of one settings scope, coerced to string values. A string is kept
50
46
  * as-is, a number or boolean becomes its String() form, and anything else (object,
@@ -19,6 +19,7 @@ import * as os from 'node:os'
19
19
  import * as path from 'node:path'
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
21
  import { claudeConfigDir } from './internal/config-dir.js'
22
+ import { contentText, errorMessage } from './internal/values.js'
22
23
 
23
24
  const CUSTOM_TYPE = 'git-checkpoint'
24
25
  /** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
@@ -116,17 +117,8 @@ function rememberWorkTree(shadowDir: string, cwd: string): void {
116
117
  }
117
118
  }
118
119
 
119
- function extractText(content: unknown): string {
120
- if (typeof content === 'string') return content
121
- if (!Array.isArray(content)) return ''
122
- return content
123
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
124
- .map((part) => part.text)
125
- .join(' ')
126
- }
127
-
128
120
  function promptSnippet(content: unknown): string {
129
- const text = extractText(content).replace(/\s+/g, ' ').trim()
121
+ const text = contentText(content, ' ').replace(/\s+/g, ' ').trim()
130
122
  if (text.length <= PROMPT_SNIPPET_LENGTH) return text
131
123
  return `${text.slice(0, PROMPT_SNIPPET_LENGTH)}…`
132
124
  }
@@ -156,7 +148,7 @@ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string
156
148
  if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
157
149
  return true
158
150
  } catch (error) {
159
- const message = error instanceof Error ? error.message : String(error)
151
+ const message = errorMessage(error)
160
152
  ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
161
153
  return false
162
154
  }
@@ -237,7 +229,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
237
229
  } catch (error) {
238
230
  // Without the mirror, files the user excluded locally are snapshotted into the
239
231
  // checkpoint store and restored by /rewind, so this is not a silent fallback.
240
- ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${error instanceof Error ? error.message : String(error)}`, 'warning')
232
+ ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${errorMessage(error)}`, 'warning')
241
233
  }
242
234
  }
243
235
 
@@ -37,7 +37,6 @@
37
37
 
38
38
  import * as os from 'node:os'
39
39
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
40
-
41
40
  import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
42
41
  import {
43
42
  checkinIntervalMs,
@@ -66,6 +65,7 @@ import { completeText } from './internal/model-complete.js'
66
65
  import { resolveModelOverride } from './internal/model-lookup.js'
67
66
  import { isProjectApprovedSilently } from './internal/project-approval.js'
68
67
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
68
+ import { errorMessage } from './internal/values.js'
69
69
 
70
70
  /** Session entry type the goal state persists under, and the custom message type its
71
71
  * transcript lines (kickoff, verdicts, check-ins) carry. */
@@ -340,7 +340,7 @@ export default function goalExtension(pi: ExtensionAPI) {
340
340
  // A user interrupt is not an evaluator failure: the goal stays, nothing to say.
341
341
  if (ctx.signal?.aborted) return
342
342
  // No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
343
- if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${error instanceof Error ? error.message : String(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
343
+ if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${errorMessage(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
344
344
  return
345
345
  }
346
346
  // Interrupted, cleared, or replaced during the await: this verdict must not act.
@@ -8,7 +8,8 @@ import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
9
  import { readManagedSettings } from '../internal/managed-settings.js'
10
10
  import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
11
- import { claudeSettingsChain } from '../internal/settings-chain.js'
11
+ import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
12
+ import { errorMessage, isRecord } from '../internal/values.js'
12
13
 
13
14
  export interface HookCommand {
14
15
  type?: string
@@ -67,10 +68,6 @@ export function isBackgroundHook(hook: HookCommand): boolean {
67
68
  }
68
69
  export type HooksConfig = Record<string, HookMatcher[]>
69
70
 
70
- export function isRecord(value: unknown): value is Record<string, unknown> {
71
- return typeof value === 'object' && value !== null && !Array.isArray(value)
72
- }
73
-
74
71
  /** Settings files to read, newest-winning. Project files load only when trusted, each
75
72
  * the nearest of its name at or above cwd (bounded at the repository root, matching
76
73
  * the approval walk), so a subdirectory session reads the settings that gated it. */
@@ -92,13 +89,8 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
92
89
  * disableAllHooks cannot disable hooks configured through managed policy settings,
93
90
  * so the caller keeps managed hooks running when only this half is set. */
94
91
  export function readSettingsDisableAllHooks(files: string[]): boolean {
95
- for (const file of files) {
96
- try {
97
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
98
- if (isRecord(parsed) && parsed.disableAllHooks === true) return true
99
- } catch {
100
- // missing or invalid file: skip
101
- }
92
+ for (const settings of readSettingsChain(files)) {
93
+ if (settings.disableAllHooks === true) return true
102
94
  }
103
95
  return false
104
96
  }
@@ -152,14 +144,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
152
144
  found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
153
145
  }
154
146
  collect(managed.allowedHttpHookUrls)
155
- for (const file of files) {
156
- try {
157
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
158
- if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
159
- } catch {
160
- // missing or invalid file: skip
161
- }
162
- }
147
+ for (const settings of readSettingsChain(files)) collect(settings.allowedHttpHookUrls)
163
148
  return found
164
149
  }
165
150
 
@@ -203,7 +188,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
203
188
  } catch (error) {
204
189
  // Every hook this source declares is now absent, a policy hook among them, so the
205
190
  // failure is named rather than left to look like a file with no hooks in it.
206
- console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${error instanceof Error ? error.message : String(error)}`)
191
+ console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${errorMessage(error)}`)
207
192
  return
208
193
  }
209
194
  for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
@@ -6,8 +6,9 @@
6
6
 
7
7
  import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
8
8
  import type { PathAnchors } from '../internal/path-rules.js'
9
+ import { errorMessage, isRecord } from '../internal/values.js'
9
10
  import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
10
- import { type HookCommand, type HooksConfig, isRecord } from './config.js'
11
+ import type { HookCommand, HooksConfig } from './config.js'
11
12
  import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
12
13
  import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
13
14
 
@@ -63,7 +64,7 @@ export function hookJsonError(text: string): string | undefined {
63
64
  JSON.parse(trimmed)
64
65
  return undefined
65
66
  } catch (error) {
66
- parseError = error instanceof Error ? error.message : String(error)
67
+ parseError = errorMessage(error)
67
68
  }
68
69
  const lines = trimmed
69
70
  .split('\n')