pi-code 1.0.8 → 1.0.10

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.
@@ -10,14 +10,23 @@
10
10
  * imported content plus the approval-gated CLAUDE.local.md body. The base
11
11
  * files pi already injected are never re-appended.
12
12
  *
13
+ * It also loads the Claude Code memory locations pi's own loader misses, each
14
+ * through the same exclude/strip/announce/import pipeline: the user-scope
15
+ * ~/.claude/CLAUDE.md (the user's own file, no approval gate, @imports at
16
+ * user-config roots), the project-scope alternate ./.claude/CLAUDE.md (nearest at
17
+ * or above cwd, approval-gated, deduped against pi's native blocks, @imports at
18
+ * project roots), and the enterprise managed CLAUDE.md file deployed beside
19
+ * managed-settings.json.
20
+ *
13
21
  * It also rewrites the context blocks pi assembled, by exact-substring
14
22
  * replacement of the wrapper reconstructed from each file's path+content (a
15
- * wrapper that is not found is skipped, never guessed at): a managed-settings
16
- * `claudeMd` block is prepended at the top of <project_context> (managed
17
- * settings only; the key is ignored elsewhere and the block is never
18
- * excludable), files matching the merged `claudeMdExcludes` globs are removed
19
- * along with their imports, and block-level HTML comments are stripped from
20
- * every surviving body (see internal/strip-comments).
23
+ * wrapper that is not found is skipped, never guessed at): the managed claudeMd
24
+ * (the managed CLAUDE.md file first, then the managed-settings `claudeMd` key)
25
+ * and the user CLAUDE.md are prepended at the top of <project_context> in Claude's
26
+ * order (managed, user, then pi's native project blocks; managed is managed-source
27
+ * only and never excludable), files matching the merged `claudeMdExcludes` globs
28
+ * are removed along with their imports, and block-level HTML comments are stripped
29
+ * from every surviving body (see internal/strip-comments).
21
30
  *
22
31
  * Security: context files can come from an untrusted project, so imports are
23
32
  * confined (after resolving symlinks) to the working directory plus its
@@ -43,22 +52,26 @@
43
52
  *
44
53
  * Loads are also announced on the shared instruction-events bus for the
45
54
  * InstructionsLoaded hook: `include` for each resolved @import, `session_start`
46
- * for the native context files that survived claudeMdExcludes plus
47
- * CLAUDE.local.md and additional-dir files, once per file per session. This
48
- * extension owns exclusion, so it owns the announcements too: a file the
49
- * exclusion removed never announces, and the hooks extension only consumes the
50
- * bus (emit is synchronous, so extension order does not matter).
55
+ * for the native context files that survived claudeMdExcludes plus CLAUDE.local.md,
56
+ * the user (User) and project ./.claude/CLAUDE.md (Project), the managed file
57
+ * (Managed) and additional-dir files, once per file per session. This extension
58
+ * owns exclusion, so it owns the announcements too: a file the exclusion removed
59
+ * never announces, and the hooks extension only consumes the bus (emit is
60
+ * synchronous, so extension order does not matter). The managed-settings `claudeMd`
61
+ * key is not a file pi loaded, so like today it is inserted but not announced.
51
62
  *
52
63
  * Docs: https://code.claude.com/docs/en/memory.md (imports)
53
64
  */
54
65
 
66
+ import { createHash } from 'node:crypto'
55
67
  import * as fs from 'node:fs'
56
68
  import * as os from 'node:os'
57
69
  import * as path from 'node:path'
58
70
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
59
71
 
72
+ import { claudeConfigDir } from './internal/config-dir.js'
60
73
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
61
- import { readManagedSettings } from './internal/managed-settings.js'
74
+ import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
62
75
  import { globToRegExpSource } from './internal/path-rules.js'
63
76
  import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
