pi-code 1.0.57 → 1.0.59

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.
@@ -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))
@@ -67,18 +67,20 @@ import { createHash } from 'node:crypto'
67
67
  import * as fs from 'node:fs'
68
68
  import * as os from 'node:os'
69
69
  import * as path from 'node:path'
70
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
71
-
70
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
72
71
  import { claudeConfigDir } from './internal/config-dir.js'
72
+ import { AGENTS_FILE_NAMES } from './internal/context-files.js'
73
+ import { externalImportDecision, externalImportKey, rememberExternalImportDecision } from './internal/external-imports.js'
73
74
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
74
75
  import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
75
- import { sliceBytes } from './internal/output-guard.js'
76
+ import { capForContext, sliceBytes } from './internal/output-guard.js'
76
77
  import { globToRegExpSource } from './internal/path-rules.js'
77
- import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
78
+ import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
78
79
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
79
80
  import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
80
81
  import { statToken } from './internal/stat-token.js'
81
82
  import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
83
+ import { fileToolTarget } from './internal/tool-target.js'
82
84
 
83
85
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
84
86
  const MAX_IMPORT_DEPTH = 4
@@ -106,8 +108,13 @@ export function expandHome(target: string, home: string): string {
106
108
  return target
107
109
  }
108
110
 
111
+ /** Stands for "no boundary" in an allowed-roots list: the grant a project gets when
112
+ * its external imports are approved, where a file may import from anywhere as Claude's
113
+ * dialog allows. A real path never equals it, and realRoots never produces it. */
114
+ const ANY_ROOT = '*'
115
+
109
116
  function isUnder(target: string, roots: string[]): boolean {
110
- return roots.some((root) => target === root || target.startsWith(root + path.sep))
117
+ return roots.some((root) => root === ANY_ROOT || target === root || target.startsWith(root + path.sep))
111
118
  }
112
119
 
113
120
  /** Realpath the roots that exist; used both to seed and to bound the import search. */
@@ -138,9 +145,12 @@ export interface ImportBudget {
138
145
  files: number
139
146
  bytes: number
140
147
  dropped: number
148
+ /** Existing files an @import named that resolve outside the importer's allowed
149
+ * roots. Collected so the refusal can be reported rather than left silent. */
150
+ refused: Set<string>
141
151
  }
142
152
 
143
- export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
153
+ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
144
154
 
145
155
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
146
156
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
@@ -162,16 +172,28 @@ function importTargets(content: string): string[] {
162
172
  }
163
173
 
164
174
  /** 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 {
175
+ 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
176
  const resolved = path.resolve(fromDir, expandHome(target, home))
177
+ // Always the path the importing file named, never where a symlink pointed: the
178
+ // notice would otherwise hand a repo the real name of whatever the link reaches,
179
+ // which is the disclosure the refusal exists to prevent. An excluded file is not
180
+ // named either, since exclusion removes it from every other surface too.
181
+ const refuse = (): null => {
182
+ if (isExcluded?.(resolved) !== true) refused.add(resolved)
183
+ return null
184
+ }
167
185
  let real: string
168
186
  try {
169
187
  real = fs.realpathSync(resolved)
170
188
  } catch {
171
- return null
189
+ // Not on disk. Still refused when it points outside, so that whether a path is
190
+ // reported never depends on whether it exists: a notice that named only the
191
+ // existing ones would enumerate the filesystem for any repo-controlled file
192
+ // willing to write one @line per guess.
193
+ return isUnder(resolved, allowedRoots) ? null : refuse()
172
194
  }
173
195
  if (seen.has(real)) return null
174
- if (!isUnder(real, allowedRoots)) return null
196
+ if (!isUnder(real, allowedRoots)) return refuse()
175
197
  // Checked before the read so an excluded file contributes nothing: no body, no
176
198
  // transitive imports, no budget spend, no announce. A post-collection filter
177
199
  // would drop the file itself but keep its children.
@@ -182,6 +204,9 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
182
204
  // Only a consumed file dedupes: marking a blocked or unreadable target seen
183
205
  // would let one reader's failure suppress the import for a later, allowed one.
184
206
  seen.add(real)
207
+ // A file another importer already refused is in context after all; the notice
208
+ // must not claim otherwise.
209
+ refused.delete(resolved)
185
210
  return { real, body }
186
211
  } catch {
187
212
  return null
@@ -218,7 +243,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
218
243
  scan.budget.dropped += 1
219
244
  continue
220
245
  }
221
- const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
246
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
222
247
  if (!file) continue
223
248
  scan.budget.files -= 1
224
249
  // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
@@ -252,11 +277,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
252
277
  * project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
253
278
  * would let it read them into the system prompt.
254
279
  */
