pi-code 1.0.3 → 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.
@@ -86,12 +86,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
86
86
  // fire-and-forget, so the restore would land before the agent ever read the tool
87
87
  // list, leaving the command running with everything enabled.
88
88
  if (parsed.allowedTools) {
89
- const saved = pi.getActiveTools()
90
- const granted = parsed.allowedTools.filter((tool) => saved.includes(tool))
91
- // Only the first restriction in a turn knows the unrestricted set; a second
92
- // command would otherwise record the first one's narrowed set as the thing to
93
- // restore, and the tools the first command dropped would never come back.
94
- pendingRestore ??= saved
89
+ // Only the first restriction in a turn sees the unrestricted set; a second
90
+ // command must grant and restore against that original set, or its own tools
91
+ // are intersected away by the first command's narrowing.
92
+ const original = pendingRestore ?? pi.getActiveTools()
93
+ pendingRestore = original
94
+ const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
95
95
  // `allowed-tools: []` says no tools, and is honored. A non-empty list that
96
96
  // intersects to nothing named only tools pi has none of: that restriction cannot
97
97
  // be expressed, and applying it as "no tools" is not what the command asked for.
@@ -110,11 +110,14 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
110
110
  return null
111
111
  }
112
112
  if (seen.has(real)) return null
113
- seen.add(real)
114
113
  if (!isUnder(real, allowedRoots)) return null
115
114
  try {
116
115
  // real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
117
- return { real, body: fs.readFileSync(real, 'utf-8') }
116
+ const body = fs.readFileSync(real, 'utf-8')
117
+ // Only a consumed file dedupes: marking a blocked or unreadable target seen
118
+ // would let one reader's failure suppress the import for a later, allowed one.
119
+ seen.add(real)
120
+ return { real, body }
118
121
  } catch {
119
122
  return null
120
123
  }
@@ -137,12 +137,25 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
137
137
  pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
138
138
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
139
139
  if (check.code !== 0) {
140
- await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
140
+ const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
141
+ if (init.code !== 0) {
142
+ // Every later snapshot fails against the missing repo, so without this the
143
+ // user first learns /rewind is dead at the moment they need it.
144
+ ctx.ui.notify(`Checkpoints disabled: ${init.stderr.trim() || 'git init failed'}`, 'warning')
145
+ return
146
+ }
141
147
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
142
148
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
143
149
  }
144
150
  }
145
151
 
152
+ /** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
153
+ * snapshot restores as a no-op rather than vetoing the whole rewind. */
154
+ async function snapshotIsEmpty(ref: string): Promise<boolean> {
155
+ const files = await gitShadow(['ls-tree', '-r', '--name-only', ref])
156
+ return files.code === 0 && files.stdout.trim() === ''
157
+ }
158
+
146
159
  async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
147
160
  const createdAt = new Date().toISOString()
148
161
  const add = await gitShadow(['add', '-A'])
@@ -175,6 +188,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
175
188
  ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
176
189
  return true
177
190
  }
191
+ if (await snapshotIsEmpty(checkpoint.ref)) {
192
+ ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
193
+ return true
194
+ }
178
195
  const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
179
196
  if (result.code !== 0) {
180
197
  ctx.ui.notify(`Code restore failed: ${result.stderr.trim()}`, 'warning')
@@ -248,6 +265,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
248
265
 
249
266
  const choice = await ctx.ui.select('Restore code state?', ['Yes, restore code to that point', 'No, keep current code'])
250
267
  if (choice?.startsWith('Yes')) {
268
+ if (await snapshotIsEmpty(checkpoint.ref)) {
269
+ ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
270
+ return
271
+ }
251
272
  const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
252
273
  ctx.ui.notify(result.code === 0 ? 'Code restored to checkpoint' : `Restore failed: ${result.stderr.trim()}`, result.code === 0 ? 'info' : 'warning')
253
274
  }
@@ -78,6 +78,8 @@ export interface HookRunResult {
78
78
  stderr: string
79
79
  /** The hook was killed at its timeout, so its exit code carries no verdict. */
80
80
  timedOut: boolean
81
+ /** The process errored before delivering a verdict (spawn failure, EIO). */
82
+ spawnFailed?: boolean
81
83
  }
82
84
  export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
83
85
 
@@ -265,7 +267,9 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
265
267
  if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
266
268
  })
267
269
  child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
