pi-code 1.1.0 → 1.2.1

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 CHANGED
@@ -62,7 +62,7 @@ pi has no general permission system, so most of what Claude routes through a per
62
62
 
63
63
  Trust is the other place pi-code is deliberately stricter. Claude states that "a `claude -p` run never shows the trust dialog" and loads the project's hooks, MCP servers, agents, commands, skills and rules anyway. pi-code refuses instead: with no stored decision and no UI to ask, a headless run in a project you have not already trusted loads none of them. A repository would otherwise get to run its own hooks and MCP servers in any CI job that checks it out, with nobody present to decline.
64
64
 
65
- `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
65
+ `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` adds what pi's loader lacks, the `@import` resolution and Claude's other memory locations, without re-injecting the base (see [docs/claude-md.md](docs/claude-md.md)). Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
66
66
 
67
67
  [`extensions/internal/`](extensions/internal) holds the shared modules pi's loader must not treat as extensions (each file's header says what it owns); only `internal/` keeps them out of pi's extension scan.
68
68
 
@@ -5,7 +5,9 @@
5
5
  * - Unscoped rules are inlined in full into the system prompt, global
6
6
  * (~/.claude/rules/*.md) and approved-project (.claude/rules/*.md) alike:
7
7
  * Claude loads rules without `paths:` frontmatter at launch with the same
8
- * priority as .claude/CLAUDE.md.
8
+ * priority as .claude/CLAUDE.md. Where the runtime builds the prompt from its
9
+ * options they go in as context files, one per rule file, so a provider that
10
+ * rebuilds the prompt keeps them.
9
11
  * - Path-scoped rules auto-attach: a rule file may declare `paths:` frontmatter
10
12
  * (a glob or list of globs). Its scope is surfaced upfront as a pointer, and
11
13
  * when a read/edit/write touches a file the globs cover, the rule body is
@@ -177,18 +179,39 @@ function findMarkdownFiles(dir: string, basePath = '', visited = new Set<string>
177
179
  return results
178
180
  }
179
181
 
182
+ interface InlineRule {
183
+ /** The rule file's absolute path. */
184
+ file: string
185
+ body: string
186
+ }
187
+
180
188
  interface ScopedRule {
181
189
  rel: string
190
+ /** The rule file's absolute path. */
191
+ file: string
182
192
  paths: string[]
183
193
  /** The rule text, attached when a matching file is touched. */
184
194
  body: string
185
195
  }
186
196
 
187
197
  interface RuleSet {
188
- inline: string[]
198
+ inline: InlineRule[]
189
199
  scoped: ScopedRule[]
190
200
  }
191
201
 
202
+ interface ContextFile {
203
+ path: string
204
+ content: string
205
+ }
206
+
207
+ /** The prompt options this extension edits. pi sets `forceSystemPrompt` from 0.86, where
208
+ * a handler that returned a prompt forced it for the run. */
209
+ interface PromptOptions {
210
+ contextFiles?: ContextFile[]
211
+ appendSystemPrompt?: string
212
+ forceSystemPrompt?: string
213
+ }
214
+
192
215
  const EMPTY_RULES: RuleSet = { inline: [], scoped: [] }
193
216
 
194
217
  /** The canonical form of a path. A target that does not exist yet (a write
@@ -211,19 +234,19 @@ function realpathOr(target: string): string {
211
234
  * (excluding another team's `.claude/rules/**`) relies on; the check runs on the
212
235
  * realpath so a symlink cannot dodge an exclusion. */