64
77
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
@@ -222,7 +235,7 @@ export function collectImports(content: string, fromDir: string, home: string, a
222
235
  * would let it read them into the system prompt.
223
236
  */
224
237
  export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
225
- const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
238
+ const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
226
239
  const [real] = realRoots([importer])
227
240
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
228
241
  if (fromUserConfig) return realRoots([cwd, ...userRoots])
@@ -286,9 +299,34 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
286
299
  return files
287
300
  }
288
301
 
289
- /** Path label given to the managed claudeMd block; not a file pi loaded. */
302
+ /** Path label given to the managed claudeMd settings-key block; not a file pi loaded. */
290
303
  export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
291
304
 
305
+ let managedClaudeMdPathOverride: string | undefined
306
+
307
+ /** Test seam mirroring setManagedSettingsPath: point the managed CLAUDE.md file
308
+ * readers consult at a writable directory. */
309
+ export function setManagedClaudeMdPath(file?: string): void {
310
+ managedClaudeMdPathOverride = file
311
+ }
312
+
313
+ /** The managed CLAUDE.md file path: alongside managed-settings.json, in the same OS
314
+ * directory IT deploys the enterprise policy to (managed-settings.ts owns that
315
+ * directory per platform). Organizations ship a CLAUDE.md there to load before user
316
+ * and project context. Overridable for tests. */
317
+ export function managedClaudeMdPath(): string {
318
+ return managedClaudeMdPathOverride ?? path.join(path.dirname(managedSettingsPath()), 'CLAUDE.md')
319
+ }
320
+
321
+ /** The managed CLAUDE.md file body, or '' when absent or unreadable. */
322
+ export function readManagedClaudeMdFile(): string {
323
+ try {
324
+ return fs.readFileSync(managedClaudeMdPath(), 'utf-8')
325
+ } catch {
326
+ return ''
327
+ }
328
+ }
329
+
292
330
  /** pi's exact per-file wrapper inside <project_context>, reconstructed from
293
331
  * path+content for exact-substring rewriting. tests/context-imports.test.ts pins
294
332
  * this format against pi's own source so drift fails loudly instead of silently
@@ -317,10 +355,12 @@ function replaceBlock(prompt: string, wrapper: string, replacement: string): str
317
355
  return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
318
356
  }
319
357
 
320
- /** Insert the managed claudeMd block at the top of <project_context>, before the
321
- * files pi loaded (Claude documents managed claudeMd loading before user and
322
- * project CLAUDE.md); when pi assembled no context block, add one in pi's shape. */
323
- function withManagedBlock(prompt: string, block: string): string {
358
+ /** Insert a block at the top of <project_context>, before the files pi loaded;
359
+ * when pi assembled no context block, add one in pi's shape. Used for the blocks
360
+ * Claude loads ahead of pi's native project context: managed claudeMd (file then
361
+ * key) and the user CLAUDE.md. Each call prepends, so the last block inserted ends
362
+ * up highest, which is how the managed/user/native order is built (see caller). */
363
+ function withTopBlock(prompt: string, block: string): string {
324
364
  for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
325
365
  const at = prompt.indexOf(anchor)
326
366
  if (at === -1) continue
@@ -335,7 +375,7 @@ function withManagedBlock(prompt: string, block: string): string {
335
375
  * settings.local.json (nearest at or above cwd) only when the project is
336
376
  * approved. Managed settings are read separately by the caller. */
337
377
  export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
338
- const files = [path.join(home, '.claude', 'settings.json')]
378
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
339
379
  if (!approved) return files
340
380
  for (const name of ['settings.json', 'settings.local.json']) {
341
381
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -453,6 +493,16 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
453
493
  return imported
454
494
  }
455
495
 
496
+ /** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
497
+ * announced as it is added (only when non-empty, matching what reaches the prompt).
498
+ * Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
499
+ * the former natively, so this fills the alternate location as an extra project block. */
500
+ function projectContextAddition(kept: { path: string; content: string } | undefined, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
501
+ if (kept === undefined || kept.content.trim().length === 0) return ''
502
+ announce({ file_path: kept.path, memory_type: memoryTypeForPath(kept.path, home, projectRoot), load_reason: 'session_start' })
503
+ return `\n\n${instructionsBlock(kept.path, kept.content.trim())}`
504
+ }
505
+
456
506
  /** The CLAUDE.local.md bodies appended after the native context, announced as they
457
507
  * are added (only the non-empty ones, matching what actually reaches the prompt). */
458
508
  function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
@@ -491,8 +541,74 @@ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home:
491
541
  return `\n\n## Imported context (@)\n\n${section}${notice}`
492
542
  }
493
543
 
544
+ /** Prepend the managed and user memory blocks Claude loads ahead of pi's native project
545
+ * context, top to bottom: managed file, managed key, then the user CLAUDE.md. withTopBlock
546
+ * prepends, so they are inserted bottom-up (user, then managed key, then managed file just
547
+ * below) to land in that order. keptUser was already exclude-checked and comment-stripped by
548
+ * the caller like every other file; the managed claudeMd is never excludable and comes from
549
+ * two managed-only surfaces, the settings key (ignored in user/project settings) and the file
550
+ * IT deploys beside managed-settings.json, re-read every turn so a policy change applies
551
+ * immediately (only its announce is deduped per session). Returns the grown prompt, whether
552
+ * anything was added, and the managed file body (reused for the import memo key). */
553
+ function prependMemoryBlocks(prompt: string, changed: boolean, keptUser: { path: string; content: string } | undefined, managed: Record<string, unknown>, announce: (event: InstructionLoadEvent) => void): { prompt: string; changed: boolean; managedFile: string } {
554
+ if (keptUser !== undefined && keptUser.content.trim().length > 0) {
555
+ prompt = withTopBlock(prompt, instructionsBlock(keptUser.path, keptUser.content.trim()))
556
+ changed = true
557
+ announce({ file_path: keptUser.path, memory_type: 'User', load_reason: 'session_start' })
558
+ }
559
+ const managedKey = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
560
+ if (managedKey.length > 0) {
561
+ prompt = withTopBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedKey))
562
+ changed = true
563
+ }
564
+ const managedFile = stripBlockComments(readManagedClaudeMdFile()).trim()
565
+ if (managedFile.length > 0) {
566
+ prompt = withTopBlock(prompt, instructionsBlock(managedClaudeMdPath(), managedFile))
567
+ changed = true
568
+ announce({ file_path: managedClaudeMdPath(), memory_type: 'Managed', load_reason: 'session_start' })
569
+ }
570
+ return { prompt, changed, managedFile }
571
+ }
572
+
573
+ /** Everything the import expansion depends on, hashed to a memo key: a turn whose inputs
574
+ * match a prior key and whose recorded mtimes are unchanged reuses the previous expansion
575
+ * outright. The native/local paths, the user/project-.claude additions, and the managed file
576
+ * body seed the "seen" set, so a change in which of them exist (even an excluded one that
577
+ * never reaches contextFiles) changes the key; the managed file is re-read every turn, so a
578
+ * change in its content re-expands and keeps the seed self-consistent. */
579
+ function buildImportMemoKey(input: {
580
+ cwd: string
581
+ home: string
582
+ projectApproved: boolean
583
+ addDirsRaw: string
584
+ excludeGlobs: string[]
585
+ native: Array<{ path: string; content: string }>
586
+ localContexts: Array<{ path: string; content: string }>
587
+ userContext: { path: string; content: string } | undefined
588
+ projectDotClaude: { path: string; content: string } | undefined
589
+ managedFile: string
590
+ contextFiles: Array<{ path: string; content: string }>
591
+ }): string {
592
+ const keyHash = createHash('sha256')
593
+ keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
594
+ for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
595
+ if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
596
+ if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
597
+ keyHash.update(`${input.managedFile}\0`)
598
+ for (const file of input.contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
599
+ return keyHash.digest('hex')
600
+ }
601
+
494
602
  export default function contextImportsExtension(pi: ExtensionAPI) {
495
603
  let localContexts: Array<{ path: string; content: string }> = []
604
+ // ~/.claude/CLAUDE.md, Claude's user-scope memory (all projects). The user's own
605
+ // file, so it needs no project approval; read once at session start like the
606
+ // locals, so a mid-session body edit applies next session, matching Claude.
607
+ let userContext: { path: string; content: string } | undefined
608
+ // The project-scope alternate location ./.claude/CLAUDE.md. pi loads ./CLAUDE.md
609
+ // natively but not this one; repo-controlled, so it is approval-gated like the
610
+ // locals and read once at session start.
611
+ let projectDotClaude: { path: string; content: string } | undefined
496
612
  // Whether project settings may contribute claudeMdExcludes; decided at session
497
613
  // start with the silent check, so no prompt fires mid-flight.
498
614
  let projectApproved = false
@@ -507,6 +623,87 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
507
623
  publishInstructionLoad(pi.events, event)
508
624
  }
509
625
 
626
+ // before_agent_start fires every turn, but its inputs almost never change
627
+ // mid-session. The settings-derived environment (managed settings, exclude
628
+ // globs, repo root) is cached per cwd, and the whole import expansion is
629
+ // memoized on its inputs and revalidated by a stat token (mtime and size): a
630
+ // turn where nothing changed costs a handful of stats instead of re-reading and
631
+ // re-recursing every @import. A brand-new file satisfying a previously missing
632
+ // @import is picked up when any recorded stat token moves (or next session),
633
+ // which is already fresher than Claude, which loads context once at session start.
634
+ let envCache: { cwd: string; managed: Record<string, unknown>; excludeGlobs: string[]; projectRoot: string } | undefined
635
+ let importMemo:
636
+ | {
637
+ key: string
638
+ extras: Array<{ path: string; content: string; dir: string }>
639
+ imported: ImportedFile[]
640
+ budget: ImportBudget
641
+ tokens: Array<[string, string]>
642
+ }
643
+ | undefined
644
+ // mtime plus size, so a same-mtime rewrite of a different length still invalidates.
645
+ const statToken = (file: string): string => {
646
+ const stat = fs.statSync(file)
647
+ return `${stat.mtimeMs}:${stat.size}`
648
+ }
649
+ const memoIsFresh = (memo: NonNullable<typeof importMemo>): boolean => {
650
+ try {
651
+ return memo.tokens.every(([file, token]) => statToken(file) === token)
652
+ } catch {
653
+ return false // a recorded file vanished: re-expand
654
+ }
655
+ }
656
+ // Memo lookup or recompute: a turn whose key matches the previous expansion and
657
+ // whose recorded stat tokens are all unchanged reuses that expansion outright;
658
+ // otherwise the imports are re-expanded and the memo (with a fresh revalidation
659
+ // set) is rebuilt for next turn.
660
+ const resolveImports = (
661
+ memoKey: string,
662
+ native: Array<{ path: string; content: string }>,
663
+ contextFiles: Array<{ path: string; content: string }>,
664
+ home: string,
665
+ cwd: string,
666
+ excluded: (absPath: string) => boolean,
667
+ ): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
668
+ if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
669
+ const { extras, budget, imported } = importMemo
670
+ return { extras, budget, imported }
671
+ }
672
+ // Seed with every loaded context file path, excluded ones included, so pi's own
673
+ // files are never re-imported and an excluded file cannot return as an import.
674
+ // The user, project-.claude and managed-file additions join the seed too, so a
675
+ // context file's @import cannot pull any of them in a second time.
676
+ const ownPaths = [...native, ...localContexts].map((file) => file.path)
677
+ if (userContext !== undefined) ownPaths.push(userContext.path)
678
+ if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
679
+ ownPaths.push(managedClaudeMdPath())
680
+ const seenSet = new Set(realRoots(ownPaths))
681
+
682
+ // Claude's --add-dir memory loading, env-gated. The files join the seen set
683
+ // before import expansion so an @import cannot pull one in twice, and they get
684
+ // the same exclude and comment-strip treatment as native context files.
685
+ const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
686
+ const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
687
+
688
+ // One budget for the whole run, so N context files cannot each spend a full one.
689
+ // Exclusion applies inside the recursion: an excluded @import is skipped before
690
+ // it is read, so its transitive imports never load and it spends no budget.
691
+ const budget = createImportBudget()
692
+ const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
693
+
694
+ // Revalidation set: every file the expansion read, plus each add-dir itself
695
+ // (a directory's mtime moves when a memory file is added or removed there).
696
+ const tokens: Array<[string, string]> = []
697
+ try {
698
+ for (const file of [...extras.map((extra) => extra.path), ...imported.map((entry) => entry.path)]) tokens.push([file, statToken(file)])
699
+ for (const dir of addDirs) tokens.push([dir, statToken(dir)])
700
+ importMemo = { key: memoKey, extras, budget, imported, tokens }
701
+ } catch {
702
+ importMemo = undefined // a file moved mid-expansion: just recompute next turn
703
+ }
704
+ return { extras, budget, imported }
705
+ }
706
+
510
707
  // Claude's --add-dir. Only the memory-loading half is meaningful here: pi has
