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 +1 -1
- package/extensions/claude-rules.ts +119 -30
- package/extensions/context-imports.ts +149 -3
- package/extensions/internal/instruction-events.ts +10 -2
- package/extensions/question.ts +161 -11
- package/package.json +1 -1
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`
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
284
|
-
let
|
|
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:
|
|
322
|
-
...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
47
|
-
* 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/extensions/question.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|