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.
@@ -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
- fs.writeFileSync(indexPath, upsertIndexLine(index, name, description))
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 = readIndex(dir)
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 = readIndex(dir)
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
- return saveMemory(dir, indexPath, name, params.description, params.content)
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(readIndex(dir), name)
205
- if (remaining) fs.writeFileSync(indexPath, 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 = readIndex(dir)
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
  })
@@ -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
- // Announce completion and reset once every step is done
139
- function finalizeCompletedExecution(ctx: ExtensionContext): void {
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
- // Block destructive bash commands in plan mode
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 || event.toolName !== 'bash') return
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
- finalizeCompletedExecution(ctx)
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
- enterPlanTools()
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
- model: { id: (ctx.model as { id?: string } | undefined)?.id ?? '' },
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
- // Claude Code tool names -> pi tool names; unmapped names pass through lowercased
11
- const CLAUDE_TOOL_MAP: Record<string, string> = {
12
- read: 'read',
13
- write: 'write',
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
- let items: unknown[]
34
- if (Array.isArray(raw)) items = raw
35
- else if (typeof raw === 'string') items = raw.split(',')
36
- else return null
37
- if (items.some((item) => typeof item !== 'string')) return null
38
- const tools = (items as string[]).map((item) => normalizeToolName(item.trim())).filter(Boolean)
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
- /** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
50
- export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
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
- for (const line of jsonl.split('\n')) {
54
- if (!line.trim()) continue
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(line)
79
+ event = JSON.parse(raw)
58
80
  } catch {
59
- continue
81
+ return
60
82
  }
61
- if (event.type !== 'message_end' || event.message?.role !== 'assistant') continue
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 { text, turns }
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
- const args = withRebuiltPrompt(run.spawn).map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
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', 'ignore'],
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.kill = () => {
213
+ run.live = true
214
+ const killGroup = (signal: NodeJS.Signals): void => {
166
215
  try {
167
- process.kill(-proc.pid!, 'SIGTERM')
216
+ process.kill(-proc.pid!, signal)
168
217
  } catch {
169
- proc.kill('SIGTERM')
218
+ try {
219
+ proc.kill(signal)
220
+ } catch {
221
+ // already gone
222
+ }
170
223
  }
171
224
  }
172
- let stdout = ''
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
- onComplete(run)
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
- stdout += data.toString()
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 } = parseFinalOutputFromJsonl(stdout)
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
  }