pi-code 1.0.48 → 1.0.49
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/extensions/claude-rules.ts +2 -1
- package/extensions/commands.ts +2 -2
- package/extensions/context-imports.ts +13 -12
- package/extensions/git-checkpoint.ts +26 -0
- package/extensions/internal/command-file.ts +22 -11
- package/extensions/internal/output-guard.ts +6 -5
- package/extensions/internal/strip-comments.ts +3 -3
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +1 -1
- package/extensions/subagent/background.ts +33 -10
- package/extensions/todo.ts +6 -2
- package/package.json +1 -1
|
@@ -68,7 +68,8 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
68
68
|
|
|
69
69
|
/** Split YAML-ish frontmatter off the front of a rule file, extracting `paths`. */
|
|
70
70
|
export function parseFrontmatter(content: string): Frontmatter {
|
|
71
|
-
|
|
71
|
+
// A leading byte order mark (Windows editors add one) is part of the header, not the body.
|
|
72
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
|
|
72
73
|
if (!match) return { paths: [], body: content }
|
|
73
74
|
return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
|
|
74
75
|
}
|
package/extensions/commands.ts
CHANGED
|
@@ -197,7 +197,7 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
|
|
|
197
197
|
|
|
198
198
|
/** The exec seam expandCommand runs spans through; pi itself satisfies it. */
|
|
199
199
|
interface SpanRunner {
|
|
200
|
-
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }>
|
|
200
|
+
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -231,7 +231,7 @@ export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, a
|
|
|
231
231
|
// pwsh cannot merge a native command's stderr in-script (spanExec sets
|
|
232
232
|
// mergeStreams), so it is appended here; the sh script merges via 2>&1.
|
|
233
233
|
const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
|
|
234
|
-
return { stdout, stderr: result.stderr, code: result.code }
|
|
234
|
+
return { stdout, stderr: result.stderr, code: result.code, killed: result.killed }
|
|
235
235
|
}
|
|
236
236
|
: async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
|
|
237
237
|
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec, parsed.shell === 'powershell' ? 'powershell' : 'bash')
|
|
@@ -72,12 +72,13 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
72
72
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
73
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
74
74
|
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
75
|
+
import { sliceBytes } from './internal/output-guard.js'
|
|
75
76
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
76
77
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
77
78
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
78
79
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
79
80
|
import { statToken } from './internal/stat-token.js'
|
|
80
|
-
import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
|
|
81
|
+
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
81
82
|
|
|
82
83
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
83
84
|
const MAX_IMPORT_DEPTH = 4
|
|
@@ -145,16 +146,14 @@ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES
|
|
|
145
146
|
* imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
|
|
146
147
|
function importTargets(content: string): string[] {
|
|
147
148
|
const targets: string[] = []
|
|
148
|
-
//
|
|
149
|
-
// example may
|
|
150
|
-
let fence:
|
|
149
|
+
// CommonMark fences: closed only by the same character in a run at least as long
|
|
150
|
+
// as the opener, so a backtick example may hold tilde lines or shorter fences.
|
|
151
|
+
let fence: Fence | null = null
|
|
151
152
|
for (const line of content.split('\n')) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
if (fence !== null) continue
|
|
153
|
+
const trimmed = line.trimStart()
|
|
154
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
155
|
+
fence = step.fence
|
|
156
|
+
if (step.fenced) continue
|
|
158
157
|
// Backreference so a multi-backtick span (``literal `@x` backticks``) strips whole.
|
|
159
158
|
const withoutSpans = line.replace(/(`+)[^`]*?\1/g, '')
|
|
160
159
|
for (const match of withoutSpans.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
|
|
@@ -222,8 +221,10 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
|
|
|
222
221
|
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
223
222
|
if (!file) continue
|
|
224
223
|
scan.budget.files -= 1
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
// The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
|
|
225
|
+
// at three times the budget without ever reaching the truncation marker.
|
|
226
|
+
const kept = sliceBytes(file.body, scan.budget.bytes)
|
|
227
|
+
scan.budget.bytes -= Buffer.byteLength(kept)
|
|
227
228
|
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
228
229
|
// Comments are stripped before the scan for further imports, so a
|
|
229
230
|
// commented-out @import stays dead at every depth, matching the top level
|
|
@@ -194,6 +194,32 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
194
194
|
}
|
|
195
195
|
// Written on every start, so repos that predate the sidecar pick it up too.
|
|
196
196
|
rememberWorkTree(shadowDir, ctx.cwd)
|
|
197
|
+
await mirrorLocalExcludes(ctx.cwd)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
|
|
201
|
+
* and $GIT_DIR/info/exclude. The shadow is the GIT_DIR here, so the repo's own
|
|
202
|
+
* .git/info/exclude (where secrets and scratch that must never be committed live)
|
|
203
|
+
* would be snapshotted and restored. Mirror it into the shadow on every start; the
|
|
204
|
+
* global excludes stay untouched (core.excludesFile is single-valued, so pointing it
|
|
205
|
+
* at the repo file would replace them). */
|
|
206
|
+
async function mirrorLocalExcludes(cwd: string): Promise<void> {
|
|
207
|
+
if (!shadowDir) return
|
|
208
|
+
// Resolved through git so a linked worktree maps to its common dir; outside a repo
|
|
209
|
+
// git exits 128 and there is nothing to mirror.
|
|
210
|
+
const located = await pi.exec('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd })
|
|
211
|
+
const target = path.join(shadowDir, 'info', 'exclude')
|
|
212
|
+
try {
|
|
213
|
+
const source = located.code === 0 ? path.resolve(cwd, located.stdout.trim()) : undefined
|
|
214
|
+
if (source && fs.existsSync(source)) {
|
|
215
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
216
|
+
fs.copyFileSync(source, target)
|
|
217
|
+
} else {
|
|
218
|
+
fs.rmSync(target, { force: true })
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// A failed mirror only means local excludes are not honored this session.
|
|
222
|
+
}
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
|
@@ -13,8 +13,8 @@ import * as fs from 'node:fs'
|
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
15
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
16
|
-
|
|
17
16
|
import { splitSegments } from './shell-split.js'
|
|
17
|
+
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
18
18
|
|
|
19
19
|
/** The pi file tools a Claude path rule can govern. */
|
|
20
20
|
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
@@ -421,7 +421,7 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
|
|
|
421
421
|
return found
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
|
|
424
|
+
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
425
425
|
|
|
426
426
|
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
427
427
|
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
@@ -511,20 +511,27 @@ interface FenceBlock {
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
514
|
-
* fence, while a ```! fence is itself a placeholder that executes.
|
|
514
|
+
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
515
|
+
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
516
|
+
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
515
517
|
function fenceBlocks(body: string): FenceBlock[] {
|
|
516
518
|
const blocks: FenceBlock[] = []
|
|
517
|
-
|
|
519
|
+
let fence: Fence | null = null
|
|
518
520
|
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
519
|
-
let
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
521
|
+
let offset = 0
|
|
522
|
+
for (const line of body.split('\n')) {
|
|
523
|
+
const trimmed = line.trimStart()
|
|
524
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
525
|
+
const lineEnd = offset + line.length
|
|
526
|
+
if (fence === null && step.fence !== null) {
|
|
527
|
+
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
528
|
+
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
529
|
+
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
530
|
+
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
525
531
|
open = undefined
|
|
526
532
|
}
|
|
527
|
-
|
|
533
|
+
fence = step.fence
|
|
534
|
+
offset = lineEnd + 1
|
|
528
535
|
}
|
|
529
536
|
// An unterminated fence protects to the end of the body rather than executing.
|
|
530
537
|
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
@@ -563,6 +570,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
|
|
|
563
570
|
* documents: the model never sees a half-expanded body. */
|
|
564
571
|
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
565
572
|
const result = await exec(command)
|
|
573
|
+
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
574
|
+
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
575
|
+
// at the Bash timeout and that failure aborts the invocation.
|
|
576
|
+
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
566
577
|
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
567
578
|
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
568
579
|
}
|
|
@@ -15,12 +15,13 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
|
|
|
15
15
|
/**
|
|
16
16
|
* Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
|
|
17
17
|
* string by a byte budget keeps up to three times the bytes asked for; cutting the
|
|
18
|
-
* encoded buffer is exact. A character straddling the cut
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* encoded buffer is exact. A character straddling the cut is dropped (the streaming
|
|
19
|
+
* decoder holds back an incomplete sequence instead of emitting U+FFFD), so the
|
|
20
|
+
* result is whole characters within the budget. Shorter input comes back whole and a
|
|
21
|
+
* negative budget yields nothing, so callers need no length check of their own.
|
|
21
22
|
*/
|
|
22
|
-
function sliceBytes(text: string, maxBytes: number): string {
|
|
23
|
-
return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes))
|
|
23
|
+
export function sliceBytes(text: string, maxBytes: number): string {
|
|
24
|
+
return new TextDecoder().decode(Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)), { stream: true })
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* fenced code block (backtick or tilde).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/** The fence a line opens or closes, if any
|
|
14
|
+
/** The fence a line opens or closes, if any. */
|
|
15
15
|
export function fenceMarker(lineStart: string): string | null {
|
|
16
16
|
if (lineStart.startsWith('```')) return '`'
|
|
17
17
|
if (lineStart.startsWith('~~~')) return '~'
|
|
@@ -29,14 +29,14 @@ function fenceLength(lineStart: string, marker: string): number {
|
|
|
29
29
|
// is at least as long as the opener, so both are tracked: a shorter same-char
|
|
30
30
|
// fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
|
|
31
31
|
// content, not a closer.
|
|
32
|
-
interface Fence {
|
|
32
|
+
export interface Fence {
|
|
33
33
|
marker: string
|
|
34
34
|
length: number
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** The fence state after a line, plus whether the line is fenced code (opener,
|
|
38
38
|
* body, or closer) and so emitted verbatim rather than scanned for comments. */
|
|
39
|
-
function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
39
|
+
export function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
40
40
|
if (marker !== null && fence === null) {
|
|
41
41
|
return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
|
|
42
42
|
}
|
|
@@ -429,7 +429,9 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
429
429
|
planModeEnabled = true
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// The current branch only: getEntries() lists every branch in the file, so after a
|
|
433
|
+
// rewind past a plan it would resurrect the abandoned plan and its tool restriction.
|
|
434
|
+
const entries = ctx.sessionManager.getBranch()
|
|
433
435
|
|
|
434
436
|
// Restore persisted state
|
|
435
437
|
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
|
package/extensions/question.ts
CHANGED
|
@@ -179,7 +179,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
179
179
|
|
|
180
180
|
renderCall(args, theme, _context) {
|
|
181
181
|
const multi = args.multiSelect === true
|
|
182
|
-
const heading = args.header ? `[${args.header}] ` : ''
|
|
182
|
+
const heading = args.header ? `[${shortHeader(String(args.header))}] ` : ''
|
|
183
183
|
let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
|
|
184
184
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
185
185
|
if (opts.length) {
|
|
@@ -24,6 +24,8 @@ export interface BackgroundRun {
|
|
|
24
24
|
stderr?: string
|
|
25
25
|
/** Claude's partial marker: the run stopped at its maxTurns limit. */
|
|
26
26
|
partial?: boolean
|
|
27
|
+
/** Temp dir holding a prompt file rebuilt for a resume; removed with the run. */
|
|
28
|
+
rebuiltPromptDir?: string
|
|
27
29
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
28
30
|
kill?: () => void
|
|
29
31
|
/** True until the child process actually closes: a cancelled child that ignores
|
|
@@ -65,7 +67,7 @@ export const MAX_FINISHED_RUNS = 20
|
|
|
65
67
|
/** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
|
|
66
68
|
const CANCEL_KILL_GRACE_MS = 5000
|
|
67
69
|
|
|
68
|
-
/**
|
|
70
|
+
/** Characters of stderr kept per run, enough for the boot error without buffering logs. */
|
|
69
71
|
const STDERR_TAIL_CHARS = 2048
|
|
70
72
|
|
|
71
73
|
export function activeBackgroundRuns(): number {
|
|
@@ -79,6 +81,7 @@ let finishSequence = 0
|
|
|
79
81
|
/** Test seam: the registry is module state, so tests reset it between cases to
|
|
80
82
|
* stay order-independent. */
|
|
81
83
|
export function resetBackgroundRuns(): void {
|
|
84
|
+
for (const run of runs.values()) removeRebuiltPrompt(run)
|
|
82
85
|
runs.clear()
|
|
83
86
|
finishSequence = 0
|
|
84
87
|
}
|
|
@@ -86,7 +89,10 @@ export function resetBackgroundRuns(): void {
|
|
|
86
89
|
function evictFinishedRuns(): void {
|
|
87
90
|
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
88
91
|
finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
|
|
89
|
-
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS)))
|
|
92
|
+
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) {
|
|
93
|
+
removeRebuiltPrompt(stale)
|
|
94
|
+
runs.delete(stale.id)
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
@@ -162,6 +168,8 @@ export function cancelAllBackgroundRuns(): number {
|
|
|
162
168
|
for (const id of Array.from(runs.keys())) {
|
|
163
169
|
if (cancelBackgroundRun(id) === 'cancelled') count++
|
|
164
170
|
}
|
|
171
|
+
// Called at quit: nothing in this registry is resumable once pi exits.
|
|
172
|
+
for (const run of runs.values()) removeRebuiltPrompt(run)
|
|
165
173
|
return count
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -190,38 +198,53 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
190
198
|
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
191
199
|
// prompt dir every follow-up.
|
|
192
200
|
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
193
|
-
run.spawn = { ...run.spawn, args: rebuilt }
|
|
194
|
-
|
|
201
|
+
run.spawn = { ...run.spawn, args: rebuilt.args }
|
|
202
|
+
if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
|
|
203
|
+
const args = rebuilt.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
195
204
|
run.state = 'running'
|
|
196
205
|
run.task = task
|
|
197
206
|
run.output = undefined
|
|
198
207
|
run.exitCode = undefined
|
|
199
208
|
run.stderr = undefined
|
|
200
209
|
run.finishedAt = undefined
|
|
210
|
+
// Both belong to the child that ran: a resume that dies before its first turn would
|
|
211
|
+
// otherwise report the previous count, and a clean follow-up to a maxTurns-capped run
|
|
212
|
+
// would still be offered as partial.
|
|
213
|
+
run.turns = 0
|
|
214
|
+
run.partial = undefined
|
|
201
215
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
202
216
|
return 'resumed'
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
/** Re-point --system-prompt at a fresh file when the original is gone
|
|
206
|
-
|
|
219
|
+
/** Re-point --system-prompt at a fresh file when the original is gone; `dir` is the
|
|
220
|
+
* temp dir created for it, which the run then owns. */
|
|
221
|
+
function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?: string } {
|
|
207
222
|
const flag = spawnSpec.args.indexOf('--system-prompt')
|
|
208
|
-
if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
|
|
223
|
+
if (flag === -1 || !spawnSpec.promptBody) return { args: spawnSpec.args }
|
|
209
224
|
const current = spawnSpec.args[flag + 1]
|
|
210
|
-
if (current && fs.existsSync(current)) return spawnSpec.args
|
|
225
|
+
if (current && fs.existsSync(current)) return { args: spawnSpec.args }
|
|
211
226
|
try {
|
|
212
227
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
|
|
213
228
|
const file = path.join(dir, 'prompt.md')
|
|
214
229
|
fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
|
|
215
230
|
const rebuilt = [...spawnSpec.args]
|
|
216
231
|
rebuilt[flag + 1] = file
|
|
217
|
-
return rebuilt
|
|
232
|
+
return { args: rebuilt, dir }
|
|
218
233
|
} catch {
|
|
219
234
|
// Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
|
|
220
235
|
// prompt text, which would replace the agent persona with a temp path.
|
|
221
|
-
return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
|
|
236
|
+
return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
|
|
222
237
|
}
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
/** The rebuilt prompt lives as long as its run can be resumed, so it goes when the run
|
|
241
|
+
* leaves the registry: eviction, quit, or the test reset. */
|
|
242
|
+
function removeRebuiltPrompt(run: BackgroundRun): void {
|
|
243
|
+
if (!run.rebuiltPromptDir) return
|
|
244
|
+
fs.rmSync(run.rebuiltPromptDir, { recursive: true, force: true })
|
|
245
|
+
run.rebuiltPromptDir = undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
225
248
|
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
|
|
226
249
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
227
250
|
// between any check of their own and this call, so a parallel tool-call batch
|
package/extensions/todo.ts
CHANGED
|
@@ -387,8 +387,12 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
387
387
|
content: [{ type: 'text' as const, text }],
|
|
388
388
|
details,
|
|
389
389
|
})
|
|
390
|
-
|
|
391
|
-
|
|
390
|
+
// pi keeps a result's details by reference in the live session, and start/complete
|
|
391
|
+
// mutate the todo objects in place, so each result carries its own copies: without
|
|
392
|
+
// them a rewind replays, and the TUI re-renders, today's statuses on earlier calls.
|
|
393
|
+
const snapshot = () => todos.map((t) => ({ ...t }))
|
|
394
|
+
const ok = (action: TodoAction, text: string) => toolMessage(text, { action, todos: snapshot(), nextId })
|
|
395
|
+
const fail = (action: TodoAction, error: string) => toolMessage(`Error: ${error}`, { action, todos: snapshot(), nextId, error })
|
|
392
396
|
|
|
393
397
|
const handleAdd = (params: TodoParamsType) => {
|
|
394
398
|
if (!params.text) return fail('add', 'text required for add')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.49",
|
|
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",
|