255
- export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
280
+ export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
256
281
  const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
257
282
  const [real] = realRoots([importer])
258
283
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
259
284
  if (fromUserConfig) return realRoots([cwd, ...userRoots])
285
+ // The project was asked about its external imports and allowed them, so a project
286
+ // file may reach outside, as Claude's dialog grants. The widening is deliberately
287
+ // only for project files: a user-scope file's roots are its own config, and an
288
+ // approval given to a repository must not extend them.
289
+ if (externalApproved) return [ANY_ROOT]
260
290
  // A non-config file is bounded at the repository root: that covers an ancestor
261
291
  // context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
262
292
  // session, where cwd alone silently dropped its relative imports) without
@@ -479,24 +509,205 @@ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded:
479
509
  return extras
480
510
  }
481
511
 
512
+ /** The parts of one launch-time expansion that every file shares: where it runs, what
513
+ * has already been read, the budget they all draw on, and whether this project's
514
+ * external imports were approved. */
515
+ interface ExpansionContext {
516
+ home: string
517
+ cwd: string
518
+ seen: Set<string>
519
+ excluded: (absPath: string) => boolean
520
+ budget: ImportBudget
521
+ externalApproved: boolean
522
+ }
523
+
482
524
  /** Resolve every context and additional-dir file's @imports through the one shared
483
525
  * budget, each with roots scoped to the importing file so a project file never
484
- * reaches user config. */
485
- 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[] {
526
+ * reaches user config unless the project's external imports were approved. */
527
+ function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, run: ExpansionContext): ImportedFile[] {
486
528
  const imported: ImportedFile[] = []
529
+ const options = { budget: run.budget, isExcluded: run.excluded }
487
530
  for (const file of contextFiles) {
488
- const allowedRoots = rootsForImporter(file.path, home, cwd)
489
- imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
531
+ const allowedRoots = rootsForImporter(file.path, run.home, run.cwd, run.externalApproved)
532
+ imported.push(...collectImports(file.content, path.dirname(file.path), run.home, allowedRoots, run.seen, { ...options, importer: file.path }))
490
533
  }
491
534
  for (const extra of extras) {
492
535
  // The additional dir itself is an allowed root, so its files' relative imports
493
536
  // resolve even from .claude/rules two levels down.
494
- const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
495
- imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
537
+ const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, run.home, run.cwd)]
538
+ imported.push(...collectImports(extra.content, path.dirname(extra.path), run.home, allowedRoots, run.seen, { ...options, importer: extra.path }))
496
539
  }
497
540
  return imported
498
541
  }
499
542
 