268
- child.on('error', () => finish({ code: 0, stdout, stderr, timedOut: false }))
270
+ // Marked rather than silently read as a clean run: under fd exhaustion a
271
+ // deny-list guard that never spawned would otherwise pass as an allow.
272
+ child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
269
273
  // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
270
274
  // so ignore EPIPE on this write rather than crashing the host process.
271
275
  child.stdin?.on('error', () => {})
@@ -294,21 +298,42 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
294
298
  Object.assign(target, next)
295
299
  }
296
300
 
297
- /** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
298
- * matcher sees both the pi name and the Claude alias, and the payload reports the alias,
299
- * which is the name a Claude-written hook script expects in tool_name. A hook's
300
- * hookSpecificOutput.updatedInput replaces the tool input in place before the permission
301
- * decision applies, and later hooks see the rewritten input in their payload. */
301
+ /** Claude surfaces a hook error notice and the action proceeds; silence would read a
302
+ * guard that never ran as a clean allow. */
303
+ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
304
+ if (!notify) return
305
+ for (const [i, result] of results.entries()) {
306
+ if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
307
+ }
308
+ }
309
+
310
+ /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
311
+ * verdict in config order wins. For MCP tools the matcher sees both the pi name and
312
+ * the Claude alias, and the payload reports the alias, which is the name a
313
+ * Claude-written hook script expects in tool_name. Every hook sees the original
314
+ * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
315
+ * hook completes, so with several rewrites the last to finish takes effect, which
316
+ * is Claude's documented (non-deterministic) behavior. */
302
317
  export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
303
318
  const names = claudeName ? [toolName, claudeName] : [toolName]
304
- for (const command of matchingCommands(config.PreToolUse, names)) {
305
- const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
319
+ const commands = matchingCommands(config.PreToolUse, names)
320
+ const results = await Promise.all(
321
+ commands.map((command) =>
322
+ runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
323
+ const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
324
+ if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
325
+ return result
326
+ }),
327
+ ),
328
+ )
329
+ surfaceHookFailures(commands, results, onSystemMessage)
330
+ for (const [i, result] of results.entries()) {
306
331
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
307
332
  // would otherwise read as a clean allow. Fail closed instead.
308
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
309
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
310
- const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
311
- if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
333
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
334
+ }
335
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
336
+ for (const result of results) {
312
337
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
313
338
  if (decision.block) return decision
314
339
  }
@@ -343,14 +368,19 @@ function promptContext(stdout: string): string {
343
368
  return stdout.trim()
344
369
  }
345
370
 
346
- /** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
347
- * additional context is concatenated for injection ahead of the prompt. */
371
+ /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
372
+ * verdict in config order wins; otherwise their additional context is concatenated
373
+ * in config order for injection ahead of the prompt. */
348
374
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
375
+ const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
376
+ const results = await Promise.all(commands.map((command) => runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
377
+ surfaceHookFailures(commands, results, onSystemMessage)
378
+ for (const [i, result] of results.entries()) {
379
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
380
+ }
381
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
349
382
  const contexts: string[] = []
350
- for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
351
- const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
352
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
353
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
383
+ for (const result of results) {
354
384
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
355
385
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
356
386
  const context = promptContext(result.stdout)
@@ -148,16 +148,21 @@ export function splitArgs(args: string): string[] {
148
148
  return out
149
149
  }
150
150
 
151
- /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`. An
152
- * unfilled positional becomes empty rather than leaking its literal token. */
151
+ /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`, and `\$`
152
+ * for a literal dollar. An unfilled positional becomes empty rather than leaking its
153
+ * literal token. One pass with a replacer function: sequential string passes both
154
+ * interpreted `$&`-style metacharacters in the arguments and re-scanned substituted
155
+ * text, so `$` sequences the user typed were consumed as tokens. */
153
156
  export function substituteArgs(body: string, args: string): string {
154
157
  const parts = splitArgs(args)
155
- return body
156
- .replaceAll(/\$\{(\d+):-([^}]*)\}/g, (_m, index: string, fallback: string) => parts[Number(index) - 1] ?? fallback)
157
- .replaceAll(/\$\{ARGUMENTS:-([^}]*)\}/g, (_m, fallback: string) => (args.trim() ? args.trim() : fallback))
158
- .replaceAll(/\$ARGUMENTS\b/g, args.trim())
159
- .replaceAll('$@', args.trim())
160
- .replaceAll(/\$(\d+)/g, (_m, index: string) => parts[Number(index) - 1] ?? '')
158
+ const all = args.trim()
159
+ return body.replaceAll(/\\\$|\$\{(\d+):-([^}]*)\}|\$\{ARGUMENTS:-([^}]*)\}|\$ARGUMENTS\b|\$@|\$(\d+)/g, (token, index?: string, fallback?: string, argsFallback?: string, position?: string) => {
160
+ if (token === '\\$') return '$'
161
+ if (index !== undefined) return parts[Number(index) - 1] ?? fallback ?? ''
162
+ if (argsFallback !== undefined) return all || argsFallback
163
+ if (position !== undefined) return parts[Number(position) - 1] ?? ''
164
+ return all
165
+ })
161
166
  }
