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.
@@ -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
 
@@ -628,15 +644,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
628
644
  const invocation = getPiInvocation(args)
629
645
  const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd, promptBody: tmpPrompt ? promptWithSkills : undefined }, (run) => {
630
646
  removeTmpPrompt(tmpPrompt)
647
+ // Both calls throw once the session that started the run is disposed; driveRun
648
+ // catches for the whole callback, so neither can escape into the child's close
649
+ // listener and become an uncaughtException.
631
650
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
632
- // The run outlives the session that started it, and every pi call throws once
633
- // that session is disposed; an escaping error here would reach Node as an
634
- // uncaughtException and take the process down with it.
635
- try {
636
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
637
- } catch {
638
- // the session that asked for this run is gone; nothing left to notify
639
- }
651
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
640
652
  })
641
653
  if (id === null) {
642
654
  // Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
@@ -1112,12 +1124,11 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1112
1124
  }
1113
1125
 
1114
1126
  export default function subagentExtension(pi: ExtensionAPI) {
1115
- const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1116
- try {
1117
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1118
- } catch {
1119
- // same as above: the session that started the run may already be gone
1120
- }
1127
+ const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
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 })
1131
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1121
1132
  }
1122
1133
 
1123
1134
  // Claude surfaces each agent's description so the model can pick one autonomously.
@@ -1173,7 +1184,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1173
1184
  })
1174
1185
 
1175
1186
  if (params.resume) {
1176
- 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')([]) }
1177
1188
  }
1178
1189
 
1179
1190
  if (params.cancel) {
@@ -337,8 +337,8 @@ export default function todoExtension(pi: ExtensionAPI) {
337
337
 
338
338
  const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
339
339
  // pi persists a failed tool call as `details: {}`, which is truthy: a rejected
340
- // or blocked todo call would otherwise throw here and break replay for the rest
341
- // of the session, losing the list on every resume, fork and compaction.
340
+ // or blocked call would otherwise throw here and break replay for the rest of
341
+ // the session, losing the list on every resume, fork and compaction.
342
342
  if (Array.isArray(details?.todos)) {
343
343
  replayTodos = details.todos.map(normalizeTodo)
344
344
  replayNextId = details.nextId
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.2",
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",