pi-code 1.0.4 → 1.0.5

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 +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  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 +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -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 +171 -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 +125 -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 +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -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 +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -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')
@@ -128,7 +164,7 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
128
164
  * discovery order. Imports are resolved through symlinks and kept within
129
165
  * `allowedRoots` (which must already be realpath'd).
130
166
  */
131
- export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0): ImportedFile[] {
167
+ export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0, importer?: string, isExcluded?: (realPath: string) => boolean): ImportedFile[] {
132
168
  if (depth >= MAX_IMPORT_DEPTH) return []
133
169
  const out: ImportedFile[] = []
134
170
  for (const target of importTargets(content)) {
@@ -137,13 +173,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
137
173
  budget.dropped += 1
138
174
  continue
139
175
  }
140
- const file = readImport(target, fromDir, home, allowedRoots, seen)
176
+ const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
141
177
  if (!file) continue
142
178
  budget.files -= 1
143
179
  const kept = file.body.slice(0, budget.bytes)
144
180
  budget.bytes -= kept.length
145
181
  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))
182
+ // Comments are stripped before the scan for further imports, so a
183
+ // commented-out @import stays dead at every depth, matching the top level
184
+ // (whose bodies arrive here already stripped by the caller).
185
+ out.push({ path: file.real, body, parent: importer }, ...collectImports(stripBlockComments(kept), path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1, file.real, isExcluded))
147
186
  }
148
187
  return out
149
188
  }
@@ -160,57 +199,332 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
160
199
  const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
161
200
  const [real] = realRoots([importer])
162
201
  const fromUserConfig = real !== undefined && isUnder(real, userRoots)
