pi-code 1.0.3 → 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 +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- 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 +377 -53
- 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 +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- 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 +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- 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 {
|
|
@@ -110,11 +142,18 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
110
142
|
return null
|
|
111
143
|
}
|
|
112
144
|
if (seen.has(real)) return null
|
|
113
|
-
seen.add(real)
|
|
114
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
|
|
115
150
|
try {
|
|
116
151
|
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
117
|
-
|
|
152
|
+
const body = fs.readFileSync(real, 'utf-8')
|
|
153
|
+
// Only a consumed file dedupes: marking a blocked or unreadable target seen
|
|
154
|
+
// would let one reader's failure suppress the import for a later, allowed one.
|
|
155
|
+
seen.add(real)
|
|
156
|
+
return { real, body }
|
|
118
157
|
} catch {
|
|
119
158
|
return null
|
|
120
159
|
}
|
|
@@ -125,7 +164,7 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
125
164
|
* discovery order. Imports are resolved through symlinks and kept within
|
|
126
165
|
* `allowedRoots` (which must already be realpath'd).
|
|
127
166
|
*/
|
|
128
|
-
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[] {
|
|
129
168
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
130
169
|
const out: ImportedFile[] = []
|
|
131
170
|
for (const target of importTargets(content)) {
|
|
@@ -134,13 +173,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
|
|
|
134
173
|
budget.dropped += 1
|
|
135
174
|
continue
|
|
136
175
|
}
|
|
137
|
-
const file = readImport(target, fromDir, home, allowedRoots, seen)
|
|
176
|
+
const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
|
|
138
177
|
if (!file) continue
|
|
139
178
|
budget.files -= 1
|
|
140
179
|
const kept = file.body.slice(0, budget.bytes)
|
|
141
180
|
budget.bytes -= kept.length
|
|
142
181
|
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
143
|
-
|
|
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))
|
|
144
186
|
}
|
|
145
187
|
return out
|
|
146
188
|
}
|
|
@@ -157,57 +199,332 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
|
|
|
157
199
|
const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
|
|
158
200
|
const [real] = realRoots([importer])
|
|
159
201
|
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
160
|
-
|
|
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
|
+
})
|
|
161
358
|
}
|
|
162
359
|
|
|
163
360
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
164
|
-
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
|
+
})
|
|
165
383
|
|
|
166
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()
|
|
167
389
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
168
390
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
}
|
|
177
404
|
}
|
|
405
|
+
// Read after the local-context flow so an approval it just recorded is honored.
|
|
406
|
+
projectApproved = isProjectApprovedSilently(ctx)
|
|
178
407
|
})
|
|
179
408
|
|
|
180
409
|
pi.on('before_agent_start', async (event) => {
|
|
181
|
-
const contextFiles: Array<{ path: string; content: string }> = [...(event.systemPromptOptions?.contextFiles ?? [])]
|
|
182
|
-
if (localContext) contextFiles.push(localContext)
|
|
183
|
-
if (contextFiles.length === 0) return
|
|
184
|
-
|
|
185
410
|
const home = os.homedir()
|
|
186
411
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
187
|
-
|
|
188
|
-
|
|
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))
|
|
189
468
|
const seenSet = new Set(seen)
|
|
190
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
|
+
|
|
191
488
|
const imported: ImportedFile[] = []
|
|
192
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.
|
|
193
492
|
const budget = createImportBudget()
|
|
194
493
|
for (const file of contextFiles) {
|
|
195
494
|
// Roots are scoped per importing file: a project file never reaches user config.
|
|
196
495
|
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
197
|
-
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))
|
|
198
503
|
}
|
|
199
504
|
|
|
200
505
|
let addition = ''
|
|
201
|
-
|
|
202
|
-
|
|
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' })
|
|
203
517
|
}
|
|
204
518
|
if (imported.length > 0) {
|
|
205
|
-
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')
|
|
206
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.`
|
|
207
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
|
+
}
|
|
208
525
|
}
|
|
209
|
-
if (addition.length === 0) return
|
|
526
|
+
if (!changed && addition.length === 0) return
|
|
210
527
|
|
|
211
|
-
return { systemPrompt:
|
|
528
|
+
return { systemPrompt: prompt + addition }
|
|
212
529
|
})
|
|
213
530
|
}
|
|
@@ -137,12 +137,25 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
137
137
|
pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
|
|
138
138
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
139
139
|
if (check.code !== 0) {
|
|
140
|
-
await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
140
|
+
const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
141
|
+
if (init.code !== 0) {
|
|
142
|
+
// Every later snapshot fails against the missing repo, so without this the
|
|
143
|
+
// user first learns /rewind is dead at the moment they need it.
|
|
144
|
+
ctx.ui.notify(`Checkpoints disabled: ${init.stderr.trim() || 'git init failed'}`, 'warning')
|
|
145
|
+
return
|
|
146
|
+
}
|
|
141
147
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
|
|
142
148
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
|
|
143
149
|
}
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
|
153
|
+
* snapshot restores as a no-op rather than vetoing the whole rewind. */
|
|
154
|
+
async function snapshotIsEmpty(ref: string): Promise<boolean> {
|
|
155
|
+
const files = await gitShadow(['ls-tree', '-r', '--name-only', ref])
|
|
156
|
+
return files.code === 0 && files.stdout.trim() === ''
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
|
|
147
160
|
const createdAt = new Date().toISOString()
|
|
148
161
|
const add = await gitShadow(['add', '-A'])
|
|
@@ -175,6 +188,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
175
188
|
ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
|
|
176
189
|
return true
|
|
177
190
|
}
|
|
191
|
+
if (await snapshotIsEmpty(checkpoint.ref)) {
|
|
192
|
+
ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
178
195
|
const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
|
|
179
196
|
if (result.code !== 0) {
|
|
180
197
|
ctx.ui.notify(`Code restore failed: ${result.stderr.trim()}`, 'warning')
|
|
@@ -248,6 +265,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
248
265
|
|
|
249
266
|
const choice = await ctx.ui.select('Restore code state?', ['Yes, restore code to that point', 'No, keep current code'])
|
|
250
267
|
if (choice?.startsWith('Yes')) {
|
|
268
|
+
if (await snapshotIsEmpty(checkpoint.ref)) {
|
|
269
|
+
ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
251
272
|
const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
|
|
252
273
|
ctx.ui.notify(result.code === 0 ? 'Code restored to checkpoint' : `Restore failed: ${result.stderr.trim()}`, result.code === 0 ? 'info' : 'warning')
|
|
253
274
|
}
|