pi-code 1.0.77 → 1.2.0

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 }
@@ -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
@@ -4,8 +4,11 @@
4
4
  * only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
5
5
  * labels the question. Escape in the editor returns to options; Escape in options cancels.
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
+ * On the TUI each question is also offered to a remote responder over pi.events
8
+ * (REMOTE_QUESTION_CHANNEL); the first answer, local or remote, wins.
7
9
  */
8
10
 
11
+ import { randomUUID } from 'node:crypto'
9
12
  import * as os from 'node:os'
10
13
  import * as path from 'node:path'
11
14
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
@@ -64,6 +67,26 @@ export interface QuestionSpec {
64
67
  multiSelect?: boolean
65
68
  }
66
69
 
70
+ /** In-process, question-owned offer; arbitrary custom TUI components are not sent. */
71
+ export const REMOTE_QUESTION_CHANNEL = 'pi-code:question:v1'
72
+ export type RemoteQuestionOutcome = { action: 'answer'; indices: number[] } | { action: 'text'; text: string } | { action: 'cancel' } | { action: 'pass' }
73
+
74
+ export interface RemoteQuestionOffer {
75
+ version: 1
76
+ requestId: string
77
+ sessionId: string
78
+ question: string
79
+ header?: string
80
+ options: OptionWithDesc[]
81
+ multiSelect: boolean
82
+ allowFreeText: boolean
83
+ signal: AbortSignal
84
+ /** Must be called during event emission; only one listener receives a settle handle. */
85
+ claim: () => ((outcome: RemoteQuestionOutcome) => boolean) | undefined
86
+ /** The claimed responder reports input activity to reset a configured idle timer. */
87
+ touch: () => boolean
88
+ }
89
+
67
90
  /** Normalize either accepted shape into the list of questions to ask. */
68
91
  function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
69
92
  if (params.questions && params.questions.length > 0) return params.questions
@@ -194,19 +217,19 @@ export default function question(pi: ExtensionAPI) {
194
217
  // parallel leave the first unanswerable and the run unable to finish.
195
218
  executionMode: 'sequential',
196
219
 
197
- async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
220
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
198
221
  const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
199
222
  if (specs.length === 0) {
200
223
  return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
201
224
  }
202
- if (specs.length === 1) return await askOne(specs[0], ctx)
225
+ if (specs.length === 1) return await askOne(specs[0], ctx, pi.events, signal)
203
226
 
204
227
  // Several questions are asked in sequence; a cancel ends the run, since the
205
228
  // remaining answers would be guesses about a flow the user just declined.
206
229
  const texts: string[] = []
207
230
  const collected: QuestionDetails[] = []
208
231
  for (const spec of specs) {
209
- const result = await askOne(spec, ctx)
232
+ const result = await askOne(spec, ctx, pi.events, signal)
210
233
  const detail = result.details as QuestionDetails
211
234
  collected.push(detail)
212
235
  texts.push(`${spec.question}\n${result.content[0].text}`)
@@ -258,7 +281,135 @@ export default function question(pi: ExtensionAPI) {
258
281
  })
259
282
  }
260
283
 
