pi-code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -10,15 +10,45 @@
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 rewrites the context blocks pi assembled, by exact-substring
14
+ * 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).
21
+ *
13
22
  * Security: context files can come from an untrusted project, so imports are
14
- * confined (after resolving symlinks) to the working directory and the user's
15
- * own ~/.claude and ~/.pi config roots. An import that escapes those roots
16
- * (absolute paths, ~/.ssh, ../.. traversal, symlinks) is ignored, so a hostile
17
- * CLAUDE.md cannot read arbitrary files into the prompt. Imports inside fenced
23
+ * confined (after resolving symlinks) to the working directory plus its
24
+ * repository root, and for user-config importers the user's own ~/.claude and
25
+ * ~/.pi config roots. An import that escapes those roots (absolute paths,
26
+ * ~/.ssh, ../.. traversal, symlinks) is ignored, so a hostile CLAUDE.md, or a
27
+ * home-level context file whose own directory is $HOME, cannot read arbitrary
28
+ * files into the prompt. Imports inside fenced
18
29
  * code blocks are also skipped. One byte-and-file budget is shared by the whole
19
30
  * run, so a context file cannot flood the prompt by importing breadth-first;
20
31
  * what the budget refused is stated in the prompt rather than dropped silently.
21
32
  *
33
+ * It also carries Claude's --add-dir memory loading: with the `add-dir` flag set
34
+ * and CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD in the environment, each
35
+ * additional directory's CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md and
36
+ * (approval-gated) CLAUDE.local.md are appended as extra project_instructions
37
+ * blocks after the native context, their @imports resolved through the shared
38
+ * budgeted resolver with the additional dir as an allowed root. Known gaps,
39
+ * deliberate: pi's getFlag is single-value, so a repeated `--add-dir a --add-dir b`
40
+ * cannot be expressed; the flag accepts a comma-separated value instead. pi has
41
+ * no `--setting-sources`. The permission half of Claude's --add-dir (widening
42
+ * file access) is moot: pi has no path-based permission system to widen.
43
+ *
44
+ * Loads are also announced on the shared instruction-events bus for the
45
+ * 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).
51
+ *
22
52
  * Docs: https://code.claude.com/docs/en/memory.md (imports)
23
53
  */
24
54
 
@@ -27,9 +57,15 @@ import * as os from 'node:os'
27
57
  import * as path from 'node:path'
28
58
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
29
59
 
30
- import { isProjectApproved } from './internal/project-approval.js'
60
+ import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
61
+ import { readManagedSettings } from './internal/managed-settings.js'
62
+ import { globToRegExpSource } from './internal/path-rules.js'
63
+ import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
64
+ import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
65
+ import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
31
66
 
32
- const MAX_IMPORT_DEPTH = 5
67
+ /** Claude documents "a maximum depth of four hops" for recursive imports. */
68
+ const MAX_IMPORT_DEPTH = 4
33
69
  export const MAX_IMPORT_FILES = 50
34
70
  export const MAX_IMPORT_BYTES = 256 * 1024
35
71
 