543
+ /** The external-import dialog's title. Exported so a test can tell it apart from the
544
+ * project-trust dialog by identity rather than by matching a prefix that a retitle
545
+ * would silently break. */
546
+ export const EXTERNAL_IMPORT_PROMPT_TITLE = 'Load imports from outside this project?'
547
+
548
+ /** Ask about the imports the expansion just refused for leaving the project.
549
+ *
550
+ * The list is the refusals the enforcing path produced, not a second enumeration of
551
+ * what it might refuse: same files, same depth, same resolution, same exclusions. That
552
+ * is the only way the dialog can promise it names everything the approval lets in.
553
+ */
554
+ async function askExternalImports(ctx: ExtensionContext, root: string, refused: ReadonlySet<string>): Promise<boolean> {
555
+ const listed = [...refused]
556
+ .sort((a, b) => a.localeCompare(b, 'en'))
557
+ .map((file) => ` ${file}`)
558
+ .join('\n')
559
+ const body = `${root}\n\nIts context files import these files from outside the project:\n\n${listed}\n\nThey will be read into every session's context. Only allow this for repositories you trust.`
560
+ return await ctx.ui.confirm(EXTERNAL_IMPORT_PROMPT_TITLE, body)
561
+ }
562
+
563
+ /** Every context file that could load on demand for a touched directory, shallowest
564
+ * first, so the deepest instructions are read last as they are at launch. */
565
+ function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
566
+ for (const dir of nestedContextDirs(touchedDir, realCwd)) {
567
+ for (const name of NESTED_CONTEXT_NAMES) yield { file: path.join(dir, name), dir, name }
568
+ }
569
+ }
570
+
571
+ /** Everything the on-demand load needs from the session and the tool call, grouped so
572
+ * the per-file worker stays a three-argument function. */
573
+ interface NestedLoadContext {
574
+ home: string
575
+ cwd: string
576
+ realCwd: string
577
+ projectRoot: string
578
+ excludeGlobs: string[]
579
+ /** The file whose access triggered the load, for trigger_file_path. */
580
+ touched: string
581
+ /** Paths already in the system prompt, so a nested @import does not repeat one. */
582
+ launchLoaded: string[]
583
+ }
584
+
585
+ /** One nested context file's block and the instruction loads it should announce, or
586
+ * nothing to attach: absent, excluded, empty, or reaching outside the project. `read`
587
+ * reports whether the file was there at all, so a file that exists is only ever
588
+ * attached once while a missing one can still appear later in the session. */
589
+ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext): { read: boolean; text?: string; events?: InstructionLoadEvent[] } {
590
+ if (isExcludedPath(file, load.excludeGlobs, load.home)) return { read: false }
591
+ // The file itself may be a link out of the project, whatever its directory is.
592
+ const [real] = realRoots([file])
593
+ if (real === undefined || !isUnder(real, [load.realCwd])) return { read: false }
594
+ const content = readContextFile(real)
595
+ if (content === undefined) return { read: false }
596
+ const body = stripBlockComments(content).trim()
597
+ if (body.length === 0) return { read: true }
598
+ // Its own @imports resolve at project roots, on a budget of their own: this load is
599
+ // outside the launch-time expansion the shared budget covers. The launch-time paths
600
+ // seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
601
+ // a body already in the system prompt.
602
+ const seen = new Set([...load.launchLoaded, real])
603
+ const budget = createImportBudget()
604
+ const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
605
+ importer: real,
606
+ isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
607
+ budget,
608
+ })
609
+ return {
610
+ read: true,
611
+ // The refusal notice rides along, so an import this file names and does not get is
612
+ // as visible here as it is at launch.
613
+ text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n') + refusedImportsAddition(budget.refused),
614
+ events: [
615
+ { file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
616
+ ...imports.map((entry) => ({
617
+ file_path: entry.path,
618
+ memory_type: memoryTypeForPath(entry.path, load.home, load.projectRoot),
619
+ load_reason: 'include',
620
+ parent_file_path: file,
621
+ })),
622
+ ],
623
+ }
624
+ }
625
+
626
+ /** The context files Claude loads on demand rather than at launch, in the order it
627
+ * reads them within a directory. */
628
+ const NESTED_CONTEXT_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
629
+
630
+ /** The directories between a touched file and cwd, nearest cwd first.
631
+ *
632
+ * Claude loads CLAUDE.md from cwd and every directory above it at launch, and the
633
+ * ones below "are included when Claude reads files in those directories"
634
+ * (memory.md). Only the strictly-below range belongs here; cwd's own file is
635
+ * already in the prompt. A file outside cwd contributes nothing.
636
+ */
637
+ function nestedContextDirs(from: string, cwd: string): string[] {
638
+ const dirs: string[] = []
639
+ let current = from
640
+ while (current !== cwd && current !== path.dirname(current)) {
641
+ dirs.unshift(current)
642
+ current = path.dirname(current)
643
+ }
644
+ // The walk reached the filesystem root without meeting cwd, so the file is outside
645
+ // it: nothing below cwd to load. A file in cwd itself ends the loop with no dirs.
646
+ return current === cwd ? dirs : []
647
+ }
648
+
649
+ /** The blocks and instruction loads for one touched directory. `loaded` is the
650
+ * session's set of already-attached files and is updated in place, so a file that
651
+ * exists is attached once and a missing one can still appear later. */
652
+ function nestedContextAttachments(touchedDir: string, load: NestedLoadContext, loaded: Set<string>, localsApproved: boolean): { bodies: string[]; events: InstructionLoadEvent[] } {
653
+ const bodies: string[] = []
654
+ const events: InstructionLoadEvent[] = []
655
+ for (const { file, dir, name } of nestedCandidates(touchedDir, load.realCwd)) {
656
+ if (loaded.has(file)) continue
657
+ // A CLAUDE.local.md needs a decision, never the "nothing here to gate" shortcut:
658
+ // the approval walk only looks at or above cwd, so this is the one door it cannot
659
+ // see (see isGatedFileApproved).
660
+ if (name === 'CLAUDE.local.md' && !localsApproved) continue
661
+ const block = nestedContextBlock(file, dir, load)
662
+ if (block.read) loaded.add(file)
663
+ if (block.text === undefined) continue
664
+ bodies.push(block.text)
665
+ events.push(...(block.events ?? []))
666
+ }
667
+ return { bodies, events }
668
+ }
669
+
670
+ /** The CLAUDE.md files pi passed over.
671
+ *
672
+ * Claude Code reads CLAUDE.md and never AGENTS.md, and its documented recipe for a
673
+ * repository that already has an AGENTS.md is a CLAUDE.md that imports it and adds
674
+ * Claude-specific instructions below (memory.md, "AGENTS.md"). pi loads one context
675
+ * file per directory and prefers AGENTS.md, so on exactly that layout the
676
+ * Claude-specific half never reaches the prompt. Each one found here is loaded like
677
+ * any other project file: exclude-checked, comment-stripped, imports expanded.
678
+ *
679
+ * The `@AGENTS.md` the recipe opens with costs nothing: pi's own file paths already
680
+ * seed the import seen-set, so the body it names is not injected a second time. */
681
+ function siblingClaudeMdFiles(native: Array<{ path: string; content: string }>): Array<{ path: string; content: string }> {
682
+ const loaded = new Set(realRoots(native.map((file) => file.path)))
683
+ const found: Array<{ path: string; content: string }> = []
684
+ for (const file of native) {
685
+ if (!AGENTS_FILE_NAMES.has(path.basename(file.path))) continue
686
+ // CLAUDE.md only: pi also answers to CLAUDE.MD, but Claude Code reads the one
687
+ // spelling, and on a case-insensitive filesystem looking for both finds the same
688
+ // file twice under two names.
689
+ const candidate = path.join(path.dirname(file.path), 'CLAUDE.md')
690
+ const [real] = realRoots([candidate])
691
+ if (real !== undefined && loaded.has(real)) continue
692
+ const content = readContextFile(candidate)
693
+ if (content === undefined) continue
694
+ found.push({ path: candidate, content })
695
+ }
696
+ return found
697
+ }
698
+
699
+ /** The CLAUDE.md files pi passed over, appended as project_instructions blocks in the
700
+ * ./CLAUDE.md slot, ahead of the ./.claude/CLAUDE.md alternate and the locals. */
701
+ function siblingClaudeMdAddition(siblings: Array<{ path: string; content: string }>, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
702
+ let addition = ''
703
+ for (const sibling of siblings) {
704
+ if (sibling.content.trim().length === 0) continue
705
+ announce({ file_path: sibling.path, memory_type: memoryTypeForPath(sibling.path, home, projectRoot), load_reason: 'session_start' })
706
+ addition += `\n\n${instructionsBlock(sibling.path, sibling.content.trim())}`
707
+ }
708
+ return addition
709
+ }
710
+
500
711
  /** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
501
712
  * announced as it is added (only when non-empty, matching what reaches the prompt).
502
713
  * Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
@@ -535,6 +746,19 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
535
746
  /** The `## Imported context (@)` section for every resolved @import, with the
536
747
  * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
537
748
  * was imported. */