511
708
  // no path-based permission system, so there is no access grant to mirror.
512
709
  // Optional-called so the extension still wires under stub hosts without flags.
@@ -520,14 +717,31 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
520
717
  // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
521
718
  // which is fine, since a reload re-loads the instruction files.
522
719
  announced.clear()
720
+ envCache = undefined
721
+ importMemo = undefined
722
+ localContexts = []
723
+ userContext = undefined
724
+ projectDotClaude = undefined
725
+
726
+ // ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
727
+ // project approval is required; a missing file simply leaves it unset.
728
+ try {
729
+ const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
730
+ userContext = { path: userClaudeMd, content: fs.readFileSync(userClaudeMd, 'utf-8') }
731
+ } catch {
732
+ // no user CLAUDE.md
733
+ }
734
+
523
735
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
524
736
  // skips it. A cloned repo can ship one, so it is gated like other project config.
525
737
  // Claude loads local context from the whole hierarchy above the working
526
738
  // directory, ordered root down to cwd; the walk is bounded at the repository
527
- // root like every other project-config search here.
528
- localContexts = []
739
+ // root like every other project-config search here. The project-scope alternate
740
+ // ./.claude/CLAUDE.md (nearest at or above cwd) is repo-controlled too, so both
741
+ // ride the one approval decision.
529
742
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
530
- if (candidates.length > 0 && (await isProjectApproved(ctx))) {
743
+ const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
744
+ if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
531
745
  for (const candidate of candidates) {
532
746
  try {
533
747
  localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
@@ -535,6 +749,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
535
749
  // unreadable: treat as absent
536
750
  }
537
751
  }
752
+ if (dotClaudeMd !== null) {
753
+ try {
754
+ projectDotClaude = { path: dotClaudeMd, content: fs.readFileSync(dotClaudeMd, 'utf-8') }
755
+ } catch {
756
+ // unreadable: treat as absent
757
+ }
758
+ }
538
759
  }