@@ -59,6 +95,8 @@ export function realRoots(candidates: string[]): string[] {
59
95
  export interface ImportedFile {
60
96
  path: string
61
97
  body: string
98
+ /** The file whose `@path` pulled this one in, for InstructionsLoaded's parent_file_path. */
99
+ parent?: string
62
100
  }
63
101
 
64
102
  /** Appended to the last body the byte budget could only partly pay for. */
@@ -73,12 +111,6 @@ export interface ImportBudget {
73
111
 
74
112
  export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
75
113
 
76
- function fenceMarker(lineStart: string): string | null {
77
- if (lineStart.startsWith('```')) return '`'
78
- if (lineStart.startsWith('~~~')) return '~'
79
- return null
80
- }
81
-
82
114
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
83
115
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
84
116
  function importTargets(content: string): string[] {
@@ -100,8 +132,8 @@ function importTargets(content: string): string[] {
100
132
  return targets
101
133
  }
102
134
 
103
- /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, or unreadable. */
104
- function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>): { real: string; body: string } | null {
135
+ /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
136
+ function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded?: (realPath: string) => boolean): { real: string; body: string } | null {
105
137
  const resolved = path.resolve(fromDir, expandHome(target, home))
106
138
  let real: string
107
139
  try {
@@ -111,6 +143,10 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
111
143
  }
112
144
  if (seen.has(real)) return null
113
145
  if (!isUnder(real, allowedRoots)) return null
146
+ // Checked before the read so an excluded file contributes nothing: no body, no
147
+ // transitive imports, no budget spend, no announce. A post-collection filter
148
+ // would drop the file itself but keep its children.
149
+ if (isExcluded?.(real)) return null
114
150
  try {
115
151
  // real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
116
152
  const body = fs.readFileSync(real, 'utf-8')
@@ -123,31 +159,60 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
123
159
  }
124
160
  }
125
161
 
126
- /**
127
- * Collect the contents of every file transitively imported via `@path`, in
128
- * discovery order. Imports are resolved through symlinks and kept within
129
- * `allowedRoots` (which must already be realpath'd).
130
- */
131
- export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0): ImportedFile[] {
162
+ /** Optional controls for a collection run: the byte/file budget shared across the
163
+ * whole run, the path of the file this content came from (seeds each top-level
164
+ * import's `parent`), and the exclusion predicate. Recursion depth is internal. */
165
+ export interface CollectImportsOptions {
166
+ budget?: ImportBudget
167
+ importer?: string
168
+ isExcluded?: (realPath: string) => boolean
169
+ }
170
+
171
+ /** The parts of a collection run that stay fixed across the recursion: resolution
172
+ * roots, the seen/budget accumulators, and the exclusion predicate. Only content,
173
+ * fromDir, depth and the parent path change from one level to the next. */
174
+ interface ImportScan {
175
+ home: string
176
+ allowedRoots: string[]
177
+ seen: Set<string>
178
+ budget: ImportBudget
179
+ isExcluded?: (realPath: string) => boolean
180
+ }
181
+
182
+ /** One recursion level: read the imports named in `content`, then recurse into each. */
183
+ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth: number, importer?: string): ImportedFile[] {
132
184
  if (depth >= MAX_IMPORT_DEPTH) return []
133
185
  const out: ImportedFile[] = []
134
186
  for (const target of importTargets(content)) {
135
187
  // Checked before the read so an exhausted budget costs no I/O.
136
- if (budget.files === 0 || budget.bytes === 0) {
137
- budget.dropped += 1
188
+ if (scan.budget.files === 0 || scan.budget.bytes === 0) {
189
+ scan.budget.dropped += 1
138
190
  continue
139
191
  }
140
- const file = readImport(target, fromDir, home, allowedRoots, seen)
192
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
141
193
  if (!file) continue
142
- budget.files -= 1
143
- const kept = file.body.slice(0, budget.bytes)
144
- budget.bytes -= kept.length
194
+ scan.budget.files -= 1
195
+ const kept = file.body.slice(0, scan.budget.bytes)
196
+ scan.budget.bytes -= kept.length
145
197
  const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
146
- out.push({ path: file.real, body }, ...collectImports(kept, path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1))
198
+ // Comments are stripped before the scan for further imports, so a
199
+ // commented-out @import stays dead at every depth, matching the top level
200
+ // (whose bodies arrive here already stripped by the caller).
201
+ out.push({ path: file.real, body, parent: importer }, ...collectFrom(scan, stripBlockComments(kept), path.dirname(file.real), depth + 1, file.real))
147
202
  }
148
203
  return out
149
204
  }
150
205
 
206
+ /**
207
+ * Collect the contents of every file transitively imported via `@path`, in
208
+ * discovery order. Imports are resolved through symlinks and kept within
209
+ * `allowedRoots` (which must already be realpath'd).
210
+ */
211
+ export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, options: CollectImportsOptions = {}): ImportedFile[] {
212
+ const scan: ImportScan = { home, allowedRoots, seen, budget: options.budget ?? createImportBudget(), isExcluded: options.isExcluded }
213
+ return collectFrom(scan, content, fromDir, 0, options.importer)
214
+ }
215
+
151
216
  /**
152
217
  * Roots an importing file may pull from.
153
218
  *
@@ -160,57 +225,377 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
160
225
  const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
161
226
  const [real] = realRoots([importer])
162
227
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
163
- return fromUserConfig ? realRoots([cwd, ...userRoots]) : realRoots([cwd])
228
+ if (fromUserConfig) return realRoots([cwd, ...userRoots])
229
+ // A non-config file is bounded at the repository root: that covers an ancestor
230
+ // context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
231
+ // session, where cwd alone silently dropped its relative imports) without
232
+ // granting the importer's own directory. pi also loads home-level context
233
+ // files (~/AGENTS.md) that sit outside the config roots; their directory is
234
+ // $HOME, and allowing it would let @.ssh/... read into every session's prompt.
235
+ return realRoots([cwd, repoRoot(cwd) ?? cwd])
236
+ }
237
+
238
+ /** Claude's env gate for loading memory files from --add-dir directories. */
239
+ export const ADDITIONAL_DIRS_ENV = 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'
240
+
241
+ /** Whether the env gate is on. Claude documents `=1`; any value that is not
242
+ * empty/0/false/no counts, so `=true` behaves as a user would expect. */
243
+ export function additionalDirsClaudeMdEnabled(env: Record<string, string | undefined> = process.env): boolean {
244
+ const value = env[ADDITIONAL_DIRS_ENV]?.trim().toLowerCase()
245
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false' && value !== 'no'
246
+ }
247
+
248
+ /** Additional directories from the `add-dir` flag value. pi's getFlag is
249
+ * single-value, so a repeated `--add-dir a --add-dir b` cannot be expressed;
250
+ * a comma-separated value (`--add-dir a,b`) carries multiple dirs instead.
251
+ * `~` expands to home and relative paths resolve against cwd. */
252
+ export function parseAdditionalDirs(flagValue: unknown, home: string, cwd: string): string[] {
253
+ if (typeof flagValue !== 'string') return []
254
+ return flagValue
255
+ .split(',')
256
+ .map((entry) => entry.trim())
257
+ .filter(Boolean)
258
+ .map((entry) => path.resolve(cwd, expandHome(entry, home)))
259
+ }
260
+
261
+ /** The memory files Claude loads from one --add-dir directory when the env gate
262
+ * is set: CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md and CLAUDE.local.md.
263
+ * The local file is approval-gated like the session's own CLAUDE.local.md, so
264
+ * `includeLocal` reflects that decision. Missing or unreadable files are skipped. */
265
+ export function additionalDirContextFiles(dir: string, includeLocal: boolean): Array<{ path: string; content: string }> {
266
+ const candidates = [path.join(dir, 'CLAUDE.md'), path.join(dir, '.claude', 'CLAUDE.md')]
267
+ const rulesDir = path.join(dir, '.claude', 'rules')
268
+ try {
269
+ const names = fs
270
+ .readdirSync(rulesDir)
271
+ .filter((name) => name.endsWith('.md'))
272
+ .sort((a, b) => a.localeCompare(b))
273
+ candidates.push(...names.map((name) => path.join(rulesDir, name)))
274
+ } catch {
275
+ // no rules directory in this additional dir
276
+ }
277
+ if (includeLocal) candidates.push(path.join(dir, 'CLAUDE.local.md'))
278
+ const files: Array<{ path: string; content: string }> = []
279
+ for (const candidate of candidates) {
280
+ try {
281
+ files.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
282
+ } catch {
283
+ // absent or unreadable: treat as not there
284
+ }
285
+ }
286
+ return files
287
+ }
288
+
289
+ /** Path label given to the managed claudeMd block; not a file pi loaded. */
290
+ export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
291
+
292
+ /** pi's exact per-file wrapper inside <project_context>, reconstructed from
293
+ * path+content for exact-substring rewriting. tests/context-imports.test.ts pins
294
+ * this format against pi's own source so drift fails loudly instead of silently
295
+ * turning every rewrite into a no-op. */
296
+ export function instructionsBlock(filePath: string, content: string): string {
297
+ return `<project_instructions path="${filePath}">\n${content}\n</project_instructions>`
298
+ }
299
+
300
+ /** pi's <project_context> opener, the anchor the managed block is inserted after. */
301
+ const CONTEXT_OPENER = '<project_context>\n\nProject-specific instructions and guidelines:\n\n'
302
+
303
+ /** Remove a context block, preferring the shape pi assembles (trailing blank line). */
304
+ function removeBlock(prompt: string, wrapper: string): string | null {
305
+ for (const needle of [`${wrapper}\n\n`, wrapper]) {
306
+ const at = prompt.indexOf(needle)
307
+ if (at !== -1) return prompt.slice(0, at) + prompt.slice(at + needle.length)
308
+ }
309
+ return null
310
+ }
311
+
312
+ /** Replace one context block. String#replace is unsafe here: `$&` and friends in
313
+ * file content are replacement patterns. Splice by index instead. */
314
+ function replaceBlock(prompt: string, wrapper: string, replacement: string): string | null {
315
+ const at = prompt.indexOf(wrapper)
316
+ if (at === -1) return null
317
+ return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
318
+ }
319
+
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 {
324
+ for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
325
+ const at = prompt.indexOf(anchor)
326
+ if (at === -1) continue
327
+ const insert = at + anchor.length
328
+ return `${prompt.slice(0, insert)}${block}\n\n${prompt.slice(insert)}`
329
+ }
330
+ return `${prompt}\n\n${CONTEXT_OPENER}${block}\n\n</project_context>\n`
331
+ }
332
+
333
+ /** Settings files whose `claudeMdExcludes` merge, following the hooks/memory
334
+ * chain: user settings always, the project's settings.json and
335
+ * settings.local.json (nearest at or above cwd) only when the project is
336
+ * approved. Managed settings are read separately by the caller. */
337
+ export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
338
+ const files = [path.join(home, '.claude', 'settings.json')]
339
+ if (!approved) return files
340
+ for (const name of ['settings.json', 'settings.local.json']) {
341
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
342
+ }
343
+ return files
344
+ }
345
+
346
+ /** Merged `claudeMdExcludes` globs across the settings chain plus managed
347
+ * settings. Exclusion lists union rather than override: any scope may add
348
+ * exclusions, none may remove another's. */
349
+ export function readClaudeMdExcludes(files: string[], managed: Record<string, unknown>): string[] {
350
+ const globs: string[] = []
351
+ const collect = (value: unknown) => {
352
+ if (!Array.isArray(value)) return
353
+ for (const entry of value) {
354
+ if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
355
+ }
356
+ }
357
+ for (const file of files) {
358
+ try {
359
+ const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
360
+ if (settings === null || typeof settings !== 'object') continue
361
+ collect((settings as Record<string, unknown>).claudeMdExcludes)
362
+ } catch {
363
+ // missing or invalid settings file: skip
364
+ }
365
+ }
366
+ collect(managed.claudeMdExcludes)
367
+ return globs
368
+ }
369
+
370
+ /** Whether an absolute context-file path matches one of the exclude globs. Globs
371
+ * match absolute paths: `~/` expands to home, a leading `/` anchors at the
372
+ * filesystem root, and a relative glob matches at any depth (gitignore-style).
373
+ * Matching runs on the path without its leading slash so `**` and `**\/`, which
374
+ * span whole segments, can reach a root-anchored path. */
375
+ export function isExcludedPath(absPath: string, globs: string[], home: string): boolean {
376
+ const target = absPath.split(path.sep).join('/').replace(/^\//, '')
377
+ return globs.some((raw) => {
378
+ let glob = expandHome(raw.trim(), home).split(path.sep).join('/')
379
+ if (glob.length === 0) return false
380
+ if (glob.startsWith('/')) glob = glob.slice(1)
381
+ else if (!glob.startsWith('**/')) glob = `**/${glob}`
382
+ return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
383
+ })
384
+ }
385
+
386
+ /** Apply claudeMdExcludes and comment-stripping to pi's native context blocks,
387
+ * rewriting the assembled prompt by exact substring: an excluded file's block is
388
+ * removed, a surviving file's block is replaced with its comment-stripped body. A
389
+ * wrapper not found in the prompt is skipped rather than risk corrupting it.
390
+ * Returns the rewritten prompt and the files that survived exclusion (stripped). */
391
+ function rewriteNativeBlocks(prompt: string, native: Array<{ path: string; content: string }>, excluded: (absPath: string) => boolean): { prompt: string; changed: boolean; kept: Array<{ path: string; content: string }> } {
392
+ let changed = false
393
+ const kept: Array<{ path: string; content: string }> = []
394
+ for (const file of native) {
395
+ const wrapper = instructionsBlock(file.path, file.content)
396
+ if (excluded(file.path)) {
397
+ const removed = removeBlock(prompt, wrapper)
398
+ if (removed !== null) {
399
+ prompt = removed
400
+ changed = true
401
+ }
402
+ continue
403
+ }
404
+ const stripped = stripBlockComments(file.content)
405
+ if (stripped !== file.content) {
406
+ const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
407
+ if (replaced !== null) {
408
+ prompt = replaced
409
+ changed = true
410
+ }
411
+ }
412
+ kept.push({ path: file.path, content: stripped })
413
+ }
414
+ return { prompt, changed, kept }
415
+ }
416
+
417
+ /** Claude's --add-dir memory files, minus any pi already loaded natively (added to
418
+ * `seenSet` here so an @import cannot pull one in twice) and any the excludes drop
419
+ * or that strip to nothing. Each survivor is comment-stripped and tagged with its
420
+ * additional dir, so its relative imports can resolve from there. */
421
+ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded: (absPath: string) => boolean, includeLocal: boolean): Array<{ path: string; content: string; dir: string }> {
422
+ const extras: Array<{ path: string; content: string; dir: string }> = []
423
+ for (const dir of addDirs) {
424
+ for (const file of additionalDirContextFiles(dir, includeLocal)) {
425
+ const [real] = realRoots([file.path])
426
+ const key = real ?? file.path
427
+ if (seenSet.has(key)) continue // pi already loaded it natively
428
+ seenSet.add(key)
429
+ if (excluded(file.path)) continue
430
+ const stripped = stripBlockComments(file.content)
431
+ if (stripped.trim().length === 0) continue
432
+ extras.push({ path: file.path, content: stripped, dir })
433
+ }
434
+ }
435
+ return extras
436
+ }
437
+
438
+ /** Resolve every context and additional-dir file's @imports through the one shared
439
+ * budget, each with roots scoped to the importing file so a project file never
440
+ * reaches user config. */
441
+ 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[] {
442
+ const imported: ImportedFile[] = []
443
+ for (const file of contextFiles) {
444
+ const allowedRoots = rootsForImporter(file.path, home, cwd)
445
+ imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
446
+ }
447
+ for (const extra of extras) {
448
+ // The additional dir itself is an allowed root, so its files' relative imports
449
+ // resolve even from .claude/rules two levels down.
450
+ const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
451
+ imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
452
+ }
453
+ return imported
454
+ }
455
+
456
+ /** The CLAUDE.local.md bodies appended after the native context, announced as they
457
+ * are added (only the non-empty ones, matching what actually reaches the prompt). */
458
+ function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
459
+ let addition = ''
460
+ for (const local of keptLocals) {
461
+ if (local.content.trim().length > 0) {
462
+ addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
463
+ announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
464
+ }
465
+ }
466
+ return addition
467
+ }
468
+
469
+ /** The --add-dir memory files appended as extra project_instructions blocks.
470
+ * Additional dirs are extra working directories, so their files are Project-typed
471
+ * regardless of where the dir sits (Local for a CLAUDE.local.md). */
472
+ function additionalDirsAddition(extras: Array<{ path: string; content: string; dir: string }>, announce: (event: InstructionLoadEvent) => void): string {
473
+ let addition = ''
474
+ for (const extra of extras) {
475
+ addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
476
+ announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
477
+ }
478
+ return addition
479
+ }
480
+
481
+ /** The `## Imported context (@)` section for every resolved @import, with the
482
+ * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
483
+ * was imported. */
484
+ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
485
+ if (imported.length === 0) return ''
486
+ const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
487
+ const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
488
+ for (const entry of imported) {
489
+ announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
490
+ }
491
+ return `\n\n## Imported context (@)\n\n${section}${notice}`
164
492
  }