163
- return fromUserConfig ? realRoots([cwd, ...userRoots]) : realRoots([cwd])
202
+ if (fromUserConfig) return realRoots([cwd, ...userRoots])
203
+ // A non-config file is bounded at the repository root: that covers an ancestor
204
+ // context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
205
+ // session, where cwd alone silently dropped its relative imports) without
206
+ // granting the importer's own directory. pi also loads home-level context
207
+ // files (~/AGENTS.md) that sit outside the config roots; their directory is
208
+ // $HOME, and allowing it would let @.ssh/... read into every session's prompt.
209
+ return realRoots([cwd, repoRoot(cwd) ?? cwd])
210
+ }
211
+
212
+ /** Claude's env gate for loading memory files from --add-dir directories. */
213
+ export const ADDITIONAL_DIRS_ENV = 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'
214
+
215
+ /** Whether the env gate is on. Claude documents `=1`; any value that is not
216
+ * empty/0/false/no counts, so `=true` behaves as a user would expect. */
217
+ export function additionalDirsClaudeMdEnabled(env: Record<string, string | undefined> = process.env): boolean {
218
+ const value = env[ADDITIONAL_DIRS_ENV]?.trim().toLowerCase()
219
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false' && value !== 'no'
220
+ }
221
+
222
+ /** Additional directories from the `add-dir` flag value. pi's getFlag is
223
+ * single-value, so a repeated `--add-dir a --add-dir b` cannot be expressed;
224
+ * a comma-separated value (`--add-dir a,b`) carries multiple dirs instead.
225
+ * `~` expands to home and relative paths resolve against cwd. */
226
+ export function parseAdditionalDirs(flagValue: unknown, home: string, cwd: string): string[] {
227
+ if (typeof flagValue !== 'string') return []
228
+ return flagValue
229
+ .split(',')
230
+ .map((entry) => entry.trim())
231
+ .filter(Boolean)
232
+ .map((entry) => path.resolve(cwd, expandHome(entry, home)))
233
+ }
234
+
235
+ /** The memory files Claude loads from one --add-dir directory when the env gate
236
+ * is set: CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md and CLAUDE.local.md.
237
+ * The local file is approval-gated like the session's own CLAUDE.local.md, so
238
+ * `includeLocal` reflects that decision. Missing or unreadable files are skipped. */
239
+ export function additionalDirContextFiles(dir: string, includeLocal: boolean): Array<{ path: string; content: string }> {
240
+ const candidates = [path.join(dir, 'CLAUDE.md'), path.join(dir, '.claude', 'CLAUDE.md')]
241
+ const rulesDir = path.join(dir, '.claude', 'rules')
242
+ try {
243
+ const names = fs
244
+ .readdirSync(rulesDir)
245
+ .filter((name) => name.endsWith('.md'))
246
+ .sort((a, b) => a.localeCompare(b))
247
+ candidates.push(...names.map((name) => path.join(rulesDir, name)))
248
+ } catch {
249
+ // no rules directory in this additional dir
250
+ }
251
+ if (includeLocal) candidates.push(path.join(dir, 'CLAUDE.local.md'))
252
+ const files: Array<{ path: string; content: string }> = []
253
+ for (const candidate of candidates) {
254
+ try {
255
+ files.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
256
+ } catch {
257
+ // absent or unreadable: treat as not there
258
+ }
259
+ }
260
+ return files
261
+ }
262
+
263
+ /** Path label given to the managed claudeMd block; not a file pi loaded. */
264
+ export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
265
+
266
+ /** pi's exact per-file wrapper inside <project_context>, reconstructed from
267
+ * path+content for exact-substring rewriting. tests/context-imports.test.ts pins
268
+ * this format against pi's own source so drift fails loudly instead of silently
269
+ * turning every rewrite into a no-op. */
270
+ export function instructionsBlock(filePath: string, content: string): string {
271
+ return `<project_instructions path="${filePath}">\n${content}\n</project_instructions>`
272
+ }
273
+
274
+ /** pi's <project_context> opener, the anchor the managed block is inserted after. */
275
+ const CONTEXT_OPENER = '<project_context>\n\nProject-specific instructions and guidelines:\n\n'
276
+
277
+ /** Remove a context block, preferring the shape pi assembles (trailing blank line). */
278
+ function removeBlock(prompt: string, wrapper: string): string | null {
279
+ for (const needle of [`${wrapper}\n\n`, wrapper]) {
280
+ const at = prompt.indexOf(needle)
281
+ if (at !== -1) return prompt.slice(0, at) + prompt.slice(at + needle.length)
282
+ }
283
+ return null
284
+ }
285
+
286
+ /** Replace one context block. String#replace is unsafe here: `$&` and friends in
287
+ * file content are replacement patterns. Splice by index instead. */
288
+ function replaceBlock(prompt: string, wrapper: string, replacement: string): string | null {
289
+ const at = prompt.indexOf(wrapper)
290
+ if (at === -1) return null
291
+ return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
292
+ }
293
+
294
+ /** Insert the managed claudeMd block at the top of <project_context>, before the
295
+ * files pi loaded (Claude documents managed claudeMd loading before user and
296
+ * project CLAUDE.md); when pi assembled no context block, add one in pi's shape. */
297
+ function withManagedBlock(prompt: string, block: string): string {
298
+ for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
299
+ const at = prompt.indexOf(anchor)
300
+ if (at === -1) continue
301
+ const insert = at + anchor.length
302
+ return `${prompt.slice(0, insert)}${block}\n\n${prompt.slice(insert)}`
303
+ }
304
+ return `${prompt}\n\n${CONTEXT_OPENER}${block}\n\n</project_context>\n`
305
+ }
306
+
307
+ /** Settings files whose `claudeMdExcludes` merge, following the hooks/memory
308
+ * chain: user settings always, the project's settings.json and
309
+ * settings.local.json (nearest at or above cwd) only when the project is
310
+ * approved. Managed settings are read separately by the caller. */
311
+ export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
312
+ const files = [path.join(home, '.claude', 'settings.json')]
313
+ if (!approved) return files
314
+ for (const name of ['settings.json', 'settings.local.json']) {
315
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
316
+ }
317
+ return files
318
+ }
319
+
320
+ /** Merged `claudeMdExcludes` globs across the settings chain plus managed
321
+ * settings. Exclusion lists union rather than override: any scope may add
322
+ * exclusions, none may remove another's. */
323
+ export function readClaudeMdExcludes(files: string[], managed: Record<string, unknown>): string[] {
324
+ const globs: string[] = []
325
+ const collect = (value: unknown) => {
326
+ if (!Array.isArray(value)) return
327
+ for (const entry of value) {
328
+ if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
329
+ }
330
+ }
331
+ for (const file of files) {
332
+ try {
333
+ const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
334
+ if (settings === null || typeof settings !== 'object') continue
335
+ collect((settings as Record<string, unknown>).claudeMdExcludes)
336
+ } catch {
337
+ // missing or invalid settings file: skip
338
+ }
339
+ }
340
+ collect(managed.claudeMdExcludes)
341
+ return globs
342
+ }
343
+
344
+ /** Whether an absolute context-file path matches one of the exclude globs. Globs
345
+ * match absolute paths: `~/` expands to home, a leading `/` anchors at the
346
+ * filesystem root, and a relative glob matches at any depth (gitignore-style).
347
+ * Matching runs on the path without its leading slash so `**` and `**\/`, which
348
+ * span whole segments, can reach a root-anchored path. */
349
+ export function isExcludedPath(absPath: string, globs: string[], home: string): boolean {
350
+ const target = absPath.split(path.sep).join('/').replace(/^\//, '')
351
+ return globs.some((raw) => {
352
+ let glob = expandHome(raw.trim(), home).split(path.sep).join('/')
353
+ if (glob.length === 0) return false
354
+ if (glob.startsWith('/')) glob = glob.slice(1)
355
+ else if (!glob.startsWith('**/')) glob = `**/${glob}`
356
+ return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
357
+ })
164
358
  }
165
359
 
166
360
  export default function contextImportsExtension(pi: ExtensionAPI) {
167
- let localContext: { path: string; content: string } | null = null
361
+ let localContexts: Array<{ path: string; content: string }> = []
362
+ // Whether project settings may contribute claudeMdExcludes; decided at session
363
+ // start with the silent check, so no prompt fires mid-flight.
364
+ let projectApproved = false
365
+ // Instruction loads already announced on the shared bus, keyed reason:path.
366
+ // before_agent_start fires every turn, so without this a configured
367
+ // InstructionsLoaded hook would fire once per file per turn.
368
+ const announced = new Set<string>()
369
+ const announce = (event: InstructionLoadEvent): void => {
370
+ const key = `${event.load_reason}:${event.file_path}`
371
+ if (announced.has(key)) return
372
+ announced.add(key)
373
+ publishInstructionLoad(pi.events, event)
374
+ }
375
+
376
+ // Claude's --add-dir. Only the memory-loading half is meaningful here: pi has
377
+ // no path-based permission system, so there is no access grant to mirror.
378
+ // Optional-called so the extension still wires under stub hosts without flags.
379
+ pi.registerFlag?.('add-dir', {
380
+ description: 'Additional working directories; with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD set, their CLAUDE.md memory files load too (comma-separated)',
381
+ type: 'string',
382
+ })
168
383
 
169
384
  pi.on('session_start', async (_event, ctx) => {
385
+ // pi's ctx.reload() rebuilds extension instances, so this set never survives
386
+ // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
387
+ // which is fine, since a reload re-loads the instruction files.
388
+ announced.clear()
170
389
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
171
390
  // 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
391
+ // Claude loads local context from the whole hierarchy above the working
392
+ // directory, ordered root down to cwd; the walk is bounded at the repository
393
+ // root like every other project-config search here.
394
+ localContexts = []
395
+ const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
396
+ if (candidates.length > 0 && (await isProjectApproved(ctx))) {
397
+ for (const candidate of candidates) {
398
+ try {
399
+ localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
400
+ } catch {
401
+ // unreadable: treat as absent
402
+ }
403
+ }
180
404
  }
405
+ // Read after the local-context flow so an approval it just recorded is honored.
406
+ projectApproved = isProjectApprovedSilently(ctx)
181
407
  })
182
408
 
183
409
  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
410
  const home = os.homedir()
189
411
  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))