213
236
  function readRules(rulesDir: string, isExcluded?: (realPath: string) => boolean): RuleSet {
214
- const inline: string[] = []
237
+ const inline: InlineRule[] = []
215
238
  const scoped: ScopedRule[] = []
216
239
  for (const file of findMarkdownFiles(rulesDir)) {
240
+ const lexical = path.join(rulesDir, file)
217
241
  if (isExcluded) {
218
242
  // Both spellings count: a glob written against the lexical path and one
219
243
  // written against the resolved real path each exclude, which can only
220
244
  // widen an exclusion, never dodge one.
221
- const lexical = path.join(rulesDir, file)
222
245
  if (isExcluded(lexical) || isExcluded(realpathOr(lexical))) continue
223
246
  }
224
247
  let parsed: Frontmatter
225
248
  try {
226
- parsed = parseFrontmatter(fs.readFileSync(path.join(rulesDir, file), 'utf-8'))
249
+ parsed = parseFrontmatter(fs.readFileSync(lexical, 'utf-8'))
227
250
  } catch {
228
251
  continue // one unreadable rule must not take down session start
229
252
  }
@@ -234,26 +257,72 @@ function readRules(rulesDir: string, isExcluded?: (realPath: string) => boolean)
234
257
  // an empty text block to a tool result is rejected by the API when the
235
258
  // result's content is a block array (image-bearing results).
236
259
  if (body.length === 0) continue
237
- if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths, body })
238
- else inline.push(body)
260
+ if (parsed.paths.length > 0) scoped.push({ rel: file, file: lexical, paths: parsed.paths, body })
261
+ else inline.push({ file: lexical, body })
239
262
  }
240
263
  return { inline, scoped }
241
264
  }
242
265
 
266
+ /** The user's rules directory as a pointer names it, so the model's read resolves: against
267
+ * `~` under home, and by its full path where CLAUDE_CONFIG_DIR moved it elsewhere. */
268
+ function userRulesBase(rulesDir: string, home: string): string {
269
+ const relative = path.relative(home, rulesDir)
270
+ const outsideHome = relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
271
+ return outsideHome ? rulesDir : `~/${relative.split(path.sep).join('/')}`
272
+ }
273
+
274
+ /** The pointer list for a rule set's path-scoped rules. */
275
+ function scopedPointers(rules: RuleSet, base: string): string {
276
+ const scopedList = rules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, base)).join('\n')
277
+ return `Path-scoped rules, available in ${base}/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
278
+ }
279
+
243
280
  /** The system-prompt section for one rule set: inlined bodies, then scoped pointers. */
244
281
  function rulesSection(title: string, rules: RuleSet, base: string): string {
245
282
  if (rules.inline.length === 0 && rules.scoped.length === 0) return ''
246
283
  let section = `\n\n## ${title}`