539
760
  // Read after the local-context flow so an approval it just recorded is honored.
540
761
  projectApproved = isProjectApprovedSilently(ctx)
@@ -545,10 +766,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
545
766
  const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
546
767
  const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
547
768
 
548
- const managed = readManagedSettings()
549
- const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managed)
769
+ if (envCache?.cwd !== cwd) {
770
+ const managedNow = readManagedSettings()
771
+ envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
772
+ }
773
+ const { managed, excludeGlobs, projectRoot } = envCache
550
774
  const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
551
- const projectRoot = repoRoot(cwd) ?? cwd
552
775
 
553
776
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
554
777
  // from import expansion; surviving blocks get block-level comments stripped.
@@ -563,35 +786,45 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
563
786
  announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
564
787
  }
565
788
 
566
- // Managed claudeMd is honored from managed settings ONLY (the key is ignored in
567
- // user and project settings) and is never excludable; it loads before user and
568
- // project context, so it goes to the top of the <project_context> block.
569
- const managedClaudeMd = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
570
- if (managedClaudeMd.length > 0) {
571
- prompt = withManagedBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedClaudeMd))
572
- changed = true
573
- }
574
-
575
- const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
576
- const contextFiles = [...rewrite.kept, ...keptLocals]
789
+ // The user CLAUDE.md is the user's own file (no approval gate) but respects
790
+ // claudeMdExcludes and comment-stripping like every other file. It is kept here so it
791
+ // both gets its own block (via prependMemoryBlocks) and joins the import-expansion set
792
+ // below, so its @imports resolve.
793
+ const keptUser = userContext !== undefined && !excluded(userContext.path) ? { path: userContext.path, content: stripBlockComments(userContext.content) } : undefined
577
794
 