261
- async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
284
+ type QuestionAnswer = Awaited<ReturnType<typeof askViaOverlay>>
285
+
286
+ /** A claimed offer raced against the open overlay: whichever answers first wins. */
287
+ interface RemoteQuestion {
288
+ /** Routes remote answers into the overlay and remote activity into its idle timer. */
289
+ attach: (answer: (value: QuestionAnswer) => void, touch: () => void) => void
290
+ /** Ends the offer; the responder's settle handle and touch are rejected from then on. */
291
+ close: () => void
292
+ }
293
+ type RemoteOffer = { kind: 'settled'; value: QuestionAnswer } | { kind: 'claimed'; remote: RemoteQuestion }
294
+
295
+ function validIndices(indices: unknown, count: number, multiSelect: boolean): indices is number[] {
296
+ if (!Array.isArray(indices) || (!multiSelect && indices.length !== 1)) return false
297
+ return new Set(indices).size === indices.length && indices.every((index) => Number.isInteger(index) && index >= 1 && index <= count)
298
+ }
299
+
300
+ /** A remote outcome as the answer it stands for: null is a cancel, undefined is a
301
+ * pass or anything this question cannot accept, which must never become an answer. */
302
+ function remoteAnswer(outcome: RemoteQuestionOutcome, options: DisplayOption[], multiSelect: boolean): QuestionAnswer | undefined {
303
+ if (outcome === null || typeof outcome !== 'object') return undefined
304
+ if (outcome.action === 'cancel') return null
305
+ if (outcome.action === 'text') {
306
+ const text = typeof outcome.text === 'string' ? outcome.text.trim() : ''
307
+ return !multiSelect && text ? { answer: text, wasCustom: true } : undefined
308
+ }
309
+ if (outcome.action !== 'answer') return undefined
310
+ // Read once and copied, so a getter or Proxy cannot answer with other indices
311
+ // than the ones that passed validation.
312
+ const raw: unknown = outcome.indices
313
+ const indices: unknown = Array.isArray(raw) ? [...raw] : undefined
314
+ if (!validIndices(indices, options.length, multiSelect)) return undefined
315
+ const checked = options.map((_, index) => indices.includes(index + 1))
316
+ return { answer: selectedLabels(options, checked), wasCustom: false, ...(multiSelect ? {} : { index: indices[0] }) }
317
+ }
318
+
319
+ /** Undefined when nobody claims synchronously, so the overlay opens with no async gap. */
320
+ function offerRemoteQuestion(params: QuestionSpec, allOptions: DisplayOption[], ctx: ExtensionContext, events: ExtensionAPI['events'] | undefined, signal: AbortSignal | undefined): RemoteOffer | undefined {
321
+ if (!events) return undefined
322
+ if (signal?.aborted) return { kind: 'settled', value: null }
323
+
324
+ const multiSelect = params.multiSelect === true
325
+ const controller = new AbortController()
326
+ let claimed = false
327
+ let early: { value: QuestionAnswer } | undefined
328
+ let deliver: ((value: QuestionAnswer) => void) | undefined
329
+ let touchOverlay: (() => void) | undefined
330
+ const close = (): void => {
331
+ controller.abort()
332
+ signal?.removeEventListener('abort', abort)
333
+ }
334
+ const settle = (value: QuestionAnswer): void => {
335
+ close()
336
+ if (deliver) deliver(value)
337
+ else early = { value }
338
+ }
339
+ // Pi aborts the turn before a session replacement invalidates ctx, so this is
340
+ // also the session-replacement path; ctx is never read after emission.
341
+ const abort = (): void => settle(null)
342
+ signal?.addEventListener('abort', abort, { once: true })
343
+
344
+ const offer: RemoteQuestionOffer = {
345
+ version: 1,
346
+ requestId: randomUUID(),
347
+ sessionId: ctx.sessionManager.getSessionId(),
348
+ question: params.question,
349
+ header: shortHeader(params.header),
350
+ options: params.options.map(({ label, description }) => ({ label, ...(description === undefined ? {} : { description }) })),
351
+ multiSelect,
352
+ allowFreeText: allOptions.some((option) => option.isOther === true),
353
+ signal: controller.signal,
354
+ claim: () => {
355
+ if (claimed || controller.signal.aborted) return undefined
356
+ claimed = true
357
+ return (outcome) => {
358
+ if (controller.signal.aborted) return false
359
+ const value = remoteAnswer(outcome, params.options, multiSelect)
360
+ if (value !== undefined) {
361
+ settle(value)
362
+ return true
363
+ }
364
+ // A pass or invalid reply only withdraws the remote side; the overlay stays
365
+ // open, and a turn abort must still cancel it.
366
+ controller.abort()
367
+ return outcome?.action === 'pass'
368
+ }
369
+ },
370
+ touch: () => {
371
+ if (!claimed || controller.signal.aborted) return false
372
+ touchOverlay?.()
373
+ return true
374
+ },
375
+ }
376
+
377
+ events.emit(REMOTE_QUESTION_CHANNEL, offer)
378
+ if (early) return { kind: 'settled', value: early.value }
379
+ if (!claimed || controller.signal.aborted) {
380
+ close()
381
+ return undefined
382
+ }
383
+ return {
384
+ kind: 'claimed',
385
+ remote: {
386
+ attach: (answer, touch) => {
387
+ deliver = answer
388
+ touchOverlay = touch
389
+ },
390
+ close,
391
+ },
392
+ }
393
+ }
394
+
395
+ /** ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
396
+ * undefined immediately, which would read as a cancel without ever asking. Ask
397
+ * through the dialog primitives there instead. askUserQuestionTimeout is a TUI
398
+ * concept (a countdown, a keypress resetting it): the dialog-primitive fallback
399
+ * has no keyboard or visible countdown to drive it, so it is not applied there. */
400
+ async function collectAnswer(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<QuestionAnswer> {
401
+ if (ctx.mode !== 'tui') return askViaDialogs(params, ctx, allOptions, multiSelect)
402
+ const offer = offerRemoteQuestion(params, allOptions, ctx, events, signal)
403
+ if (offer?.kind === 'settled') return offer.value
404
+ const remote = offer?.remote
405
+ try {
406
+ return await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs(), remote)
407
+ } finally {
408
+ remote?.close()
409
+ }
410
+ }
411
+
412
+ async function askOne(params: QuestionSpec, ctx: ExtensionContext, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
262
413
  if (!ctx.hasUI) {
263
414
  return {
264
415
  content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
@@ -281,12 +432,7 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
281
432
  // The free-text option does not compose with checkbox selection, so it is single-select only.
282
433
  const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
283
434
 
284
- // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
285
- // undefined immediately, which would read as a cancel without ever asking. Ask
286
- // through the dialog primitives there instead. askUserQuestionTimeout is a TUI
287
- // concept (a countdown, a keypress resetting it): the dialog-primitive fallback
288
- // has no keyboard or visible countdown to drive it, so it is not applied there.
289
- const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
435
+ const result = await collectAnswer(params, ctx, allOptions, multiSelect, events, signal)
290
436
 
291
437
  // Build simple options list for details; header/multiSelect appear only when set,
292
438
  // so single-select details are unchanged.
@@ -345,7 +491,7 @@ const IDLE_TICK_MS = 250
345
491
  * mechanics are testable directly, independent of where timeoutMs itself is read
346
492
  * from (askUserQuestionTimeoutMs, tested separately).
347
493
  */
348
- export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
494
+ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number, remote?: RemoteQuestion): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
349
495
  return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
350
496
  let optionIndex = 0
351
497
  let editMode = false
@@ -368,6 +514,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
368
514
  * here, so the interval can never outlive the overlay it belongs to. */
369
515
  function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
370
516
  stopIdleTimer()
517
+ // Closed before done, so a remote reply racing a local answer is rejected.
518
+ remote?.close()
371
519
  done(value)
372
520
  }
373
521
 
@@ -376,6 +524,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
376
524
  deadline = Date.now() + timeoutMs
377
525
  }
378
526
 
527
+ remote?.attach(finish, resetIdleTimer)
528
+
379
529
  function fireTimeout(): void {
380
530
  // Claude: "submits any options you'd already selected". Single-select has
381
531
  // nothing pre-committed (a selection only exists once Enter confirms it), so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.77",
3
+ "version": "1.2.0",
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",