412
+ const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
413
+
414
+ const managed = readManagedSettings()
415
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managed)
416
+ const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
417
+ const projectRoot = repoRoot(cwd) ?? cwd
418
+
419
+ let prompt = event.systemPrompt
420
+ let changed = false
421
+
422
+ // claudeMdExcludes drops an excluded file's block from the assembled prompt and
423
+ // from import expansion; surviving blocks get block-level comments stripped.
424
+ // Both rewrite by exact substring: a wrapper that is not found in the prompt is
425
+ // skipped rather than risk corrupting it.
426
+ const keptNative: Array<{ path: string; content: string }> = []
427
+ for (const file of native) {
428
+ const wrapper = instructionsBlock(file.path, file.content)
429
+ if (excluded(file.path)) {
430
+ const removed = removeBlock(prompt, wrapper)
431
+ if (removed !== null) {
432
+ prompt = removed
433
+ changed = true
434
+ }
435
+ continue
436
+ }
437
+ const stripped = stripBlockComments(file.content)
438
+ if (stripped !== file.content) {
439
+ const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
440
+ if (replaced !== null) {
441
+ prompt = replaced
442
+ changed = true
443
+ }
444
+ }
445
+ keptNative.push({ path: file.path, content: stripped })
446
+ // Exclusion is owned here, so the session_start InstructionsLoaded events
447
+ // for pi's native context files are published here too, only for files
448
+ // that actually survived it: Claude fires no event for a file it never
449
+ // loaded. The hooks extension consumes them off the shared bus.
450
+ announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
451
+ }
452
+
453
+ // Managed claudeMd is honored from managed settings ONLY (the key is ignored in
454
+ // user and project settings) and is never excludable; it loads before user and
455
+ // project context, so it goes to the top of the <project_context> block.
456
+ const managedClaudeMd = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
457
+ if (managedClaudeMd.length > 0) {
458
+ prompt = withManagedBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedClaudeMd))
459
+ changed = true
460
+ }
461
+
462
+ const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
463
+ const contextFiles = [...keptNative, ...keptLocals]
464
+
465
+ // Seed with every loaded context file path, excluded ones included, so pi's own
466
+ // files are never re-imported and an excluded file cannot return as an import.
467
+ const seen = realRoots([...native, ...localContexts].map((file) => file.path))
192
468
  const seenSet = new Set(seen)