578
- // Seed with every loaded context file path, excluded ones included, so pi's own
579
- // files are never re-imported and an excluded file cannot return as an import.
580
- const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
795
+ // Prepend the managed and user memory blocks (managed file, managed key, user), the
796
+ // blocks Claude loads ahead of pi's native project context. managedFile comes back
797
+ // because it also seeds the import memo key below.
798
+ const top = prependMemoryBlocks(prompt, changed, keptUser, managed, announce)
799
+ prompt = top.prompt
800
+ changed = top.changed
801
+ const managedFile = top.managedFile
581
802
 
582
- // Claude's --add-dir memory loading, env-gated. The files join the seen set
583
- // before import expansion so an @import cannot pull one in twice, and they get
584
- // the same exclude and comment-strip treatment as native context files.
585
- const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
586
- const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
587
-
588
- // One budget for the whole run, so N context files cannot each spend a full one.
589
- // Exclusion applies inside the recursion: an excluded @import is skipped before
590
- // it is read, so its transitive imports never load and it spends no budget.
591
- const budget = createImportBudget()
592
- const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
803
+ const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
593
804
 
594
- let addition = localContextAddition(keptLocals, announce)
805
+ // ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
806
+ // loads it too there is no double block, then exclude-checked and comment-stripped
807
+ // like the rest. Its @imports resolve at project roots (rootsForImporter).
808
+ const nativeReal = new Set(realRoots(native.map((file) => file.path)))
809
+ const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
810
+ const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
811
+
812
+ // The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
813
+ // @imports resolve (each at roots scoped to it, via rootsForImporter); their own
814
+ // bodies are placed separately, so expansion only surfaces what they import.
815
+ const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
816
+
817
+ // Everything the expansion depends on, hashed: a turn whose inputs match the memo
818
+ // and whose recorded mtimes are unchanged reuses the previous expansion outright.
819
+ const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
820
+ const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
821
+
822
+ const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
823
+
824
+ // Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
825
+ // additions, ahead of the CLAUDE.local.md bodies.
826
+ let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
827
+ addition += localContextAddition(keptLocals, announce)
595
828
  addition += additionalDirsAddition(extras, announce)