247
284
  if (rules.inline.length > 0) {
248
- section += `\n\nThese rules always apply:\n\n${rules.inline.join('\n\n')}`
249
- }
250
- if (rules.scoped.length > 0) {
251
- const scopedList = rules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, base)).join('\n')
252
- section += `\n\nPath-scoped rules, available in ${base}/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
285
+ section += `\n\nThese rules always apply:\n\n${rules.inline.map((rule) => rule.body).join('\n\n')}`
253
286
  }
287
+ if (rules.scoped.length > 0) section += `\n\n${scopedPointers(rules, base)}`
254
288
  return section
255
289
  }
256
290
 
291
+ /** What a session's rules add to the prompt. Built once at session start, the only place
292
+ * the rule files are read. */
293
+ interface PromptRules {
294
+ /** The unscoped rules as context files, each under its own file, the way Claude loads it. */
295
+ files: ContextFile[]
296
+ /** The scoped pointers. Not a file, so they travel as appended instructions. */
297
+ pointers: string
298
+ /** Both as text, for a prompt that does not re-render from its options. */
299
+ text: string
300
+ }
301
+
302
+ const NO_PROMPT_RULES: PromptRules = { files: [], pointers: '', text: '' }
303
+
304
+ function promptRules(sets: Array<{ title: string; rules: RuleSet; base: string }>): PromptRules {
305
+ const scoped = sets.filter(({ rules }) => rules.scoped.length > 0)
306
+ return {
307
+ files: sets.flatMap(({ rules }) => rules.inline.map((rule) => ({ path: rule.file, content: rule.body }))),
308
+ pointers: scoped.map(({ rules, base }) => scopedPointers(rules, base)).join('\n\n'),
309
+ text: sets.map(({ title, rules, base }) => rulesSection(title, rules, base)).join(''),
310
+ }
311
+ }
312
+
313
+ /** Add the rules to the options a run's prompt is built from, returning how to take them
314
+ * out again. */
315
+ function addToOptions(options: PromptOptions, contextFiles: ContextFile[], rules: PromptRules): () => void {
316
+ const append = options.appendSystemPrompt
317
+ // Copies: the options belong to one run and these entries serve every turn.
318
+ contextFiles.push(...rules.files.map((entry) => ({ ...entry })))
319
+ if (rules.pointers.length > 0) options.appendSystemPrompt = append ? `${append}\n\n${rules.pointers}` : rules.pointers
320
+ return () => {
321
+ contextFiles.splice(contextFiles.length - rules.files.length, rules.files.length)
322
+ if (rules.pointers.length > 0) options.appendSystemPrompt = append
323
+ }
324
+ }
325
+
257
326
  /** A scoped rule resolved to the root its globs match against, ready to attach. */
258
327
  interface AttachTarget {
259
328
  /** The rule's `paths:` globs as written, reported on the instruction-events bus. */
@@ -269,6 +338,13 @@ interface AttachTarget {
269
338
  memoryType: 'User' | 'Project'
270
339
  }
271
340
 
341
+ /** CLAUDE_CODE_DISABLE_CLAUDE_MDS keeps every rule out of context, as it does in Claude
342
+ * Code. Read where a rule would be delivered rather than once at session start: a
343
+ * project's settings.json env reaches process.env after this extension's session_start. */
344
+ function rulesDisabled(): boolean {
345
+ return process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1'
346
+ }
347
+
272
348
  // Module level because the working list lives in each extension instance's closure.
273
349
  let pendingScopedRules = 0
274
350
 
@@ -280,12 +356,8 @@ export function pendingScopedRuleCount(): number {
280
356
 
281
357
  export default function claudeRulesExtension(pi: ExtensionAPI) {
282
358
  const globalRulesDir = path.join(claudeConfigDir(os.homedir()), 'rules')
283
- let globalRules: RuleSet = EMPTY_RULES
284
- let projectRules: RuleSet = EMPTY_RULES
285
- // The base a scoped-rule pointer is written against, so the model's read resolves.
286
- // The project rules dir may sit at an ancestor of cwd, where a cwd-relative
287
- // '.claude/rules' would point the read at a path that does not exist.
288
- let projectRulesBase = '.claude/rules'
359
+ const globalRulesBase = userRulesBase(globalRulesDir, os.homedir())
360
+ let rules: PromptRules = NO_PROMPT_RULES
289
361
  // Scoped rules still awaiting a matching touch. An attached rule leaves the
290
362
  // list, so each attaches at most once and the per-tool-result scan shrinks.
291
363
  let attachTargets: AttachTarget[] = []
@@ -303,14 +375,14 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
303
375
  // context loader honors gates rule files here.
304
376
  const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, os.homedir(), approved), readManagedSettings())
305
377
  const isExcluded = (realPath: string): boolean => isExcludedPath(realPath, excludeGlobs, os.homedir())
306
- globalRules = readRules(globalRulesDir, isExcluded)
378
+ const globalRules = readRules(globalRulesDir, isExcluded)
307
379
  // Nearest at-or-above cwd, so a subdirectory session still reads the rules the
308
380
  // approval walk gated on.
309
381
  // Not the user's own rules dir: from $HOME (or under a dotfiles repo rooted there)
310
382
  // the nearest one is ~/.claude/rules, which the global load above already read.
311
383
  const nearestRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
312
384
  const projectRulesDir = nearestRulesDir !== null && sameLocation(nearestRulesDir, globalRulesDir) ? null : nearestRulesDir
313
- projectRules = projectRulesDir ? readRules(projectRulesDir, isExcluded) : EMPTY_RULES
385
+ const projectRules = projectRulesDir ? readRules(projectRulesDir, isExcluded) : EMPTY_RULES
314
386
 
315
387
  // Global globs are relative to cwd; project globs to the project root (the dir
316
388
  // holding .claude), so `db/**` in a repo rule matches repo-relative paths even
@@ -318,30 +390,47 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
318
390
  // than on every tool result; rebuilt per session so a re-run re-attaches.
319
391
  const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
320
392
  attachTargets = [
321
- ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(ctx.cwd), file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
322
- ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
393
+ ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(ctx.cwd), file: rule.file, memoryType: 'User' as const })),
394
+ ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file: rule.file, memoryType: 'Project' as const })),
323
395
  ]
324
396
  scopedTargets = attachTargets
325
397
  pendingScopedRules = attachTargets.length
326
398
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
327
399
  // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
328
400
  // would point at a nonexistent path under the subdirectory.
329
- projectRulesBase = projectRulesDir === null ? '.claude/rules' : path.relative(ctx.cwd, projectRulesDir) || '.claude/rules'
401
+ const projectRulesBase = projectRulesDir === null ? '.claude/rules' : path.relative(ctx.cwd, projectRulesDir) || '.claude/rules'
402
+ // Global first: Claude loads user-level rules before project rules.
403
+ rules = promptRules([
404
+ { title: 'Global Rules', rules: globalRules, base: globalRulesBase },
405
+ { title: 'Project Rules', rules: projectRules, base: projectRulesBase },
406
+ ])
330
407
 
331
408
  const hasGlobal = globalRules.inline.length > 0 || globalRules.scoped.length > 0
332
409
  const projectCount = projectRules.inline.length + projectRules.scoped.length
333
- if (hasGlobal || projectCount > 0) {
410
+ if ((hasGlobal || projectCount > 0) && !rulesDisabled()) {
334
411
  ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectCount}`, 'info')
335
412
  }
336
413
  })
337
414
 
338
415
  pi.on('before_agent_start', async (event) => {
339
- // Global first: Claude loads user-level rules before project rules, so project
340
- // rules read later and take priority.
341
- const addition = rulesSection('Global Rules', globalRules, '~/.claude/rules') + rulesSection('Project Rules', projectRules, projectRulesBase)
342
- if (addition.length === 0) return
416
+ if (rules.text.length === 0 || rulesDisabled()) return
343
417
 
344
- return { systemPrompt: event.systemPrompt + addition }
418
+ // Rules join the options the prompt is built from. A provider that rebuilds the prompt
419
+ // from them keeps the rules there, where text appended to the rendered prompt is
420
+ // dropped: claude-bridge hands Claude Code the context files and appended instructions.
421
+ const options: PromptOptions | undefined = event.systemPromptOptions
422
+ const contextFiles = options?.contextFiles
423
+ const before = event.systemPrompt
424
+ if (options !== undefined && contextFiles !== undefined) {
425
+ const undo = addToOptions(options, contextFiles, rules)
426
+ // pi >= 0.86 re-renders event.systemPrompt from the options, and the rules are in.
427
+ if (event.systemPrompt !== before) return
428
+ // A prompt an earlier handler forced stays fixed, but the options are that run's own
429
+ // copy and still what a rebuilding provider reads, so they keep the rules. An older pi
430
+ // reuses the options object next turn, so there the edit is undone.
431
+ if (options.forceSystemPrompt === undefined) undo()
432
+ }
433
+ return { systemPrompt: before + rules.text }
345
434
  })
346
435
 
347
436
  // A rule's body sits in the tool result that attached it. Compaction folds that result into
@@ -359,7 +448,7 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
359
448
  // This mirrors Claude Code, which attaches a scoped rule when a matching file is
360
449
  // read or edited rather than inlining it upfront.
361
450
  pi.on('tool_result', async (event, ctx) => {
362
- if (attachTargets.length === 0) return
451
+ if (attachTargets.length === 0 || rulesDisabled()) return
363
452
  const rel = fileToolTarget(event)
364
453
  if (rel === undefined) return
365
454
  // Realpath both sides (roots canonicalise at session_start): a tool reporting
@@ -28,6 +28,13 @@
28
28
  * are removed along with their imports, and block-level HTML comments are stripped
29
29
  * from every surviving body (see internal/strip-comments).
30
30
  *
31
+ * Where the runtime builds the prompt from its options (pi 0.86 and later), the same
32
+ * memory is handed over as the options' context files instead, in Claude's order
33
+ * and with each import after its importer, and nothing is returned: the prompt is
34
+ * not forced, and a provider that rebuilds it from the options receives what a
35
+ * rewrite of the rendered text cannot give it. The text rewrite above remains for
36
+ * an older pi and for a prompt an earlier handler forced.
37
+ *
31
38
  * Security: context files can come from an untrusted project, so imports are
32
39
  * confined (after resolving symlinks) to the working directory plus its
33
40
  * repository root, and for user-config importers the user's own ~/.claude and
@@ -410,6 +417,10 @@ export function instructionsBlock(filePath: string, content: string): string {
410
417
  /** pi's <project_context> opener, the anchor the managed block is inserted after. */