165
493
 
166
494
  export default function contextImportsExtension(pi: ExtensionAPI) {
167
- let localContext: { path: string; content: string } | null = null
495
+ let localContexts: Array<{ path: string; content: string }> = []
496
+ // Whether project settings may contribute claudeMdExcludes; decided at session
497
+ // start with the silent check, so no prompt fires mid-flight.
498
+ let projectApproved = false
499
+ // Instruction loads already announced on the shared bus, keyed reason:path.
500
+ // before_agent_start fires every turn, so without this a configured
501
+ // InstructionsLoaded hook would fire once per file per turn.
502
+ const announced = new Set<string>()
503
+ const announce = (event: InstructionLoadEvent): void => {
504
+ const key = `${event.load_reason}:${event.file_path}`
505
+ if (announced.has(key)) return
506
+ announced.add(key)
507
+ publishInstructionLoad(pi.events, event)
508
+ }
509
+
510
+ // Claude's --add-dir. Only the memory-loading half is meaningful here: pi has
511
+ // no path-based permission system, so there is no access grant to mirror.
512
+ // Optional-called so the extension still wires under stub hosts without flags.
513
+ pi.registerFlag?.('add-dir', {
514
+ description: 'Additional working directories; with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD set, their CLAUDE.md memory files load too (comma-separated)',
515
+ type: 'string',
516
+ })
168
517
 
169
518
  pi.on('session_start', async (_event, ctx) => {
519
+ // pi's ctx.reload() rebuilds extension instances, so this set never survives
520
+ // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
521
+ // which is fine, since a reload re-loads the instruction files.
522
+ announced.clear()
170
523
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
171
524
  // skips it. A cloned repo can ship one, so it is gated like other project config.
172
- localContext = null
173
- const candidate = path.join(ctx.cwd, 'CLAUDE.local.md')
174
- if (!fs.existsSync(candidate)) return
175
- if (!(await isProjectApproved(ctx))) return
176
- try {
177
- localContext = { path: candidate, content: fs.readFileSync(candidate, 'utf-8') }
178
- } catch {
179
- // unreadable: treat as absent
525
+ // Claude loads local context from the whole hierarchy above the working
526
+ // directory, ordered root down to cwd; the walk is bounded at the repository
527
+ // root like every other project-config search here.
528
+ localContexts = []
529
+ const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
530
+ if (candidates.length > 0 && (await isProjectApproved(ctx))) {
531
+ for (const candidate of candidates) {
532
+ try {
533
+ localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
534
+ } catch {
535
+ // unreadable: treat as absent
536
+ }
537
+ }
180
538
  }
539
+ // Read after the local-context flow so an approval it just recorded is honored.
540
+ projectApproved = isProjectApprovedSilently(ctx)
181
541
  })
182
542
 
183
543
  pi.on('before_agent_start', async (event) => {
184
- const contextFiles: Array<{ path: string; content: string }> = [...(event.systemPromptOptions?.contextFiles ?? [])]
185
- if (localContext) contextFiles.push(localContext)
186
- if (contextFiles.length === 0) return
187
-
188
544
  const home = os.homedir()
189
545
  const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
190
- // Seed with the loaded context file paths so pi's own files are never re-imported.
191
- const seen = realRoots(contextFiles.map((file) => file.path))
192
- const seenSet = new Set(seen)
546
+ const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
193
547
 
194
- const imported: ImportedFile[] = []
195
- // One budget for the whole run, so N context files cannot each spend a full one.
196
- const budget = createImportBudget()
197
- for (const file of contextFiles) {
198
- // Roots are scoped per importing file: a project file never reaches user config.
199
- const allowedRoots = rootsForImporter(file.path, home, cwd)
200
- imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
201
- }
548
+ const managed = readManagedSettings()
549
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managed)
550
+ const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
551
+ const projectRoot = repoRoot(cwd) ?? cwd
202
552
 
203
- let addition = ''
204
- if (localContext && localContext.content.trim().length > 0) {
205
- addition += `\n\n## CLAUDE.local.md\n\n${localContext.content.trim()}`
553
+ // claudeMdExcludes drops an excluded file's block from the assembled prompt and
554
+ // from import expansion; surviving blocks get block-level comments stripped.
555
+ const rewrite = rewriteNativeBlocks(event.systemPrompt, native, excluded)
556
+ let prompt = rewrite.prompt
557
+ let changed = rewrite.changed
558
+ // Exclusion is owned here, so the session_start InstructionsLoaded events for
559
+ // pi's native context files are published here too, only for files that
560
+ // survived it: Claude fires no event for a file it never loaded. The hooks
561
+ // extension consumes them off the shared bus.
562
+ for (const file of rewrite.kept) {
563
+ announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
206
564
  }
207
- if (imported.length > 0) {
208
- const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
209
- const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
210
- addition += `\n\n## Imported context (@)\n\n${section}${notice}`
565
+
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
211
573
  }
212
- if (addition.length === 0) return
213
574
 
214
- return { systemPrompt: event.systemPrompt + addition }
575
+ const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
576
+ const contextFiles = [...rewrite.kept, ...keptLocals]
577
+
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)))
581
+
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)
593
+
594
+ let addition = localContextAddition(keptLocals, announce)
595
+ addition += additionalDirsAddition(extras, announce)
596
+ addition += importedAddition(imported, budget, home, projectRoot, announce)
597
+ if (!changed && addition.length === 0) return
598
+
599
+ return { systemPrompt: prompt + addition }
215
600
  })
216
601
  }