pi-code 1.0.58 → 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.
@@ -67,9 +67,10 @@ 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
76
  import { capForContext, sliceBytes } from './internal/output-guard.js'
@@ -107,8 +108,13 @@ export function expandHome(target: string, home: string): string {
107
108
  return target
108
109
  }
109
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
+
110
116
  function isUnder(target: string, roots: string[]): boolean {
111
- 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))
112
118
  }
113
119
 
114
120
  /** Realpath the roots that exist; used both to seed and to bound the import search. */
@@ -271,11 +277,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
271
277
  * project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
272
278
  * would let it read them into the system prompt.
273
279
  */
274
- export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
280
+ export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
275
281
  const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
276
282
  const [real] = realRoots([importer])
277
283
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
278
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]
279
290
  // A non-config file is bounded at the repository root: that covers an ancestor
280
291
  // context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
281
292
  // session, where cwd alone silently dropped its relative imports) without
@@ -498,24 +509,57 @@ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded:
498
509
  return extras
499
510
  }
500
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
+
501
524
  /** Resolve every context and additional-dir file's @imports through the one shared
502
525
  * budget, each with roots scoped to the importing file so a project file never
503
- * reaches user config. */
504
- 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[] {
505
528
  const imported: ImportedFile[] = []
529
+ const options = { budget: run.budget, isExcluded: run.excluded }
506
530
  for (const file of contextFiles) {
507
- const allowedRoots = rootsForImporter(file.path, home, cwd)
508
- 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 }))
509
533
  }
510
534
  for (const extra of extras) {
511
535
  // The additional dir itself is an allowed root, so its files' relative imports
512
536
  // resolve even from .claude/rules two levels down.
513
- const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
514
- 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 }))
515
539
  }
516
540
  return imported
517
541
  }
518
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
+
519
563
  /** Every context file that could load on demand for a touched directory, shallowest
520
564
  * first, so the deepest instructions are read last as they are at launch. */
521
565
  function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
@@ -556,14 +600,17 @@ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext):
556
600
  // seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
557
601
  // a body already in the system prompt.
558
602
  const seen = new Set([...load.launchLoaded, real])
603
+ const budget = createImportBudget()
559
604
  const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
560
605
  importer: real,
561
606
  isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
562
- budget: createImportBudget(),
607
+ budget,
563
608
  })
564
609
  return {
565
610
  read: true,
566
- text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n'),
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),
567
614
  events: [
568
615
  { file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
569
616
  ...imports.map((entry) => ({
@@ -599,11 +646,6 @@ function nestedContextDirs(from: string, cwd: string): string[] {
599
646
  return current === cwd ? dirs : []
600
647
  }
601
648
 
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
649
  /** The blocks and instruction loads for one touched directory. `loaded` is the
608
650
  * session's set of already-attached files and is updated in place, so a file that
609
651
  * exists is attached once and a missing one can still appear later. */
@@ -766,6 +808,7 @@ function buildImportMemoKey(input: {
766
808
  cwd: string
767
809
  home: string
768
810
  projectApproved: boolean
811
+ externalApproved: boolean
769
812
  addDirsRaw: string
770
813
  excludeGlobs: string[]
771
814
  native: Array<{ path: string; content: string }>
@@ -776,7 +819,7 @@ function buildImportMemoKey(input: {
776
819
  contextFiles: Array<{ path: string; content: string }>
777
820
  }): string {
778
821
  const keyHash = createHash('sha256')
779
- 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`)
780
823
  for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
781
824
  if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
782
825
  if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
@@ -850,12 +893,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
850
893
  // set) is rebuilt for next turn.
851
894
  const resolveImports = (
852
895
  memoKey: string,
853
- native: Array<{ path: string; content: string }>,
854
- contextFiles: Array<{ path: string; content: string }>,
855
- siblings: Array<{ path: string; content: string }>,
856
- home: string,
857
- cwd: string,
858
- 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 },
859
898
  ): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
860
899
  if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
861
900
  const { extras, budget, imported } = importMemo
@@ -865,7 +904,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
865
904
  // files are never re-imported and an excluded file cannot return as an import.
866
905
  // The user, project-.claude and managed-file additions join the seed too, so a
867
906
  // context file's @import cannot pull any of them in a second time.
868
- const ownPaths = [...native, ...localContexts, ...siblings].map((file) => file.path)
907
+ const ownPaths = [...files.native, ...localContexts, ...files.siblings].map((file) => file.path)
869
908
  if (userContext !== undefined) ownPaths.push(userContext.path)
870
909
  if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
871
910
  ownPaths.push(managedClaudeMdPath())
@@ -874,14 +913,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
874
913
  // Claude's --add-dir memory loading, env-gated. The files join the seen set
875
914
  // before import expansion so an @import cannot pull one in twice, and they get
876
915
  // the same exclude and comment-strip treatment as native context files.
877
- const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
878
- 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)
879
918
 
880
919
  // One budget for the whole run, so N context files cannot each spend a full one.
881
920
  // Exclusion applies inside the recursion: an excluded @import is skipped before
882
921
  // it is read, so its transitive imports never load and it spends no budget.
883
922
  const budget = createImportBudget()
884
- const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
923
+ const imported = expandImports(files.context, extras, { ...run, seen: seenSet, budget })
885
924
 
886
925
  // Revalidation set: every file the expansion read, plus each add-dir itself
887
926
  // (a directory's mtime moves when a memory file is added or removed there).
@@ -945,7 +984,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
945
984
  gatedFilesApproved = isGatedFileApproved(ctx)
946
985
  })
947
986
 
948
- 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) => {
949
1008
  const home = os.homedir()
950
1009
  const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
951
1010
  const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
@@ -1007,9 +1066,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1007
1066
  // Everything the expansion depends on, hashed: a turn whose inputs match the memo
1008
1067
  // and whose recorded mtimes are unchanged reuses the previous expansion outright.
1009
1068
  const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
1010
- 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
+ }
1011
1073
 
1012
- const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, keptSiblings, 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)
1013
1079
  launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
1014
1080
 
1015
1081
  // Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
@@ -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
+ }
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.58",
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",