411
418
  const CONTEXT_OPENER = '<project_context>\n\nProject-specific instructions and guidelines:\n\n'
412
419
 
420
+ /** The opener as pi 0.86 and later renders it, which is the layout a forced prompt has
421
+ * there: one newline after the tag. */
422
+ const SECTION_OPENER = '<project_context>\nProject-specific instructions and guidelines:\n\n'
423
+
413
424
  /** Remove a context block, preferring the shape pi assembles (trailing blank line). */
414
425
  function removeBlock(prompt: string, wrapper: string): string | null {
415
426
  for (const needle of [`${wrapper}\n\n`, wrapper]) {
@@ -433,7 +444,7 @@ function replaceBlock(prompt: string, wrapper: string, replacement: string): str
433
444
  * key) and the user CLAUDE.md. Each call prepends, so the last block inserted ends
434
445
  * up highest, which is how the managed/user/native order is built (see caller). */
435
446
  function withTopBlock(prompt: string, block: string): string {
436
- for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
447
+ for (const anchor of [CONTEXT_OPENER, SECTION_OPENER, '<project_context>\n\n']) {
437
448
  const at = prompt.indexOf(anchor)
438
449
  if (at === -1) continue
439
450
  const insert = at + anchor.length
@@ -784,13 +795,18 @@ function refusedImportsAddition(refused: Set<string>): string {
784
795
  return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
785
796
  }
786
797
 
798
+ /** What the import budget could not pay for, or nothing when it paid for everything. */
799
+ function budgetNotice(budget: ImportBudget): string {
800
+ return budget.dropped === 0 ? '' : `${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
801
+ }
802
+
787
803
  /** The `## Imported context (@)` section for every resolved @import, with the
788
804
  * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
789
805
  * was imported. */
790
806
  function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
791
807
  if (imported.length === 0) return ''
792
808
  const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
793
- 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.`
809
+ const notice = budget.dropped === 0 ? '' : `\n\n${budgetNotice(budget)}`
794
810
  for (const entry of imported) {
795
811
  announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
796
812
  }
@@ -826,6 +842,132 @@ function prependMemoryBlocks(prompt: string, changed: boolean, keptUser: { path:
826
842
  return { prompt, changed, managedFile }
827
843
  }
828
844
 
845
+ interface ContextFile {
846
+ path: string
847
+ content: string
848
+ }
849
+
850
+ /** The prompt options this extension edits. pi sets `forceSystemPrompt` from 0.86, where
851
+ * a handler that returned a prompt forced it for the run. */
852
+ interface PromptOptions {
853
+ contextFiles?: ContextFile[]
854
+ appendSystemPrompt?: string
855
+ forceSystemPrompt?: string
856
+ }
857
+
858
+ /** A file's rank among its directory's context files, in the order Claude loads them. */
859
+ const RANK = { memory: 0, sibling: 1, alternate: 2, rule: 3, local: 4 } as const
860
+
861
+ /** A context file with its place in Claude's load order: the user's own files, then each
862
+ * directory from the filesystem root down to cwd. */
863
+ interface Placed {
864
+ file: ContextFile
865
+ /** How deep the file's directory sits on the way to cwd; 0 for the user's own files. */
866
+ depth: number
867
+ rank: number
868
+ }
869
+
870
+ function place(file: ContextFile, directory: string, rank: number, where: { config: string; cwd: string }): Placed {
871
+ const onTheWayToCwd = where.cwd === directory || where.cwd.startsWith(directory + path.sep)
872
+ // The user's rules only: a checkout under the config directory is still a project.
873
+ const own = isUnder(file.path, [path.join(where.config, 'rules')]) || !onTheWayToCwd
874
+ return { file, depth: own ? 0 : directory.length, rank }
875
+ }
876
+
877
+ /** A file pi or another extension handed over, placed by the shape of its path. */
878
+ function placeByPath(file: ContextFile, where: { config: string; cwd: string }): Placed {
879
+ const rules = file.path.lastIndexOf(`${path.sep}.claude${path.sep}rules${path.sep}`)
880
+ if (rules !== -1) return place(file, file.path.slice(0, rules), RANK.rule, where)
881
+ const directory = path.dirname(file.path)
882
+ if (path.basename(file.path) === 'CLAUDE.local.md') return place(file, directory, RANK.local, where)
883
+ if (path.basename(directory) === '.claude') return place(file, path.dirname(directory), RANK.alternate, where)
884
+ return place(file, directory, RANK.memory, where)
885
+ }
886
+
887
+ function inClaudeOrder(placed: Placed[]): ContextFile[] {
888
+ return placed
889
+ .map((entry, index) => ({ entry, index }))
890
+ .sort((a, b) => a.entry.depth - b.entry.depth || a.entry.rank - b.entry.rank || a.index - b.index)
891
+ .map(({ entry }) => entry.file)
892
+ }
893
+
894
+ /** Each file followed by what it imports, an import's own imports right after it: Claude
895
+ * loads an import "alongside the CLAUDE.md that references" it. */
896
+ function withImports(files: ContextFile[], imported: ImportedFile[]): ContextFile[] {
897
+ const result: ContextFile[] = []
898
+ const add = (file: ContextFile): void => {
899
+ result.push(file)
900
+ for (const entry of imported) {
901
+ if (entry.parent === file.path) add({ path: entry.path, content: stripBlockComments(entry.body) })
902
+ }
903
+ }
904
+ for (const file of files) add(file)
905
+ return result
906
+ }
907
+
908
+ /** A body the prompt has room for: trimmed, and absent when nothing is left. */
909
+ function entryFor(file: ContextFile | undefined): ContextFile[] {
910
+ const content = file?.content.trim() ?? ''
911
+ return file === undefined || content.length === 0 ? [] : [{ path: file.path, content }]
912
+ }
913
+
914
+ /** What one turn loaded, by source. */
915
+ interface LoadedMemory {
916
+ managedFile: string
917
+ /** The managed-settings `claudeMd` value, as found there. */
918
+ managedKey: unknown
919
+ user: ContextFile | undefined
920
+ native: ContextFile[]
921
+ siblings: ContextFile[]
922
+ alternate: ContextFile | undefined
923
+ locals: ContextFile[]
924
+ extras: ContextFile[]
925
+ imported: ImportedFile[]
926
+ }
927
+
928
+ /** The memory as context files in Claude's order, for a runtime that builds the prompt
929
+ * from its options. */
930
+ function memoryEntries(memory: LoadedMemory, where: { config: string; cwd: string }): ContextFile[] {
931
+ const managedKey = typeof memory.managedKey === 'string' ? stripBlockComments(memory.managedKey) : ''
932
+ const ordered = inClaudeOrder([
933
+ ...memory.native.map((file) => placeByPath(file, where)),
934
+ ...memory.siblings.flatMap(entryFor).map((file) => place(file, path.dirname(file.path), RANK.sibling, where)),
935
+ ...entryFor(memory.alternate).map((file) => place(file, path.dirname(path.dirname(file.path)), RANK.alternate, where)),
936
+ ...memory.locals.flatMap(entryFor).map((file) => place(file, path.dirname(file.path), RANK.local, where)),
937
+ ])
938
+ const extras = memory.extras.map((extra) => ({ path: extra.path, content: extra.content }))
939
+ return [...entryFor({ path: managedClaudeMdPath(), content: memory.managedFile }), ...entryFor({ path: MANAGED_CLAUDE_MD_PATH, content: managedKey }), ...withImports([...entryFor(memory.user), ...ordered, ...extras], memory.imported)]
940
+ }
941
+
942
+ /** What is said about the imports that did not load. Not a file, so it travels as
943
+ * appended instructions, not as one of pi's custom prompt sections: a provider that
944
+ * rebuilds the prompt forwards the appended instructions and drops those sections. */
945
+ function memoryNotices(budget: ImportBudget, refusedNotice: string): string {
946
+ return [budgetNotice(budget), refusedNotice.trim()].filter((notice) => notice.length > 0).join('\n\n')
947
+ }
948
+
949
+ /** Hand the memory over as the context files the prompt is built from, and say whether
950
+ * that reached the prompt. pi >= 0.86 re-renders the prompt from the options, so there
951
+ * the memory is in, in Claude's order, and a provider that rebuilds the prompt from the
952
+ * options receives it too. A prompt an earlier handler forced stays fixed, but the options
953
+ * are that run's own copy, so they keep the memory. An older pi reuses the options object
954
+ * next turn, so there they are left as found. */
955
+ function deliverThroughOptions(event: { systemPrompt: string; systemPromptOptions?: PromptOptions }, entries: ContextFile[], notices: string): boolean {
956
+ const options = event.systemPromptOptions
957
+ const contextFiles = options?.contextFiles
958
+ if (options === undefined || contextFiles === undefined) return false
959
+ const before = event.systemPrompt
960
+ const found = [...contextFiles]
961
+ const append = options.appendSystemPrompt
962
+ contextFiles.splice(0, contextFiles.length, ...entries)
963
+ if (notices.length > 0) options.appendSystemPrompt = append ? `${append}\n\n${notices}` : notices
964
+ if (event.systemPrompt !== before) return true
965
+ if (options.forceSystemPrompt !== undefined) return false
966
+ contextFiles.splice(0, contextFiles.length, ...found)
967
+ if (notices.length > 0) options.appendSystemPrompt = append
968
+ return false
969
+ }
970
+
829
971
  /** Everything the import expansion depends on, hashed to a memo key: a turn whose inputs
830
972
  * match a prior key and whose recorded mtimes are unchanged reuses the previous expansion
831
973
  * outright. The native/local paths, the user/project-.claude additions, and the managed file
@@ -1146,7 +1288,11 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1146
1288
  addition += localContextAddition(keptLocals, announce)
1147
1289
  addition += additionalDirsAddition(extras, announce)
1148
1290
  addition += importedAddition(imported, budget, home, projectRoot, announce)
1149
- addition += refusedImportsAddition(budget.refused)
1291
+ const refusedNotice = refusedImportsAddition(budget.refused)
1292
+ addition += refusedNotice
1293
+
1294
+ const entries = memoryEntries({ managedFile, managedKey: managed.claudeMd, user: keptUser, native: rewrite.kept, siblings: keptSiblings, alternate: keptProjectDotClaude, locals: keptLocals, extras, imported }, { config: claudeConfigDir(home), cwd })
1295
+ if (deliverThroughOptions(event, entries, memoryNotices(budget, refusedNotice))) return
1150
1296
  if (!changed && addition.length === 0) return
1151
1297
 
1152
1298
  return { systemPrompt: prompt + addition }
@@ -524,7 +524,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
524
524
  * file first edited in that turn had its baseline folded into the discarded ref. */
525
525
  function continuedCheckpoint(ctx: ExtensionContext): Checkpoint | undefined {
526
526
  // A queued follow-up is a new user message and needs its own snapshot. Optional: the
527
- // peer range reaches runtimes that may not have the method.
527
+ // supported pi range reaches runtimes that may not have the method.
528
528
  if (ctx.hasPendingMessages?.()) return undefined
529
529
  const target = findLastUserMessage(ctx)
530
530
  return target ? checkpoints.get(target.entryId) : undefined
@@ -10,6 +10,8 @@
10
10
 
11
11
  import * as path from 'node:path'
12
12
 
13
+ import { claudeConfigDir } from './config-dir.js'
14
+
13
15
  export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
14
16
 
15
17
  /** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
@@ -43,11 +45,17 @@ export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEv
43
45
  }
44
46
 
45
47
  /** Claude's memory_type from a file's location: CLAUDE.local.md is Local wherever it
46
- * sits; a file under home but outside the project is User; everything else, the
47
- * project itself included (which commonly lives under home), is Project. */
48
+ * sits; the user's own CLAUDE.md and rules, and a file under home but outside the
49
+ * project, are User; everything else, the project itself included (which commonly
50
+ * lives under home), is Project. */
48
51
  export function memoryTypeForPath(filePath: string, home: string, projectRoot: string): InstructionMemoryType {
49
52
  if (path.basename(filePath) === 'CLAUDE.local.md') return 'Local'
50
53
  const isUnder = (root: string): boolean => root.length > 0 && (filePath === root || filePath.startsWith(root + path.sep))
54
+ // Ahead of the project check: CLAUDE_CONFIG_DIR may sit outside home, and a session
55
+ // rooted at home has the config directory inside its project root. Only the user's
56
+ // memory there, since a checkout under the config directory is still a project.
57
+ const config = claudeConfigDir(home)
58
+ if (filePath === path.join(config, 'CLAUDE.md') || isUnder(path.join(config, 'rules'))) return 'User'
51
59
  if (isUnder(projectRoot)) return 'Project'
52
60
  // Nested repositories: repoRoot stops at the nearest .git, so a session inside a
53
61
  // nested checkout reports it as the project root while the outer repository's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",
@@ -53,13 +53,13 @@
53
53
  "provenance": true
54
54
  },
55
55
  "dependencies": {
56
- "@modelcontextprotocol/sdk": "^1.30.0",
57
- "typebox": "^1.3.6"
56
+ "@modelcontextprotocol/sdk": "^1.30.0"
58
57
  },
59
58
  "peerDependencies": {
60
- "@earendil-works/pi-ai": ">=0.80.4",
61
- "@earendil-works/pi-coding-agent": ">=0.80.4",
62
- "@earendil-works/pi-tui": ">=0.80.4"
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*",
61
+ "@earendil-works/pi-tui": "*",
62
+ "typebox": "*"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "^2.5.4",
@@ -71,6 +71,7 @@
71
71
  "@vitest/coverage-v8": "^5.0.0",
72
72
  "fast-check": "^4.9.0",
73
73
  "knip": "^6.34.0",
74
+ "typebox": "^1.3.6",
74
75
  "typescript": "^7.0.2",
75
76
  "vitest": "^5.0.0"
76
77
  }