pi-code 1.0.9 → 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.
@@ -21,6 +21,7 @@ import * as os from 'node:os'
21
21
  import * as path from 'node:path'
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
23
 
24
+ import { claudeConfigDir } from './internal/config-dir.js'
24
25
  import { publishInstructionLoad } from './internal/instruction-events.js'
25
26
  import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
26
27
  import { isProjectApproved } from './internal/project-approval.js'
@@ -210,7 +211,7 @@ export function pendingScopedRuleCount(): number {
210
211
  }
211
212
 
212
213
  export default function claudeRulesExtension(pi: ExtensionAPI) {
213
- const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
214
+ const globalRulesDir = path.join(claudeConfigDir(os.homedir()), 'rules')
214
215
  let globalRules: RuleSet = EMPTY_RULES
215
216
  let projectRules: RuleSet = EMPTY_RULES
216
217
  // The base a scoped-rule pointer is written against, so the model's read resolves.
@@ -45,6 +45,7 @@ import { Type } from 'typebox'
45
45
 
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
47
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
+ import { claudeConfigDir } from './internal/config-dir.js'
48
49
  import { readManagedSettings } from './internal/managed-settings.js'
49
50
  import { capForContext } from './internal/output-guard.js'
50
51
  import { matchesPathRules } from './internal/path-rules.js'
@@ -115,7 +116,7 @@ function isDirectory(target: string): boolean {
115
116
  * directory is the nearest at or above cwd (bounded at the repository root, matching
116
117
  * the approval walk) and is included only for approved projects. */
117
118
  export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
118
- const candidates = [path.join(home, '.claude', 'commands')]
119
+ const candidates = [path.join(claudeConfigDir(home), 'commands')]
119
120
  if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'commands')) ?? path.join(cwd, '.claude', 'commands'))
120
121
  const dirs: string[] = []