749
+ /** The refusal notice: every existing file an @import named that its importer may not
750
+ * reach. Claude asks about these through an approval dialog and loads the ones you
751
+ * allow; pi-code refuses them, and this is what says so. Silence was the real defect:
752
+ * the importing file looks loaded and its instructions are simply not there. */
753
+ function refusedImportsAddition(refused: Set<string>): string {
754
+ if (refused.size === 0) return ''
755
+ const list = [...refused]
756
+ .sort((a, b) => a.localeCompare(b, 'en'))
757
+ .map((file) => `- ${file}`)
758
+ .join('\n')
759
+ 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}`
760
+ }
761
+
538
762
  function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
539
763
  if (imported.length === 0) return ''
540
764
  const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
@@ -584,6 +808,7 @@ function buildImportMemoKey(input: {
584
808
  cwd: string
585
809
  home: string
586
810
  projectApproved: boolean
811
+ externalApproved: boolean
587
812
  addDirsRaw: string
588
813
  excludeGlobs: string[]
589
814
  native: Array<{ path: string; content: string }>
@@ -594,7 +819,7 @@ function buildImportMemoKey(input: {
594
819
  contextFiles: Array<{ path: string; content: string }>
595
820
  }): string {
596
821
  const keyHash = createHash('sha256')
597
- keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
822
+ keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.externalApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
598
823
  for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
599
824
  if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
600
825
  if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
@@ -613,9 +838,19 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
613
838
  // natively but not this one; repo-controlled, so it is approval-gated like the
614
839
  // locals and read once at session start.
615
840
  let projectDotClaude: { path: string; content: string } | undefined
841
+ // Whether a file that is itself the thing to gate may be read. Decided at session
842
+ // start like the flag below, from the session context, which is where the trust
843
+ // capability lives; a tool_result context carries no approval state.
844
+ let gatedFilesApproved = false
616
845
  // Whether project settings may contribute claudeMdExcludes; decided at session
617
846
  // start with the silent check, so no prompt fires mid-flight.
618
847
  let projectApproved = false
848
+ // Every context file the last turn put in the system prompt, so a nested file's
849
+ // @import cannot pay for a body that is already there.
850
+ let launchLoadedPaths: string[] = []
851
+ // Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
852
+ // read in the same subtree does not repeat them.
853
+ const nestedLoaded = new Set<string>()
619
854
  // Instruction loads already announced on the shared bus, keyed reason:path.
620
855
  // before_agent_start fires every turn, so without this a configured
621
856
  // InstructionsLoaded hook would fire once per file per turn.
@@ -658,11 +893,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
658
893
  // set) is rebuilt for next turn.
659
894
  const resolveImports = (
660
895
  memoKey: string,
661
- native: Array<{ path: string; content: string }>,
662
- contextFiles: Array<{ path: string; content: string }>,
663
- home: string,
664
- cwd: string,
665
- excluded: (absPath: string) => boolean,
896
+ files: { native: Array<{ path: string; content: string }>; context: Array<{ path: string; content: string }>; siblings: Array<{ path: string; content: string }> },
897
+ run: { home: string; cwd: string; excluded: (absPath: string) => boolean; externalApproved: boolean },
666
898
  ): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
667
899
  if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
668
900
  const { extras, budget, imported } = importMemo
@@ -672,7 +904,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
672
904
  // files are never re-imported and an excluded file cannot return as an import.
673
905
  // The user, project-.claude and managed-file additions join the seed too, so a
674
906
  // context file's @import cannot pull any of them in a second time.
675
- const ownPaths = [...native, ...localContexts].map((file) => file.path)
907
+ const ownPaths = [...files.native, ...localContexts, ...files.siblings].map((file) => file.path)
676
908
  if (userContext !== undefined) ownPaths.push(userContext.path)
677
909
  if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
678
910
  ownPaths.push(managedClaudeMdPath())
@@ -681,14 +913,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
681
913
  // Claude's --add-dir memory loading, env-gated. The files join the seen set
682
914
  // before import expansion so an @import cannot pull one in twice, and they get
683
915
  // the same exclude and comment-strip treatment as native context files.
684
- const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
685
- const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
916
+ const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), run.home, run.cwd) : []
917
+ const extras = additionalDirExtras(addDirs, seenSet, run.excluded, projectApproved)
686
918
 
687
919
  // One budget for the whole run, so N context files cannot each spend a full one.
688
920
  // Exclusion applies inside the recursion: an excluded @import is skipped before
689
921
  // it is read, so its transitive imports never load and it spends no budget.
690
922
  const budget = createImportBudget()
691
- const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
923
+ const imported = expandImports(files.context, extras, { ...run, seen: seenSet, budget })
692
924
 
693
925
  // Revalidation set: every file the expansion read, plus each add-dir itself
694
926
  // (a directory's mtime moves when a memory file is added or removed there).
@@ -749,9 +981,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
749
981
  }
750
982
  // Read after the local-context flow so an approval it just recorded is honored.
751
983
  projectApproved = isProjectApprovedSilently(ctx)
984
+ gatedFilesApproved = isGatedFileApproved(ctx)
752
985
  })
753
986
 
754
- pi.on('before_agent_start', async (event) => {
987
+ /** The launch-time expansion, asking about what it refuses for leaving the project
988
+ * when this project has not been asked yet.
989
+ *
990
+ * Claude asks once per project and remembers the answer either way. The list is what
991
+ * this very expansion refused, so it names exactly the files the answer governs, and
992
+ * on approval the expansion is simply redone with the wider roots. */
993
+ const expandAskingAboutExternals = async (
994
+ cwd: string,
995
+ ctx: ExtensionContext | undefined,
996
+ expandWith: (externalApproved: boolean) => { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] },
997
+ ): Promise<{ extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] }> => {
998
+ const key = externalImportKey(cwd)
999
+ const decided = externalImportDecision(key)
1000
+ const result = expandWith(decided === true)
1001
+ if (decided !== null || result.budget.refused.size === 0 || ctx?.hasUI !== true) return result
1002
+ const approved = await askExternalImports(ctx, key, result.budget.refused)
1003
+ rememberExternalImportDecision(key, approved)
1004
+ return approved ? expandWith(true) : result
1005
+ }
1006
+
1007
+ pi.on('before_agent_start', async (event, ctx) => {
755
1008
  const home = os.homedir()
756
1009
  const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
757
1010
  const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
@@ -792,33 +1045,82 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
792
1045
 
793
1046
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
794
1047
 
1048
+ // Repo-controlled text, so gated on the same approval as the locals and the
1049
+ // ./.claude/CLAUDE.md; read here rather than at session start because only the
1050
+ // turn's contextFiles say which file pi actually chose per directory.
1051
+ const keptSiblings = (projectApproved ? siblingClaudeMdFiles(native) : []).filter((sibling) => !excluded(sibling.path)).map((sibling) => ({ path: sibling.path, content: stripBlockComments(sibling.content) }))
1052
+
795
1053
  // ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
796
1054
  // loads it too there is no double block, then exclude-checked and comment-stripped
797
1055
  // like the rest. Its @imports resolve at project roots (rootsForImporter).
798
1056
  const nativeReal = new Set(realRoots(native.map((file) => file.path)))
799
1057
  const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
800
- const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
1058
+ const siblingReal = new Set(realRoots(keptSiblings.map((sibling) => sibling.path)))
1059
+ const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && (nativeReal.has(dotReal) || siblingReal.has(dotReal))) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
801
1060
 
802
1061
  // The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
803
1062
  // @imports resolve (each at roots scoped to it, via rootsForImporter); their own
804
1063
  // bodies are placed separately, so expansion only surfaces what they import.
805
- const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
1064
+ const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...keptSiblings, ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
806
1065
 
807
1066
  // Everything the expansion depends on, hashed: a turn whose inputs match the memo
808
1067
  // and whose recorded mtimes are unchanged reuses the previous expansion outright.
809
1068
  const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
810
- const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
1069
+ const expandWith = (externalApproved: boolean) => {
1070
+ const memoKey = buildImportMemoKey({ cwd, home, projectApproved, externalApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
1071
+ return resolveImports(memoKey, { native, context: contextFiles, siblings: keptSiblings }, { home, cwd, excluded, externalApproved })
1072
+ }
811
1073
 
812
- const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
1074
+ // Claude's external-import dialog. Asked from the refusals the expansion just
1075
+ // produced, so the files named are exactly the files the answer governs, and asked
1076
+ // here rather than at session start because only this event knows which context
1077
+ // files pi actually loaded. Once per project: the answer is remembered either way.
1078
+ const { extras, budget, imported } = await expandAskingAboutExternals(cwd, ctx, expandWith)
1079
+ launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
813
1080
 
814
1081
  // Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
815
1082
  // additions, ahead of the CLAUDE.local.md bodies.
816
- let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
1083
+ let addition = siblingClaudeMdAddition(keptSiblings, home, projectRoot, announce)
1084
+ addition += projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
817
1085
  addition += localContextAddition(keptLocals, announce)
818
1086
  addition += additionalDirsAddition(extras, announce)
819
1087
  addition += importedAddition(imported, budget, home, projectRoot, announce)
1088
+ addition += refusedImportsAddition(budget.refused)
820
1089
  if (!changed && addition.length === 0) return
821
1090
 
822
1091
  return { systemPrompt: prompt + addition }
823
1092
  })
1093
+
1094
+ // Claude's nested traversal: CLAUDE.md and CLAUDE.local.md below the working
1095
+ // directory are not loaded at launch but "are included when Claude reads files in
1096
+ // those subdirectories" (memory.md). pi's loader stops at cwd, so the whole
1097
+ // below-cwd range is missing; this attaches each one to the tool result that
1098
+ // touched its directory, which is the same seam claude-rules uses for a scoped
1099
+ // rule. Once per file per session, ordered shallowest first so the deepest
1100
+ // instructions are read last, matching the launch-time ordering.
1101
+ pi.on('tool_result', async (event, ctx) => {
1102
+ const rel = fileToolTarget(event)
1103
+ if (rel === undefined) return
1104
+ // Repo-controlled text, gated like every other project file this extension adds.
1105
+ if (!projectApproved) return
1106
+ // Realpath both sides: the walk below is lexical, so a symlinked subdirectory
1107
+ // would otherwise carry it straight out of the project.
1108
+ const [realCwd = ctx.cwd] = realRoots([ctx.cwd])
1109
+ const touched = path.resolve(ctx.cwd, rel)
1110
+ // The directory, not the file: a write creates its target, and a read of a path
1111
+ // that has since moved should still contribute what its directory holds.
1112
+ const [touchedDir] = realRoots([path.dirname(touched)])
1113
+ if (touchedDir === undefined) return
1114
+
1115
+ const home = os.homedir()
1116
+ const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
1117
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
1118
+ const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
1119
+ const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
1120
+ for (const loaded of events) announce(loaded)
1121
+ if (bodies.length === 0) return
1122
+ // Capped like every other tool output: one read in a deep subtree must not be
1123
+ // able to spend the context window on memory files.
1124
+ return { content: [...event.content, { type: 'text' as const, text: capForContext(bodies.join('\n\n')) }] }
1125
+ })
824
1126
  }
@@ -16,13 +16,15 @@
16
16
 
17
17
  import * as fs from 'node:fs'
18
18
  import * as path from 'node:path'
19
+
20
+ import { CONTEXT_FILE_CANDIDATES } from './internal/context-files.js'
21
+
22
+ export { CONTEXT_FILE_CANDIDATES }
23
+
19
24
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
20
25
 
21
26
  import { repoRoot } from './internal/project-root.js'
22
27
 
23
- /** Context files pi recognizes, in its lookup order; the first hit wins. */
24
- export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
25
-
26
28
  function statOf(target: string): fs.Stats | undefined {
27
29
  try {
28
30
  return fs.statSync(target)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The context-file names pi looks for, in its own lookup order.
3
+ *
4
+ * Mirrors pi's resource loader: it takes the first of these that exists in a
5
+ * directory and ignores the rest, so a repository holding both AGENTS.md and
6
+ * CLAUDE.md loads only the first. Anything reasoning about what pi loaded, or about
7
+ * what it passed over, has to use the same list in the same order.
8
+ */
9
+
10
+ /** pi's per-directory candidates, first hit wins. */
11
+ export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
12
+
13
+ /** The candidates pi prefers over CLAUDE.md in the same directory. */
14
+ export const AGENTS_FILE_NAMES: ReadonlySet<string> = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Whether a project may load `@imports` that resolve outside it.
3
+ *
4
+ * Claude Code asks once per project, listing the external files, and remembers the
5
+ * answer: "The first time Claude Code encounters external imports in a project, it
6
+ * shows an approval dialog listing the files. If you decline, the imports stay
7
+ * disabled and the dialog doesn't appear again."
8
+ *
9
+ * The answer lives beside pi's own trust store rather than in the repository, so a
10
+ * clone cannot ship its own approval. It is keyed on the checkout: the resolved git
11
+ * root, or the resolved working directory outside a repository.
12
+ *
13
+ * The key is the git root and not the project root because a repository must not be
14
+ * able to move its own key. The project root also stops at package.json, so declining
15
+ * at the top of a monorepo and starting the next session inside a package produced a
16
+ * different key and asked again, which is not a decision that was kept. A separate
17
+ * worktree of one repository is a separate checkout and is asked separately.
18
+ */
19
+
20
+ import * as fs from 'node:fs'
21
+ import * as path from 'node:path'
22
+
23
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
24
+
25
+ import { atomicWriteFile } from './atomic-write.js'
26
+ import { gitRoot } from './project-root.js'
27
+ import { isRecord } from './values.js'
28
+
29
+ /** The key for a working directory: its checkout, resolved, so the same checkout
30
+ * reached through a symlink is the same project. */
31
+ export function externalImportKey(cwd: string): string {
32
+ const root = gitRoot(cwd) ?? cwd
33
+ try {
34
+ return fs.realpathSync(root)
35
+ } catch {
36
+ return root
37
+ }
38
+ }
39
+
40
+ /** The store file. A seam: the tests point it at a temp directory. */
41
+ export function externalImportStorePath(agentDir: string = getAgentDir()): string {
42
+ return path.join(agentDir, 'pi-code-external-imports.json')
43
+ }
44
+
45
+ function readStore(storePath: string): Record<string, unknown> {
46
+ try {
47
+ const parsed: unknown = JSON.parse(fs.readFileSync(storePath, 'utf-8'))
48
+ return isRecord(parsed) ? parsed : {}
49
+ } catch {
50
+ // No store yet, or one a hand-edit left unparseable: no decision recorded.
51
+ return {}
52
+ }
53
+ }
54
+
55
+ /** The recorded answer for `root`, or null when the project has never been asked.
56
+ * Only a boolean counts: anything else in the file reads as unasked, so a corrupt
57
+ * entry re-asks rather than silently allowing or silently refusing forever. */
58
+ export function externalImportDecision(root: string, storePath: string = externalImportStorePath()): boolean | null {
59
+ const value = readStore(storePath)[root]
60
+ return typeof value === 'boolean' ? value : null
61
+ }
62
+
63
+ /** Record the answer for `root`, keeping every other project's. */
64
+ export function rememberExternalImportDecision(root: string, allowed: boolean, storePath: string = externalImportStorePath()): void {
65
+ const store = readStore(storePath)
66
+ store[root] = allowed
67
+ try {
68
+ fs.mkdirSync(path.dirname(storePath), { recursive: true })
69
+ atomicWriteFile(storePath, `${JSON.stringify(store, null, 2)}\n`)
70
+ } catch {
71
+ // An unwritable agent directory costs the memory of the answer, not the session:
72
+ // the next start asks again, which is the safe direction.
73
+ }
74
+ }
@@ -131,6 +131,20 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
131
131
  return deps.savedDecision(ctx.cwd) === true
132
132
  }
133
133
 
134
+ /**
135
+ * The approval decision for a file that is itself the thing to gate.
136
+ *
137
+ * The silent check short-circuits to approved when the repository holds no
138
+ * Claude-shaped config, meaning there is nothing here pi-code would act on. That is
139
+ * wrong for a file the CLAUDE_SHAPED walk cannot see: the walk only looks at or above
140
+ * cwd, so a CLAUDE.local.md in a subdirectory would come in through the one door the
141
+ * gate does not cover. Forcing the shaped answer makes such a file need a real
142
+ * decision, never the shortcut.
143
+ */
144
+ export function isGatedFileApproved(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'> & { ui?: ApprovalContext['ui'] }, deps: ApprovalDeps = defaultDeps): boolean {
145
+ return isProjectApprovedSilently(ctx, { ...deps, hasClaudeShaped: () => true })
146
+ }
147
+
134
148
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
135
149
  if (runtimeLacksProjectTrust(ctx)) return false // pi predates project-trust support
136
150
  if (ctx.isProjectTrusted?.() !== true) return false // pi declined trust for this project
@@ -27,6 +27,24 @@ export function repoRoot(from: string): string | undefined {
27
27
  }
28
28
  }
29
29
 
30
+ /** The git checkout at or above `from`, or undefined outside one.
31
+ *
32
+ * Narrower than repoRoot on purpose, and used where a key must be stable rather than
33
+ * merely near: repoRoot also stops at package.json, which every package of a monorepo
34
+ * ships, so a decision keyed on it changes the moment the session starts one directory
35
+ * deeper. `.git` cannot be committed into a repository, so it is not a marker the
36
+ * repository can add to move its own key.
37
+ */
38
+ export function gitRoot(from: string): string | undefined {
39
+ let currentDir = from
40
+ while (true) {
41
+ if (fs.existsSync(path.join(currentDir, '.git'))) return currentDir
42
+ const parentDir = path.dirname(currentDir)
43
+ if (parentDir === currentDir) return undefined
44
+ currentDir = parentDir
45
+ }
46
+ }
47
+
30
48
  function statOf(target: string): fs.Stats | null {
31
49
  try {
32
50
  return fs.statSync(target)
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Which file a tool call touched.
3
+ *
4
+ * Two extensions attach instruction files when a file tool touches a path they cover
5
+ * (claude-rules for a path-scoped rule, context-imports for a nested CLAUDE.md), and
6
+ * both got the same detail wrong: pi's edit and write tools accept `file_path` as an
7
+ * alias for `path`, so a handler reading only `path` did nothing for a model that used
8
+ * the alias. One reader, one place to be wrong.
9
+ */
10
+
11
+ /** The tools that name a file pi-code acts on. */
12
+ const FILE_TOOLS: ReadonlySet<string> = new Set(['read', 'edit', 'write'])
13
+
14
+ /** The path a file tool call named, or undefined for any other tool, an errored call,
15
+ * or a call that named none. The path is as the tool gave it: callers resolve it
16
+ * against their own cwd. */
17
+ export function fileToolTarget(event: { toolName: string; input?: unknown; isError?: boolean }): string | undefined {
18
+ if (event.isError === true) return undefined
19
+ if (!FILE_TOOLS.has(event.toolName)) return undefined
20
+ const input = event.input as { path?: unknown; file_path?: unknown } | undefined
21
+ const target = typeof input?.path === 'string' ? input.path : input?.file_path
22
+ return typeof target === 'string' && target.length > 0 ? target : undefined
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",