pi-code 1.0.2 → 1.0.4
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/commands.ts +10 -3
- package/extensions/context-imports.ts +5 -2
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +76 -20
- package/extensions/internal/command-file.ts +102 -23
- package/extensions/internal/output-guard.ts +12 -1
- package/extensions/internal/project-approval.ts +18 -1
- package/extensions/mcp.ts +90 -19
- package/extensions/memory.ts +39 -7
- package/extensions/notify.ts +1 -1
- package/extensions/plan-mode/index.ts +78 -11
- package/extensions/status-line.ts +4 -1
- package/extensions/subagent/agents.ts +10 -21
- package/extensions/subagent/background.ts +106 -22
- package/extensions/subagent/index.ts +39 -28
- package/extensions/todo.ts +2 -2
- package/extensions/web.ts +7 -1
- package/package.json +1 -1
package/extensions/memory.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
|
|
|
93
93
|
}
|
|
94
94
|
fs.mkdirSync(dir, { recursive: true })
|
|
95
95
|
fs.writeFileSync(path.join(dir, `${name}.md`), content)
|
|
96
|
-
|
|
96
|
+
writeIndex(indexPath, upsertIndexLine(index, name, description))
|
|
97
97
|
return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -150,25 +150,45 @@ const MemoryParams = Type.Object({
|
|
|
150
150
|
function readIndex(dir: string): string {
|
|
151
151
|
try {
|
|
152
152
|
return fs.readFileSync(path.join(dir, INDEX_FILE), 'utf-8')
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Only a missing file means an empty index. Treating any other failure as empty
|
|
155
|
+
// lets the next read-modify-write clobber every existing entry.
|
|
156
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** For display paths, where a transiently unreadable index should not break the
|
|
162
|
+
* session; the mutating paths go through readIndex and refuse instead. */
|
|
163
|
+
function readIndexQuietly(dir: string): string {
|
|
164
|
+
try {
|
|
165
|
+
return readIndex(dir)
|
|
153
166
|
} catch {
|
|
154
167
|
return ''
|
|
155
168
|
}
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
/** Replace the index through a rename so a crash mid-write cannot truncate it. */
|
|
172
|
+
function writeIndex(indexPath: string, content: string): void {
|
|
173
|
+
const tmp = `${indexPath}.${process.pid}.tmp`
|
|
174
|
+
fs.writeFileSync(tmp, content)
|
|
175
|
+
fs.renameSync(tmp, indexPath)
|
|
176
|
+
}
|
|
177
|
+
|
|
158
178
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
159
179
|
let dir = memoryDir(process.cwd())
|
|
160
180
|
|
|
161
181
|
pi.on('session_start', async (_event, ctx) => {
|
|
162
182
|
migrateLegacyStore(ctx.cwd)
|
|
163
183
|
dir = memoryDir(ctx.cwd)
|
|
164
|
-
const count =
|
|
184
|
+
const count = readIndexQuietly(dir)
|
|
165
185
|
.split('\n')
|
|
166
186
|
.filter((l) => l.startsWith('- ')).length
|
|
167
187
|
if (count > 0) ctx.ui.notify(`Memory: ${count} memories loaded`, 'info')
|
|
168
188
|
})
|
|
169
189
|
|
|
170
190
|
pi.on('before_agent_start', async (event) => {
|
|
171
|
-
const index =
|
|
191
|
+
const index = readIndexQuietly(dir)
|
|
172
192
|
if (!index.trim()) return
|
|
173
193
|
return {
|
|
174
194
|
systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
|
|
@@ -185,7 +205,11 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
185
205
|
const indexPath = path.join(dir, INDEX_FILE)
|
|
186
206
|
|
|
187
207
|
if (params.action === 'save') {
|
|
188
|
-
|
|
208
|
+
try {
|
|
209
|
+
return saveMemory(dir, indexPath, name, params.description, params.content)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
|
|
212
|
+
}
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
if (params.action === 'read') {
|
|
@@ -200,14 +224,22 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
200
224
|
|
|
201
225
|
if (params.action === 'delete') {
|
|
202
226
|
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
227
|
+
// The index is read before anything is removed: refusing on a failed read
|
|
228
|
+
// must leave both the memory file and the index as they were.
|
|
229
|
+
let index: string
|
|
230
|
+
try {
|
|
231
|
+
index = readIndex(dir)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
234
|
+
}
|
|
203
235
|
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
204
|
-
const remaining = removeIndexLine(
|
|
205
|
-
if (remaining)
|
|
236
|
+
const remaining = removeIndexLine(index, name)
|
|
237
|
+
if (remaining) writeIndex(indexPath, remaining)
|
|
206
238
|
else fs.rmSync(indexPath, { force: true })
|
|
207
239
|
return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
const index =
|
|
242
|
+
const index = readIndexQuietly(dir)
|
|
211
243
|
return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
|
|
212
244
|
},
|
|
213
245
|
})
|
package/extensions/notify.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - Windows toast: Windows Terminal (WSL)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
11
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
12
13
|
|
|
13
14
|
function windowsToastScript(title: string, body: string): string {
|
|
@@ -29,7 +30,6 @@ function notifyOSC99(title: string, body: string): void {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function notifyWindows(title: string, body: string): void {
|
|
32
|
-
const { execFile } = require('node:child_process')
|
|
33
33
|
// Resolve powershell from a fixed system path rather than through PATH, and let the callback
|
|
34
34
|
// capture a spawn failure instead of an unhandled 'error' event crashing the host.
|
|
35
35
|
const root = process.env.SystemRoot ?? String.raw`C:\Windows`
|
|
@@ -24,6 +24,10 @@ import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type
|
|
|
24
24
|
// Tools
|
|
25
25
|
const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
|
|
26
26
|
|
|
27
|
+
/** Agent runs in execution mode with no [DONE:n] progress before execution ends on
|
|
28
|
+
* its own. Kept small: each stalled run re-injects the stale plan into the turn. */
|
|
29
|
+
const STALLED_RUN_LIMIT = 2
|
|
30
|
+
|
|
27
31
|
// Type guard for assistant messages
|
|
28
32
|
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
|
29
33
|
return m.role === 'assistant' && Array.isArray(m.content)
|
|
@@ -60,6 +64,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
60
64
|
let todoItems: TodoItem[] = []
|
|
61
65
|
let planFromTool = false
|
|
62
66
|
let savedTools: string[] = []
|
|
67
|
+
let stalledRuns = 0
|
|
68
|
+
let runProgress = false
|
|
63
69
|
|
|
64
70
|
function enterPlanTools(): void {
|
|
65
71
|
savedTools = pi.getActiveTools()
|
|
@@ -113,6 +119,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
113
119
|
executionMode = false
|
|
114
120
|
todoItems = []
|
|
115
121
|
planFromTool = false
|
|
122
|
+
stalledRuns = 0
|
|
123
|
+
runProgress = false
|
|
116
124
|
|
|
117
125
|
if (planModeEnabled) {
|
|
118
126
|
enterPlanTools()
|
|
@@ -132,14 +140,16 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
132
140
|
enabled: planModeEnabled,
|
|
133
141
|
todos: todoItems,
|
|
134
142
|
executing: executionMode,
|
|
143
|
+
// The pre-plan tool set has to survive with the state that caused it to shrink.
|
|
144
|
+
// /reload rebuilds this extension with an empty snapshot while pi carries the
|
|
145
|
+
// restricted tools into the new runtime, so a restore has no way to work out
|
|
146
|
+
// what was active before plan mode unless it was written down here.
|
|
147
|
+
savedTools,
|
|
135
148
|
})
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!todoItems.every((t) => t.completed)) return
|
|
141
|
-
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
142
|
-
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
151
|
+
function endExecution(ctx: ExtensionContext, content: string): void {
|
|
152
|
+
pi.sendMessage({ customType: 'plan-complete', content, display: true }, { triggerTurn: false })
|
|
143
153
|
executionMode = false
|
|
144
154
|
todoItems = []
|
|
145
155
|
restoreTools()
|
|
@@ -147,6 +157,23 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
147
157
|
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
// Announce completion and reset once every step is done
|
|
161
|
+
function finalizeCompletedExecution(ctx: ExtensionContext): void {
|
|
162
|
+
if (!todoItems.every((t) => t.completed)) return
|
|
163
|
+
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
164
|
+
endExecution(ctx, `**Plan Complete!** ✓\n\n${completedList}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Models regularly drop or renumber a [DONE:n] marker; without a bounded exit the
|
|
168
|
+
* stale plan would be injected into every later turn until the user finds /plan. */
|
|
169
|
+
function endStalledExecution(ctx: ExtensionContext): void {
|
|
170
|
+
const remaining = todoItems
|
|
171
|
+
.filter((t) => !t.completed)
|
|
172
|
+
.map((t) => `${t.step}. ${t.text}`)
|
|
173
|
+
.join('\n')
|
|
174
|
+
endExecution(ctx, `**Plan execution ended** after ${STALLED_RUN_LIMIT} turns without step progress. Unfinished steps:\n\n${remaining}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
// Fall back to extracting a plan from the last assistant message's prose
|
|
151
178
|
function deriveTodosFromProse(messages: AgentMessage[]): void {
|
|
152
179
|
const lastAssistant = [...messages].reverse().find(isAssistantMessage)
|
|
@@ -165,6 +192,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
165
192
|
planModeEnabled = false
|
|
166
193
|
executionMode = todoItems.length > 0
|
|
167
194
|
planFromTool = false
|
|
195
|
+
stalledRuns = 0
|
|
196
|
+
runProgress = false
|
|
168
197
|
restoreTools()
|
|
169
198
|
publishPlanState()
|
|
170
199
|
updateStatus(ctx)
|
|
@@ -236,9 +265,19 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
236
265
|
handler: async (ctx) => togglePlanMode(ctx),
|
|
237
266
|
})
|
|
238
267
|
|
|
239
|
-
//
|
|
268
|
+
// Enforce plan mode at call time, not only through the active-tool set: pi
|
|
269
|
+
// activates tools registered after the restriction was applied (an MCP server
|
|
270
|
+
// connecting during session_start, or a mid-session list_changed refresh), so the
|
|
271
|
+
// set alone leaks write-capable tools into plan mode.
|
|
240
272
|
pi.on('tool_call', async (event) => {
|
|
241
|
-
if (!planModeEnabled
|
|
273
|
+
if (!planModeEnabled) return
|
|
274
|
+
if (!PLAN_MODE_TOOLS.includes(event.toolName)) {
|
|
275
|
+
return {
|
|
276
|
+
block: true,
|
|
277
|
+
reason: `Plan mode: tool blocked (read-only mode). Use /plan to disable plan mode first.\nTool: ${event.toolName}`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (event.toolName !== 'bash') return
|
|
242
281
|
|
|
243
282
|
const command = event.input.command as string
|
|
244
283
|
if (!isSafeCommand(command)) {
|
|
@@ -327,6 +366,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
327
366
|
|
|
328
367
|
const text = getTextContent(event.message)
|
|
329
368
|
if (markCompletedSteps(text, todoItems) > 0) {
|
|
369
|
+
runProgress = true
|
|
330
370
|
updateStatus(ctx)
|
|
331
371
|
}
|
|
332
372
|
persistState()
|
|
@@ -334,9 +374,18 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
334
374
|
|
|
335
375
|
// Handle plan completion and plan mode UI
|
|
336
376
|
pi.on('agent_end', async (event, ctx) => {
|
|
337
|
-
// Check if execution is complete
|
|
377
|
+
// Check if execution is complete, or has stalled without marker progress
|
|
338
378
|
if (executionMode && todoItems.length > 0) {
|
|
339
|
-
|
|
379
|
+
if (todoItems.every((t) => t.completed)) {
|
|
380
|
+
finalizeCompletedExecution(ctx)
|
|
381
|
+
stalledRuns = 0
|
|
382
|
+
} else if (runProgress) {
|
|
383
|
+
stalledRuns = 0
|
|
384
|
+
} else {
|
|
385
|
+
stalledRuns++
|
|
386
|
+
if (stalledRuns >= STALLED_RUN_LIMIT) endStalledExecution(ctx)
|
|
387
|
+
}
|
|
388
|
+
runProgress = false
|
|
340
389
|
return
|
|
341
390
|
}
|
|
342
391
|
|
|
@@ -371,6 +420,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
371
420
|
executionMode = false
|
|
372
421
|
todoItems = []
|
|
373
422
|
planFromTool = false
|
|
423
|
+
stalledRuns = 0
|
|
424
|
+
runProgress = false
|
|
374
425
|
|
|
375
426
|
if (pi.getFlag('plan') === true) {
|
|
376
427
|
planModeEnabled = true
|
|
@@ -379,7 +430,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
379
430
|
const entries = ctx.sessionManager.getEntries()
|
|
380
431
|
|
|
381
432
|
// Restore persisted state
|
|
382
|
-
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
|
|
433
|
+
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
|
|
383
434
|
|
|
384
435
|
if (planModeEntry?.data) {
|
|
385
436
|
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
|
|
@@ -396,7 +447,23 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
396
447
|
}
|
|
397
448
|
|
|
398
449
|
if (planModeEnabled) {
|
|
399
|
-
|
|
450
|
+
// Restoring into plan mode is the only case the recorded snapshot is for.
|
|
451
|
+
// Re-reading the active set here would capture the restriction pi carried
|
|
452
|
+
// across /reload and cost the session edit and write for good; applying the
|
|
453
|
+
// snapshot when plan mode is off would instead push a stale set over whatever
|
|
454
|
+
// pi has registered since, so it stays scoped to this branch.
|
|
455
|
+
savedTools = planModeEntry?.data?.savedTools ?? pi.getActiveTools()
|
|
456
|
+
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
457
|
+
// --plan enters plan mode without ever toggling, so nothing has persisted yet
|
|
458
|
+
// and a /reload would find no snapshot to restore from. Record it now, while
|
|
459
|
+
// the active set still says what was there before the restriction.
|
|
460
|
+
//
|
|
461
|
+
// Only with no entry at all: an entry written before this field existed means
|
|
462
|
+
// the active set has already been through a restore and may be the restriction
|
|
463
|
+
// itself. Those tools are lost for this process either way, but persisting a
|
|
464
|
+
// guess would write the loss into the session file, where a later resume would
|
|
465
|
+
// inherit it instead of starting over.
|
|
466
|
+
if (!planModeEntry) persistState()
|
|
400
467
|
} else {
|
|
401
468
|
// A prior session in this instance may have shrunk the tool set; undo that when
|
|
402
469
|
// the restored/fresh state is not plan mode.
|
|
@@ -104,6 +104,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
104
104
|
/** The stdin payload per Claude's documented statusline contract. */
|
|
105
105
|
function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
|
|
106
106
|
const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
|
|
107
|
+
const model = ctx.model as { id?: string; name?: string } | undefined
|
|
107
108
|
// Same gate as the config read above: an unapproved project's style is not applied,
|
|
108
109
|
// so reporting it here would describe a style the session is not using.
|
|
109
110
|
const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
|
|
@@ -111,7 +112,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
111
112
|
session_id: ctx.sessionManager.getSessionId(),
|
|
112
113
|
cwd: ctx.cwd,
|
|
113
114
|
workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
|
|
114
|
-
|
|
115
|
+
// Both fields, per Claude's documented contract: published statusline scripts
|
|
116
|
+
// read .model.display_name and render the literal "null" when it is missing.
|
|
117
|
+
model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
|
|
115
118
|
cost: { total_cost_usd: sessionCost(ctx) },
|
|
116
119
|
context_window: { context_window_size: usage.contextWindow, used_percentage: usage.percent, total_input_tokens: usage.tokens },
|
|
117
120
|
permission_mode: permissionMode,
|
|
@@ -7,21 +7,10 @@ import * as os from 'node:os'
|
|
|
7
7
|
import * as path from 'node:path'
|
|
8
8
|
import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
edit: 'edit',
|
|
15
|
-
bash: 'bash',
|
|
16
|
-
grep: 'grep',
|
|
17
|
-
glob: 'find',
|
|
18
|
-
ls: 'ls',
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeToolName(tool: string): string {
|
|
22
|
-
const lower = tool.toLowerCase()
|
|
23
|
-
return CLAUDE_TOOL_MAP[lower] ?? lower
|
|
24
|
-
}
|
|
10
|
+
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
11
|
+
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
|
+
// is not merely ignored, it narrows the child's registry.
|
|
13
|
+
import { parseToolList } from '../internal/command-file.js'
|
|
25
14
|
|
|
26
15
|
/**
|
|
27
16
|
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
@@ -30,12 +19,12 @@ function normalizeToolName(tool: string): string {
|
|
|
30
19
|
*/
|
|
31
20
|
function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
32
21
|
if (raw === undefined) return undefined
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
// Shares the command parser's splitting, so a comma inside an argument scope stays
|
|
23
|
+
// inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
|
|
24
|
+
if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
|
|
25
|
+
if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
|
|
26
|
+
const tools = parseToolList(raw)
|
|
27
|
+
if (!tools) return null
|
|
39
28
|
return tools.length > 0 ? tools : undefined
|
|
40
29
|
}
|
|
41
30
|
|
|
@@ -19,8 +19,13 @@ export interface BackgroundRun {
|
|
|
19
19
|
exitCode?: number
|
|
20
20
|
output?: string
|
|
21
21
|
turns: number
|
|
22
|
+
/** Last stderr bytes of a failed child; the only diagnostics a boot failure leaves. */
|
|
23
|
+
stderr?: string
|
|
22
24
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
23
25
|
kill?: () => void
|
|
26
|
+
/** True until the child process actually closes: a cancelled child that ignores
|
|
27
|
+
* SIGTERM is still alive and must keep holding its concurrency slot. */
|
|
28
|
+
live?: boolean
|
|
24
29
|
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
25
30
|
sessionId: string
|
|
26
31
|
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
@@ -42,30 +47,66 @@ const runs = new Map<string, BackgroundRun>()
|
|
|
42
47
|
/** Cap on simultaneously running background children. */
|
|
43
48
|
export const MAX_BACKGROUND_RUNS = 8
|
|
44
49
|
|
|
50
|
+
/** Finished runs kept for status listings and resume; older ones are evicted so a
|
|
51
|
+
* long session's registry (each entry holds its final output) cannot grow forever. */
|
|
52
|
+
export const MAX_FINISHED_RUNS = 20
|
|
53
|
+
|
|
54
|
+
/** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
|
|
55
|
+
const CANCEL_KILL_GRACE_MS = 5000
|
|
56
|
+
|
|
57
|
+
/** Bytes of stderr kept per run, enough for the boot error without buffering logs. */
|
|
58
|
+
const STDERR_TAIL_CHARS = 2048
|
|
59
|
+
|
|
45
60
|
export function activeBackgroundRuns(): number {
|
|
46
|
-
return [...runs.values()].filter((run) => run.state === 'running').length
|
|
61
|
+
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
function evictFinishedRuns(): void {
|
|
65
|
+
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
66
|
+
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
70
|
+
* long run's JSONL stdout never accumulates whole in the parent's memory. */
|
|
71
|
+
export function createJsonlOutputParser(): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
|
|
72
|
+
let buffer = ''
|
|
51
73
|
let text = ''
|
|
52
74
|
let turns = 0
|
|
53
|
-
|
|
54
|
-
if (!
|
|
75
|
+
const takeLine = (raw: string): void => {
|
|
76
|
+
if (!raw.trim()) return
|
|
55
77
|
let event: { type?: string; message?: { role?: string; content?: Array<{ type: string; text?: string }> } }
|
|
56
78
|
try {
|
|
57
|
-
event = JSON.parse(
|
|
79
|
+
event = JSON.parse(raw)
|
|
58
80
|
} catch {
|
|
59
|
-
|
|
81
|
+
return
|
|
60
82
|
}
|
|
61
|
-
if (event.type !== 'message_end' || event.message?.role !== 'assistant')
|
|
83
|
+
if (event.type !== 'message_end' || event.message?.role !== 'assistant') return
|
|
62
84
|
turns++
|
|
63
85
|
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
64
86
|
// foreground path so a multi-part message reads the same in both.
|
|
65
87
|
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
66
88
|
if (parts.length > 0) text = parts.join('\n')
|
|
67
89
|
}
|
|
68
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
push(chunk) {
|
|
92
|
+
buffer += chunk
|
|
93
|
+
const lines = buffer.split('\n')
|
|
94
|
+
buffer = lines.pop() ?? ''
|
|
95
|
+
for (const line of lines) takeLine(line)
|
|
96
|
+
},
|
|
97
|
+
flush() {
|
|
98
|
+
takeLine(buffer)
|
|
99
|
+
buffer = ''
|
|
100
|
+
return { text, turns }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
|
|
106
|
+
export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
|
|
107
|
+
const parser = createJsonlOutputParser()
|
|
108
|
+
parser.push(jsonl)
|
|
109
|
+
return parser.flush()
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
export function formatStatus(all: Iterable<Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'exitCode'>>): string {
|
|
@@ -100,15 +141,22 @@ export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
|
100
141
|
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
101
142
|
* same --session-id, so it continues with everything it already saw rather than
|
|
102
143
|
* re-deriving context the parent would have to repeat. */
|
|
103
|
-
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'unknown' {
|
|
144
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'at-capacity' | 'unknown' {
|
|
104
145
|
const run = runs.get(id)
|
|
105
146
|
if (!run) return 'unknown'
|
|
106
|
-
if (run.state === 'running') return 'still-running'
|
|
107
|
-
|
|
147
|
+
if (run.state === 'running' || run.live) return 'still-running'
|
|
148
|
+
// A resume spawns a child like a fresh start does, so it counts against the cap.
|
|
149
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return 'at-capacity'
|
|
150
|
+
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
151
|
+
// prompt dir every follow-up.
|
|
152
|
+
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
153
|
+
run.spawn = { ...run.spawn, args: rebuilt }
|
|
154
|
+
const args = rebuilt.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
108
155
|
run.state = 'running'
|
|
109
156
|
run.task = task
|
|
110
157
|
run.output = undefined
|
|
111
158
|
run.exitCode = undefined
|
|
159
|
+
run.stderr = undefined
|
|
112
160
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
113
161
|
return 'resumed'
|
|
114
162
|
}
|
|
@@ -156,44 +204,80 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
156
204
|
const proc = spawn(invocation.command, invocation.args, {
|
|
157
205
|
cwd: invocation.cwd,
|
|
158
206
|
shell: false,
|
|
159
|
-
stdio: ['ignore', 'pipe', '
|
|
207
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
160
208
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
161
209
|
detached: true,
|
|
162
210
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
163
211
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
164
212
|
})
|
|
165
|
-
run.
|
|
213
|
+
run.live = true
|
|
214
|
+
const killGroup = (signal: NodeJS.Signals): void => {
|
|
166
215
|
try {
|
|
167
|
-
process.kill(-proc.pid!,
|
|
216
|
+
process.kill(-proc.pid!, signal)
|
|
168
217
|
} catch {
|
|
169
|
-
|
|
218
|
+
try {
|
|
219
|
+
proc.kill(signal)
|
|
220
|
+
} catch {
|
|
221
|
+
// already gone
|
|
222
|
+
}
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
|
-
|
|
225
|
+
run.kill = () => {
|
|
226
|
+
killGroup('SIGTERM')
|
|
227
|
+
// A child ignoring SIGTERM would hold its cap slot and process forever.
|
|
228
|
+
const escalate = setTimeout(() => killGroup('SIGKILL'), CANCEL_KILL_GRACE_MS)
|
|
229
|
+
escalate.unref()
|
|
230
|
+
proc.once('close', () => clearTimeout(escalate))
|
|
231
|
+
}
|
|
232
|
+
// Parsed as it streams: buffering the whole JSONL replays every tool result echoed
|
|
233
|
+
// by the child through the parent's memory for the life of the run.
|
|
234
|
+
const parser = createJsonlOutputParser()
|
|
235
|
+
let stderrTail = ''
|
|
173
236
|
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
174
237
|
let completed = false
|
|
175
238
|
const complete = (): void => {
|
|
176
239
|
if (completed) return
|
|
177
240
|
completed = true
|
|
178
|
-
|
|
241
|
+
evictFinishedRuns()
|
|
242
|
+
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
243
|
+
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
244
|
+
// the child's 'close'/'error' listener, where nothing upstream catches: an escaping
|
|
245
|
+
// error reaches Node as an uncaughtException and takes pi down with it. The run
|
|
246
|
+
// state is already recorded by this point, so there is nothing to do but drop the
|
|
247
|
+
// notification for a session that is no longer there to receive it.
|
|
248
|
+
try {
|
|
249
|
+
onComplete(run)
|
|
250
|
+
} catch {
|
|
251
|
+
// the session that asked for this run is gone
|
|
252
|
+
}
|
|
179
253
|
}
|
|
180
|
-
proc.stdout.on('data', (data) =>
|
|
181
|
-
|
|
254
|
+
proc.stdout.on('data', (data) => parser.push(data.toString()))
|
|
255
|
+
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
256
|
+
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
257
|
+
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
258
|
+
proc.stdout.on('error', () => {})
|
|
259
|
+
proc.stderr?.on('data', (data) => {
|
|
260
|
+
stderrTail = (stderrTail + data.toString()).slice(-STDERR_TAIL_CHARS)
|
|
182
261
|
})
|
|
262
|
+
proc.stderr?.on('error', () => {})
|
|
183
263
|
proc.on('close', (code) => {
|
|
184
|
-
const { text, turns } =
|
|
264
|
+
const { text, turns } = parser.flush()
|
|
185
265
|
run.kill = undefined
|
|
266
|
+
run.live = false
|
|
186
267
|
// A cancelled run keeps that state: its non-zero exit is the cancellation.
|
|
187
268
|
if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
|
|
188
269
|
run.exitCode = code ?? 0
|
|
189
270
|
run.output = text
|
|
190
271
|
run.turns = turns
|
|
272
|
+
run.stderr = stderrTail.trim() || undefined
|
|
191
273
|
complete()
|
|
192
274
|
})
|
|
193
|
-
proc.on('error', () => {
|
|
275
|
+
proc.on('error', (error) => {
|
|
194
276
|
run.kill = undefined
|
|
277
|
+
run.live = false
|
|
195
278
|
run.state = 'failed'
|
|
196
279
|
run.exitCode = 1
|
|
280
|
+
run.stderr = stderrTail.trim() || error.message
|
|
197
281
|
complete()
|
|
198
282
|
})
|
|
199
283
|
}
|