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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +179 -21
- package/extensions/context-imports.ts +353 -39
- package/extensions/hooks.ts +351 -63
- 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 +373 -59
- package/extensions/internal/html-markdown.ts +61 -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 +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -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 +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +272 -28
- package/extensions/memory.ts +129 -16
- package/extensions/notify.ts +77 -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 +93 -3
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +194 -29
- package/extensions/web.ts +80 -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')
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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:
|
|
528
|
+
return { systemPrompt: prompt + addition }
|
|
215
529
|
})
|
|
216
530
|
}
|