121
122
  for (const dir of candidates) {
@@ -165,7 +166,7 @@ type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
165
166
  */
166
167
  export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
167
168
  if (readManagedSettings().disableSkillShellExecution === true) return true
168
- const files = [path.join(home, '.claude', 'settings.json')]
169
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
169
170
  if (trusted) {
170
171
  for (const name of ['settings.json', 'settings.local.json']) {
171
172
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -244,6 +245,8 @@ export interface SlashCommandEntry {
244
245
  name: string
245
246
  description: string
246
247
  argumentHint?: string
248
+ /** `when_to_use:` trigger text, appended to this entry's line in the tool listing. */
249
+ whenToUse?: string
247
250
  }
248
251
 
249
252
  /** One listed command's cap inside the tool description, as Claude cuts an
@@ -271,7 +274,10 @@ export function slashCommandToolDescription(commands: SlashCommandEntry[], budge
271
274
  let omitted = 0
272
275
  for (const command of commands) {
273
276
  const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
274
- const entry = `/${command.name} - ${command.description}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
277
+ // when_to_use is model-facing trigger text, appended after the description and before
278
+ // the argument hint; it shares the per-entry cap and never reaches the user surface.
279
+ const whenSuffix = command.whenToUse ? ` ${command.whenToUse}` : ''
280
+ const entry = `/${command.name} - ${command.description}${whenSuffix}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
275
281
  if (used + entry.length + 1 > budget) {
276
282
  omitted++
277
283
  continue // a shorter later entry may still fit the remaining budget
@@ -303,6 +309,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
303
309
  let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
304
310
  /** The session model to restore after a command's `model:` override drove its run. */
305
311
  let pendingModelRestore: ModelLike | undefined
312
+ /** The thinking level to restore after a command's `effort:` override drove its run. */
313
+ let pendingEffortRestore: string | undefined
306
314
 
307
315
  // Claude's contract is "the grant clears when you send your next message", and
308
316
  // pi's turn_end fires after every assistant step: restoring there stripped a
@@ -323,6 +331,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
323
331
  // command's override model.
324
332
  void pi.setModel(restore).catch(() => {})
325
333
  }
334
+ if (pendingEffortRestore) {
335
+ const level = pendingEffortRestore as Parameters<typeof pi.setThinkingLevel>[0]
336
+ pendingEffortRestore = undefined
337
+ pi.setThinkingLevel(level)
338
+ }
326
339
  if (pendingRestore) {
327
340
  pi.setActiveTools(pendingRestore)
328
341
  pendingRestore = undefined
@@ -404,6 +417,19 @@ export default function commandsExtension(pi: ExtensionAPI) {
404
417
  }
405
418
  }
406
419
 
420
+ /** Claude's `effort:` frontmatter overrides the thinking level for this run only, then
421
+ * the session level resumes; restore happens on agent_settled like the model restore.
422
+ * Applied before sendUserMessage so the run it drives happens at the new level. Only the
423
+ * first override in a turn records the restore target, so a second command restores to
424
+ * the original session level rather than the first command's override (as pendingModelRestore). */
425
+ function applyEffortOverride(parsed: ParsedCommand, varCtx: VarContext): void {
426
+ const target = parsed.effort
427
+ if (target && varCtx.thinkingLevel && target !== varCtx.thinkingLevel) {
428
+ pendingEffortRestore = pendingEffortRestore ?? varCtx.thinkingLevel
429
+ pi.setThinkingLevel(target as Parameters<typeof pi.setThinkingLevel>[0])
430
+ }
431
+ }
432
+
407
433
  async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: CommandPlugin): Promise<void> {
408
434
  const varCtx = ctx as unknown as VarContext
409
435
  const vars = commandVars(ctx, filePath, plugin)
@@ -435,6 +461,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
435
461
  applyAllowedTools(parsed, vars)
436
462
  applyDisallowedTools(parsed)
437
463
  await applyModelOverride(parsed, varCtx)
464
+ applyEffortOverride(parsed, varCtx)
438
465
  pi.sendUserMessage(expanded)
439
466
  }
440
467
 
@@ -463,7 +490,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
463
490
  discovered.set(command.name, command)
464
491
  // A user-only command stays off the tool description; it is still in the
465
492
  // map so a model attempt gets the explicit refusal, not "unknown command".
466
- if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint })
493
+ if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint, whenToUse: parsed.whenToUse })
494
+ // user-invocable:false is the inverse of disable-model-invocation: the command is
495
+ // hidden from the user slash-command surface but stays in `discovered` and the tool
496
+ // description above, so the model can still run it through the slash_command tool.
497
+ if (!parsed.userInvocable) continue
467
498
  // pi has no unregister, so a command already registered this process keeps its
468
499
  // original file binding; re-registering would only add a numbered duplicate.
469
500
  if (registered.has(command.name)) continue
@@ -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,11 +52,13 @@
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
  */
@@ -58,8 +69,9 @@ import * as os from 'node:os'
58
69
  import * as path from 'node:path'
59
70
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
60
71
 
72
+ import { claudeConfigDir } from './internal/config-dir.js'
61
73
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
62
- import { readManagedSettings } from './internal/managed-settings.js'
74
+ import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
63
75
  import { globToRegExpSource } from './internal/path-rules.js'
64
76
  import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
65
77
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
@@ -223,7 +235,7 @@ export function collectImports(content: string, fromDir: string, home: string, a
223
235
  * would let it read them into the system prompt.
224
236
  */
225
237
  export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
226
- const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
238
+ const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
227
239
  const [real] = realRoots([importer])
228
240
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
229
241
  if (fromUserConfig) return realRoots([cwd, ...userRoots])
@@ -287,9 +299,34 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
287
299
  return files
288
300
  }
289
301
 
290
- /** 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. */
291
303
  export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