162
167
 
163
168
  /** `a/b/c.md` becomes Claude's `a:b:c`. */
@@ -239,12 +244,17 @@ export async function expandDynamicContent(body: string, cwd: string, exec: Comm
239
244
  bashMatch = bashPattern.exec(body)
240
245
  }
241
246
 
242
- let expanded = body
247
+ // Splice by recorded position: a textual replace would interpret `$` sequences in
248
+ // the command's output and could hit an identical fenced copy of the span instead.
249
+ let expanded = ''
250
+ let cursor = 0
243
251
  for (const entry of commands) {
244
252
  const result = await exec(entry.command)
245
253
  const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
246
- expanded = expanded.replace(entry.span, output)
254
+ expanded += body.slice(cursor, entry.index) + output
255
+ cursor = entry.index + entry.span.length
247
256
  }
257
+ expanded += body.slice(cursor)
248
258
 
249
259
  // Ranges are recomputed: command output can change offsets.
250
260
  const fencedAfter = fencedRanges(expanded)
package/extensions/mcp.ts CHANGED
@@ -482,8 +482,24 @@ export default async function mcpExtension(pi: ExtensionAPI) {
482
482
  const count = registerTools(name, config, client, tools)
483
483
  subscribeToToolChanges(name, config, client)
484
484
  status.set(name, { state: 'connected', tools: count })
485
+ // A server that dies mid-session would otherwise stay "connected" in /mcp
486
+ // while every call fails with the SDK's bare "Not connected"; flip the
487
+ // status and free the name so a later session start can reconnect it.
488
+ client.onclose = () => {
489
+ if (clients.get(name) !== client) return
490
+ clients.delete(name)
491
+ status.set(name, { state: 'disconnected', tools: 0 })
492
+ }
485
493
  } catch (error) {
486
494
  status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
495
+ // Connected but failed after (tool listing hung or errored): left in the
496
+ // map, the client idles its process for the whole session and the
497
+ // duplicate-name guard blocks the name for every later attempt.
498
+ const leaked = clients.get(name)
499
+ if (leaked) {
500
+ clients.delete(name)
501
+ void leaked.close().catch(() => {})
502
+ }
487
503
  }
488
504
  }),
489
505
  )
@@ -503,16 +519,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
503
519
  return true
504
520
  }
505
521
 
506
- let userConnected = false
507
522
  let projectConnected = false
508
523
 
509
524
  pi.on('session_start', async (_event, ctx) => {
510
525
  // Connecting spawns processes and opens sockets, so it belongs here rather than in
511
526
  // the factory: pi runs the factory for invocations that never start a session.
512
- if (!userConnected) {
513
- userConnected = true
514
- await connectServers(loadUserScope(os.homedir(), ctx.cwd))
515
- }
527
+ // Names still connected are filtered out, so a later session start only retries
528
+ // servers that failed or whose transport dropped, without duplicate-name warnings.
529
+ const userServers = Object.fromEntries(Object.entries(loadUserScope(os.homedir(), ctx.cwd)).filter(([name]) => !clients.has(name)))
530
+ if (Object.keys(userServers).length > 0) await connectServers(userServers)
516
531
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
517
532
  // the project is trusted. Per-server settings refine that: disabled servers never
518
533
  // connect, servers the user consented to individually connect without the
@@ -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()
@@ -140,11 +148,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
140
148
  })
141
149
  }
142
150
 
143
- // Announce completion and reset once every step is done
144
- function finalizeCompletedExecution(ctx: ExtensionContext): void {
145
- if (!todoItems.every((t) => t.completed)) return
146
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
147
- 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 })
148
153
  executionMode = false
149
154
  todoItems = []
150
155
  restoreTools()
@@ -152,6 +157,23 @@ export default function planModeExtension(pi: ExtensionAPI): void {
152
157
  persistState() // Save cleared state so resume doesn't restore old execution mode
153
158
  }