193
469
 
470
+ // Claude's --add-dir memory loading, env-gated. The files join the seen set
471
+ // before import expansion so an @import cannot pull one in twice, and they get
472
+ // the same exclude and comment-strip treatment as native context files.
473
+ const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
474
+ const extras: Array<{ path: string; content: string; dir: string }> = []
475
+ for (const dir of addDirs) {
476
+ for (const file of additionalDirContextFiles(dir, projectApproved)) {
477
+ const [real] = realRoots([file.path])
478
+ const key = real ?? file.path
479
+ if (seenSet.has(key)) continue // pi already loaded it natively
480
+ seenSet.add(key)
481
+ if (excluded(file.path)) continue
482
+ const stripped = stripBlockComments(file.content)
483
+ if (stripped.trim().length === 0) continue
484
+ extras.push({ path: file.path, content: stripped, dir })
485
+ }
486
+ }
487
+
194
488
  const imported: ImportedFile[] = []
195
489
  // One budget for the whole run, so N context files cannot each spend a full one.
490
+ // Exclusion applies inside the recursion: an excluded @import is skipped before
491
+ // it is read, so its transitive imports never load and it spends no budget.
196
492
  const budget = createImportBudget()
197
493
  for (const file of contextFiles) {
198
494
  // Roots are scoped per importing file: a project file never reaches user config.
199
495
  const allowedRoots = rootsForImporter(file.path, home, cwd)
200
- imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
496
+ imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget, 0, file.path, excluded))
497
+ }
498
+ for (const extra of extras) {
499
+ // The additional dir itself is an allowed root, so its files' relative
500
+ // imports resolve even from .claude/rules two levels down.
501
+ const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
502
+ imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, budget, 0, extra.path, excluded))
201
503
  }
202
504
 
203
505
  let addition = ''
204
- if (localContext && localContext.content.trim().length > 0) {
205
- addition += `\n\n## CLAUDE.local.md\n\n${localContext.content.trim()}`
506
+ for (const local of keptLocals) {
507
+ if (local.content.trim().length > 0) {
508
+ addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
509
+ announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
510
+ }
511
+ }
512
+ for (const extra of extras) {
513
+ addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
514
+ // Additional dirs are extra working directories, so their memory files are
515
+ // Project-typed regardless of where the dir sits (Local for CLAUDE.local.md).
516
+ announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
206
517
  }
207
518
  if (imported.length > 0) {
208
- const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
519
+ const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
209
520
  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
521
  addition += `\n\n## Imported context (@)\n\n${section}${notice}`
522
+ for (const entry of imported) {
523
+ announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
524
+ }
211
525
  }
212
- if (addition.length === 0) return
526
+ if (!changed && addition.length === 0) return
213
527
 
214
- return { systemPrompt: event.systemPrompt + addition }
528
+ return { systemPrompt: prompt + addition }
215
529
  })
216
530
  }