292
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
+
293
330
  /** pi's exact per-file wrapper inside <project_context>, reconstructed from
294
331
  * path+content for exact-substring rewriting. tests/context-imports.test.ts pins
295
332
  * this format against pi's own source so drift fails loudly instead of silently
@@ -318,10 +355,12 @@ function replaceBlock(prompt: string, wrapper: string, replacement: string): str
318
355
  return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
319
356
  }
320
357
 
321
- /** Insert the managed claudeMd block at the top of <project_context>, before the
322
- * files pi loaded (Claude documents managed claudeMd loading before user and
323
- * project CLAUDE.md); when pi assembled no context block, add one in pi's shape. */
324
- 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 {
325
364
  for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
326
365
  const at = prompt.indexOf(anchor)
327
366
  if (at === -1) continue
@@ -336,7 +375,7 @@ function withManagedBlock(prompt: string, block: string): string {
336
375
  * settings.local.json (nearest at or above cwd) only when the project is
337
376
  * approved. Managed settings are read separately by the caller. */
338
377
  export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
339
- const files = [path.join(home, '.claude', 'settings.json')]
378
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
340
379
  if (!approved) return files
341
380
  for (const name of ['settings.json', 'settings.local.json']) {
342
381
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -454,6 +493,16 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
454
493
  return imported
455
494
  }
456
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
+
457
506
  /** The CLAUDE.local.md bodies appended after the native context, announced as they
458
507
  * are added (only the non-empty ones, matching what actually reaches the prompt). */
459
508
  function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
@@ -492,8 +541,74 @@ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home:
492
541
  return `\n\n## Imported context (@)\n\n${section}${notice}`
493
542
  }
494
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
+
495
602
  export default function contextImportsExtension(pi: ExtensionAPI) {
496
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
497
612
  // Whether project settings may contribute claudeMdExcludes; decided at session
498
613
  // start with the silent check, so no prompt fires mid-flight.
499
614
  let projectApproved = false
@@ -556,7 +671,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
556
671
  }
557
672
  // Seed with every loaded context file path, excluded ones included, so pi's own
558
673
  // files are never re-imported and an excluded file cannot return as an import.
559
- const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
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))
560
681
 
561
682
  // Claude's --add-dir memory loading, env-gated. The files join the seen set
562
683
  // before import expansion so an @import cannot pull one in twice, and they get
@@ -598,14 +719,29 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
598
719
  announced.clear()
599
720
  envCache = undefined
600
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
+
601
735
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
602
736
  // skips it. A cloned repo can ship one, so it is gated like other project config.
603
737
  // Claude loads local context from the whole hierarchy above the working
604
738
  // directory, ordered root down to cwd; the walk is bounded at the repository
605
- // root like every other project-config search here.
606
- 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.
607
742
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
608
- 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))) {
609
745
  for (const candidate of candidates) {
610
746
  try {
611
747
  localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
@@ -613,6 +749,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
613
749
  // unreadable: treat as absent
614
750
  }
615
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
+ }
616
759
  }
617
760
  // Read after the local-context flow so an approval it just recorded is honored.
618
761
  projectApproved = isProjectApprovedSilently(ctx)
@@ -643,30 +786,45 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
643
786
  announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
644
787
  }
645
788
 
646
- // Managed claudeMd is honored from managed settings ONLY (the key is ignored in
647
- // user and project settings) and is never excludable; it loads before user and
648
- // project context, so it goes to the top of the <project_context> block.
649
- const managedClaudeMd = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
650
- if (managedClaudeMd.length > 0) {
651
- prompt = withManagedBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedClaudeMd))
652
- changed = true
653
- }
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
794
+
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
654
802
 
655
803
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
656
- const contextFiles = [...rewrite.kept, ...keptLocals]
804
+
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]
657
816
 
658
817
  // Everything the expansion depends on, hashed: a turn whose inputs match the memo
659
818
  // and whose recorded mtimes are unchanged reuses the previous expansion outright.
660
819
  const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
661
- const keyHash = createHash('sha256')
662
- keyHash.update(`${cwd}\0${home}\0${projectApproved}\0${addDirsRaw}\0${excludeGlobs.join(',')}\0`)
663
- for (const file of [...native, ...localContexts]) keyHash.update(`${file.path}\0`)
664
- for (const file of contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
665
- const memoKey = keyHash.digest('hex')
820
+ const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
666
821
 
667
822
  const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
668
823
 
669
- let addition = localContextAddition(keptLocals, announce)
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)
670
828
  addition += additionalDirsAddition(extras, announce)
671
829
  addition += importedAddition(imported, budget, home, projectRoot, announce)
672
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
+ }