154
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
+
155
177
  // Fall back to extracting a plan from the last assistant message's prose
156
178
  function deriveTodosFromProse(messages: AgentMessage[]): void {
157
179
  const lastAssistant = [...messages].reverse().find(isAssistantMessage)
@@ -170,6 +192,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
170
192
  planModeEnabled = false
171
193
  executionMode = todoItems.length > 0
172
194
  planFromTool = false
195
+ stalledRuns = 0
196
+ runProgress = false
173
197
  restoreTools()
174
198
  publishPlanState()
175
199
  updateStatus(ctx)
@@ -241,9 +265,19 @@ export default function planModeExtension(pi: ExtensionAPI): void {
241
265
  handler: async (ctx) => togglePlanMode(ctx),
242
266
  })
243
267
 
244
- // 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.
245
272
  pi.on('tool_call', async (event) => {
246
- 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
247
281
 
248
282
  const command = event.input.command as string
249
283
  if (!isSafeCommand(command)) {
@@ -332,6 +366,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
332
366
 
333
367
  const text = getTextContent(event.message)
334
368
  if (markCompletedSteps(text, todoItems) > 0) {
369
+ runProgress = true
335
370
  updateStatus(ctx)
336
371
  }
337
372
  persistState()
@@ -339,9 +374,18 @@ After completing a step, include a [DONE:n] tag in your response.`,
339
374
 
340
375
  // Handle plan completion and plan mode UI
341
376
  pi.on('agent_end', async (event, ctx) => {
342
- // Check if execution is complete
377
+ // Check if execution is complete, or has stalled without marker progress
343
378
  if (executionMode && todoItems.length > 0) {
344
- 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
345
389
  return
346
390
  }
347
391
 
@@ -376,6 +420,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
376
420
  executionMode = false
377
421
  todoItems = []
378
422
  planFromTool = false
423
+ stalledRuns = 0
424
+ runProgress = false
379
425
 
380
426
  if (pi.getFlag('plan') === true) {
381
427
  planModeEnabled = true
@@ -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,
@@ -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,25 +204,41 @@ 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
241
+ evictFinishedRuns()
178
242
  // A run outlives the session that started it, and pi's loader wires assertActive()
179
243
  // into every runtime call, so notifying a disposed session throws. This fires from
180
244
  // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
@@ -187,27 +251,33 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
187
251
  // the session that asked for this run is gone
188
252
  }
189
253
  }
190
- proc.stdout.on('data', (data) => {
191
- stdout += data.toString()
192
- })
254
+ proc.stdout.on('data', (data) => parser.push(data.toString()))
193
255
  // An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
194
256
  // belongs to a detached child, so a pipe read failure would exit pi the same way an
195
257
  // unguarded completion would. The foreground runner guards its streams the same way.
196
258
  proc.stdout.on('error', () => {})
259
+ proc.stderr?.on('data', (data) => {
260
+ stderrTail = (stderrTail + data.toString()).slice(-STDERR_TAIL_CHARS)
261
+ })
262
+ proc.stderr?.on('error', () => {})
197
263
  proc.on('close', (code) => {
198
- const { text, turns } = parseFinalOutputFromJsonl(stdout)
264
+ const { text, turns } = parser.flush()
199
265
  run.kill = undefined
266
+ run.live = false
200
267
  // A cancelled run keeps that state: its non-zero exit is the cancellation.
201
268
  if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
202
269
  run.exitCode = code ?? 0
203
270
  run.output = text
204
271
  run.turns = turns
272
+ run.stderr = stderrTail.trim() || undefined
205
273
  complete()
206
274
  })
207
- proc.on('error', () => {
275
+ proc.on('error', (error) => {
208
276
  run.kill = undefined
277
+ run.live = false
209
278
  run.state = 'failed'
210
279
  run.exitCode = 1
280
+ run.stderr = stderrTail.trim() || error.message
211
281
  complete()
212
282
  })
213
283
  }
@@ -28,7 +28,7 @@ import { isProjectApproved, isProjectApprovedSilently } from '../internal/projec
28
28
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
29
29
  import { skillDirs } from '../skills.js'
30
30
  import { type AgentConfig, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
31
- import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
31
+ import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
32
32
 
33
33
  const MAX_PARALLEL_TASKS = 8
34
34
  const MAX_CONCURRENCY = 4
@@ -343,6 +343,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
343
343
  cwd: cwd ?? defaultCwd,
344
344
  shell: false,
345
345
  stdio: ['ignore', 'pipe', 'pipe'],
346
+ // Its own group, so an abort reaches grandchildren too: killing only the
347
+ // direct child orphans a build or dev server the agent started.
348
+ detached: true,
346
349
  // The marker lets the child's subagent tool refuse to nest further.
347
350
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
348
351
  })
@@ -401,19 +404,24 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
401
404
  resolve(1)
402
405
  })
403
406
 
407
+ const killGroup = (sig: NodeJS.Signals): void => {
408
+ try {
409
+ process.kill(-proc.pid!, sig)
410
+ } catch {
411
+ try {
412
+ proc.kill(sig)
413
+ } catch {
414
+ /* already gone */
415
+ }
416
+ }
417
+ }
404
418
  if (signal) {
405
419
  onAbort = () => {
406
420
  wasAborted = true
407
- proc.kill('SIGTERM')
421
+ killGroup('SIGTERM')
408
422
  // proc.killed only reports that the signal was sent, not that the child died. Escalate
409
423
  // on a timer that the 'close' handler clears once the child has actually exited.
410
- killTimer = setTimeout(() => {
411
- try {
412
- proc.kill('SIGKILL')
413
- } catch {
414
- /* already gone */
415
- }
416
- }, 5000)
424
+ killTimer = setTimeout(() => killGroup('SIGKILL'), 5000)
417
425
  }
418
426
  if (signal.aborted) onAbort()
419
427
  else signal.addEventListener('abort', onAbort, { once: true })
@@ -498,17 +506,25 @@ type ChainStepParam = Static<typeof ChainItem>
498
506
  type TaskItemParam = Static<typeof TaskItem>
499
507
 
500
508
  /** The completion notice a background run sends when it finishes. */
501
- export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string }): string {
509
+ export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): string {
502
510
  const output = capForContext(run.output ?? '') || '(no output)'
503
- return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}`
511
+ // A child that dies at boot writes its reason only to stderr; without this the
512
+ // notice reads "failed after 0 turns ... (no output)" with nothing to act on.
513
+ const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
514
+ return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}`
504
515
  }
505
516
 
506
517
  /** What to tell the model about a resume request. */
507
- export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string }) => void): string {
518
+ export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }) => void, onResumed?: (run: { id: string; agent: string }) => void): string {
508
519
  if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
509
520
  const outcome = resumeBackgroundRun(id, task, onComplete)
510
- if (outcome === 'resumed') return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
521
+ if (outcome === 'resumed') {
522
+ const run = backgroundRun(id)
523
+ if (run) onResumed?.({ id: run.id, agent: run.agent })
524
+ return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
525
+ }
511
526
  if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
527
+ if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
512
528
  return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
513
529
  }
514
530
 
@@ -1108,8 +1124,10 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1108
1124
  }
1109
1125
 
1110
1126
  export default function subagentExtension(pi: ExtensionAPI) {
1111
- const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1127
+ const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1112
1128
  // Runs through driveRun's guard, same as the background-mode callback above.
1129
+ // The stop event fires here too, so SubagentStop hooks see resumed runs end.
1130
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
1113
1131
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1114
1132
  }
1115
1133
 
@@ -1166,7 +1184,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1166
1184
  })
1167
1185
 
1168
1186
  if (params.resume) {
1169
- return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion) }], details: makeDetails('single')([]) }
1187
+ return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, (run) => pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })) }], details: makeDetails('single')([]) }
1170
1188
  }
1171
1189
 
1172
1190
  if (params.cancel) {
package/extensions/web.ts CHANGED
@@ -194,6 +194,9 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
194
194
  userAgent: USER_AGENT,
195
195
  })
196
196
  if (response.status >= 300 && response.status < 400) {
197
+ // The hop's body is never read; without the cancel its socket stays held
198
+ // until the 20s abort timeout, once per hop.
199
+ void response.body?.cancel().catch(() => {})
197
200
  const location = response.headers.get('location')
198
201
  if (!location) throw new Error(`redirect without location from ${url.hostname}`)
199
202
  url = new URL(location, url)
@@ -202,7 +205,10 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
202
205
  if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
203
206
  continue
204
207
  }
205
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
208
+ if (!response.ok) {
209
+ void response.body?.cancel().catch(() => {})
210
+ throw new Error(`HTTP ${response.status} for ${url}`)
211
+ }
206
212
  return { text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
207
213
  }
208
214
  throw new Error(`too many redirects for ${rawUrl}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
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, and subagents",
5
5
  "keywords": [
6
6
  "pi",