596
829
  addition += importedAddition(imported, budget, home, projectRoot, announce)
597
830
  if (!changed && addition.length === 0) return
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Context Usage Command
3
+ *
4
+ * Claude Code's `/context` reports how much of the model's context window the
5
+ * current session occupies. This registers a `context` command that reads pi's
6
+ * live ContextUsage and renders a concise breakdown through notify. pi exposes
7
+ * three fields (tokens, contextWindow, percent); the used/window/free lines and
8
+ * the percentage are built from exactly those, with the model's window as a
9
+ * fallback when the usage snapshot does not carry one.
10
+ */
11
+
12
+ import type { ContextUsage, ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
+
14
+ const fmt = (n: number): string => n.toLocaleString('en-US')
15
+
16
+ /** The breakdown text for a usage snapshot, or a friendly line when there is none.
17
+ * `tokens` is null right after a compaction (before the next response recounts), so
18
+ * that case reports a recalculating state instead of a bogus zero. */
19
+ export function formatContextUsage(usage: ContextUsage | undefined, modelWindow?: number): string {
20
+ if (!usage) {
21
+ return 'Context usage is not available yet. It appears once the model has responded (and resets right after a compaction).'
22
+ }
23
+ const window = usage.contextWindow || modelWindow || 0
24
+ if (usage.tokens === null) {
25
+ const tail = window > 0 ? ` Window: ${fmt(window)} tokens.` : ''
26
+ return `Context usage: recalculating the token count (e.g. right after a compaction).${tail}`
27
+ }
28
+ const tokens = usage.tokens
29
+ const percent = usage.percent ?? (window > 0 ? (tokens / window) * 100 : null)
30
+ const percentSuffix = percent === null ? '' : ` (${percent.toFixed(1)}%)`
31
+ const lines = ['Context usage', ` Used: ${fmt(tokens)} tokens${percentSuffix}`]
32
+ if (window > 0) {
33
+ lines.push(` Window: ${fmt(window)} tokens`, ` Free: ${fmt(Math.max(window - tokens, 0))} tokens`)
34
+ }
35
+ return lines.join('\n')
36
+ }
37
+
38
+ export default function contextUsageExtension(pi: ExtensionAPI) {
39
+ pi.registerCommand('context', {
40
+ description: 'Show how much of the model context window this session is using',
41
+ handler: async (_args, ctx) => {
42
+ ctx.ui.notify(formatContextUsage(ctx.getContextUsage(), ctx.model?.contextWindow), 'info')
43
+ },
44
+ })
45
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * settings.json `env` injection.
3
+ *
4
+ * Claude Code lets any settings scope carry an `env` object whose keys are exported
5
+ * into the session's environment. This extension applies that chain to process.env:
6
+ * managed-settings.json (enterprise policy), ~/.claude/settings.json (user), and the
7
+ * project's .claude/settings.json plus settings.local.json.
8
+ *
9
+ * Two things run this. The factory body applies managed + user immediately (as pi
10
+ * loads extensions), so those variables are present before the first turn; a session
11
+ * that never approves a project still gets them. session_start refreshes and, only
12
+ * when the project is approved, folds in the project scope. The project scope stays
13
+ * approval-gated on purpose: a checked-out repository's env can redirect providers
14
+ * (ANTHROPIC_BASE_URL and friends), so an untrusted repo must not reach process.env.
15
+ *
16
+ * Precedence is per key, managed > user > project, matching Claude's merge: a scope
17
+ * only supplies keys it names and never wipes another scope's keys. Values must be
18
+ * strings; a number or boolean is coerced via String, anything else is skipped.
19
+ *
20
+ * A variable already present in the real environment that this module did not set is
21
+ * left untouched for the user and project scopes: a shell `export` outranks them. The
22
+ * managed scope is the exception: an org policy env must win over an ambient export, so
23
+ * a managed key overwrites a preexisting shell value. The keys this module sets are
24
+ * recorded so a later refresh can update them without clobbering unrelated env, and a
25
+ * key an earlier apply set that the current merge no longer defines is unset (deleted
26
+ * from process.env), so an approved project's env cannot leak into a later session or
27
+ * project that does not define it. A managed overwrite of a shell var is owned like any
28
+ * other set key; its original shell value cannot be restored, so on unset it is deleted.
29
+ *
30
+ * Docs: https://code.claude.com/docs/en/settings.md
31
+ */
32
+
33
+ import * as fs from 'node:fs'
34
+ import * as os from 'node:os'
35
+ import * as path from 'node:path'
36
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
37
+
38
+ import { claudeConfigDir } from './internal/config-dir.js'
39
+ import { readManagedSettings } from './internal/managed-settings.js'
40
+ import { isProjectApprovedSilently } from './internal/project-approval.js'
41
+ import { findNearestFile } from './internal/project-root.js'
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
45
+ }
46
+
47
+ /** The `env` object of one settings scope, coerced to string values. A string is kept
48
+ * as-is, a number or boolean becomes its String() form, and anything else (object,
49
+ * array, null) is dropped, matching Claude which only injects string-valued env. */
50
+ export function envFromSettings(settings: unknown): Record<string, string> {
51
+ const out: Record<string, string> = {}
52
+ if (!isRecord(settings) || !isRecord(settings.env)) return out
53
+ for (const [key, value] of Object.entries(settings.env)) {
54
+ if (typeof value === 'string') out[key] = value
55
+ else if (typeof value === 'number' || typeof value === 'boolean') out[key] = String(value)
56
+ }
57
+ return out
58
+ }
59
+
60
+ /** Merge the three env scopes with Claude's per-key precedence managed > user >
61
+ * project: lower scopes are laid down first and higher ones overlay, so each key
62
+ * takes its highest-precedence value and no scope wipes another's keys. */
63
+ export function mergeEnvScopes(managed: Record<string, string>, user: Record<string, string>, project: Record<string, string>): Record<string, string> {
64
+ return { ...project, ...user, ...managed }
65
+ }
66
+
67
+ /** Assign the merged env into `env`, recording each key set into `owned`. A key already
68
+ * present that this module did not set (a shell export) is left untouched for user and
69
+ * project keys; a `managedKeys` entry overwrites it (an org policy outranks the shell).
70
+ * A key this module set before is updated. A previously-owned key that the current
71
+ * `merged` no longer defines is unset (deleted from `env` and `owned`), so an approved
72
+ * project's env cannot leak into a later apply that does not define it. */
73
+ export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Set<string>, managedKeys: ReadonlySet<string> = new Set()): void {
74
+ // Unset any key an earlier apply set that the current merge dropped. Iterate a copy
75
+ // since `owned` is mutated. A shell export this module never owned is left in place.
76
+ for (const key of Array.from(owned)) {
77
+ if (!(key in merged)) {
78
+ delete env[key]
79
+ owned.delete(key)
80
+ }
81
+ }
82
+ for (const [key, value] of Object.entries(merged)) {
83
+ // A shell export outranks user/project (skip), but a managed key outranks even the
84
+ // shell. Once owned, updates always apply. A managed overwrite is owned like any
85
+ // set key: its shell value is gone and cannot be restored, so on unset it is deleted.
86
+ if (key in env && !owned.has(key) && !managedKeys.has(key)) continue
87
+ env[key] = value
88
+ owned.add(key)
89
+ }
90
+ }
91
+
92
+ function readSettingsFile(file: string): Record<string, unknown> {
93
+ try {
94
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
95
+ if (isRecord(parsed)) return parsed
96
+ } catch {
97
+ // missing or invalid file: no env from this scope
98
+ }
99
+ return {}
100
+ }
101
+
102
+ /** The user scope's env: ~/.claude/settings.json (relocated by CLAUDE_CONFIG_DIR). */
103
+ function userEnv(home: string): Record<string, string> {
104
+ return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
105
+ }
106
+
107
+ /** The project scope's env: .claude/settings.json with settings.local.json overlaid,
108
+ * each the nearest of its name at or above cwd (matching the hooks settings chain). */
109
+ function projectEnv(cwd: string): Record<string, string> {
110
+ const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
111
+ const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
112
+ return { ...base, ...local }
113
+ }
114
+
115
+ export default function envSettingsExtension(pi: ExtensionAPI) {
116
+ const owned = new Set<string>()
117
+
118
+ const apply = (home: string, project: Record<string, string>): void => {
119
+ const managed = envFromSettings(readManagedSettings())
120
+ applyEnvSettings(mergeEnvScopes(managed, userEnv(home), project), process.env, owned, new Set(Object.keys(managed)))
121
+ }
122
+
123
+ // Factory time: managed + user only. Approval needs the session ctx, so the project
124
+ // scope waits for session_start; running here means these vars land before the first turn.
125
+ apply(os.homedir(), {})
126
+
127
+ pi.on('session_start', async (_event, ctx: ExtensionContext) => {
128
+ apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
129
+ })
130
+ }