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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- 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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
}
|