pi-subagents 0.34.0 → 0.35.1

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +83 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +98 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { spawn } from "node:child_process";
6
6
  import { existsSync, unlinkSync } from "node:fs";
7
+ import * as path from "node:path";
7
8
  import type { Message } from "@earendil-works/pi-ai";
8
9
  import type { AgentConfig } from "../../agents/agents.ts";
9
10
  import {
@@ -51,7 +52,8 @@ import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
51
52
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
52
53
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
53
54
  import { readStructuredOutput } from "../shared/structured-output.ts";
54
- import { captureSingleOutputSnapshot, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
55
+ import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
56
+ import { captureSingleOutputSnapshot, extractChildWrittenOutput, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
55
57
  import {
56
58
  buildModelCandidates,
57
59
  formatModelAttemptNote,
@@ -68,9 +70,18 @@ import {
68
70
  shouldEscalateMutatingFailures,
69
71
  summarizeRecentMutatingFailures,
70
72
  } from "../shared/long-running-guard.ts";
71
- import { acceptanceFailureMessage, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport } from "../shared/acceptance.ts";
72
- import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, shouldAbortForTurnBudget, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
73
+ import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport } from "../shared/acceptance.ts";
74
+ import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, turnBudgetDecision, turnBudgetDeferredNote, turnBudgetDeferredState, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
73
75
  import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
76
+ import { resolveWatchdogConfig } from "../../watchdog/settings.ts";
77
+ import { createBoundedByteTail, createBoundedLineReader, formatProtocolOutputLimit, MAX_CHILD_STDERR_BYTES, projectChildLifecycle, type ChildLifecycleAction, type ProtocolOutputLimit } from "../shared/child-protocol.ts";
78
+ import {
79
+ acceptChildWatchdogEvent,
80
+ childWatchdogIsActive,
81
+ isChildWatchdogStatusEvent,
82
+ resolveChildWatchdogConfig,
83
+ type ChildWatchdogStateSnapshot,
84
+ } from "../../watchdog/child-status.ts";
74
85
 
75
86
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
76
87
  const acceptanceOutputByResult = new WeakMap<SingleResult, string>();
@@ -102,16 +113,14 @@ function resolveAttemptTimeout(options: RunSyncOptions): { timeoutMs: number; re
102
113
  };
103
114
  }
104
115
 
105
- function buildSkippedAcceptanceLedger(acceptance: ResolvedAcceptanceConfig, input: { id: string; message: string }): AcceptanceLedger {
116
+ function buildPendingAcceptanceLedger(acceptance: ResolvedAcceptanceConfig): AcceptanceLedger {
106
117
  return {
107
- status: acceptance.level === "none" ? "not-required" : "rejected",
118
+ status: "pending",
108
119
  explicit: acceptance.explicit,
109
120
  effectiveAcceptance: acceptance,
110
121
  inferredReason: acceptance.inferredReason,
111
122
  criteria: acceptance.criteria,
112
- runtimeChecks: acceptance.level === "none"
113
- ? []
114
- : [{ id: input.id, status: "failed", message: input.message }],
123
+ runtimeChecks: [],
115
124
  verifyRuns: [],
116
125
  };
117
126
  }
@@ -187,7 +196,16 @@ async function runSingleAttempt(
187
196
  ): Promise<SingleResult> {
188
197
  const effectiveThinking = options.thinkingOverride ?? agent.thinking;
189
198
  const modelArg = applyThinkingSuffix(model, effectiveThinking, options.thinkingOverride !== undefined);
190
- const { args, env: sharedEnv, tempDir } = buildPiArgs({
199
+ const watchdogConfig = resolveWatchdogConfig(options.cwd ?? runtimeCwd);
200
+ const childWatchdog = watchdogConfig.ok
201
+ ? resolveChildWatchdogConfig({
202
+ config: watchdogConfig.config,
203
+ agent: agent.name,
204
+ runId: options.runId,
205
+ childIndex: options.index ?? 0,
206
+ })
207
+ : undefined;
208
+ const { args, env: sharedEnv, tempDir, toolDiagnosticPath } = buildPiArgs({
191
209
  baseArgs: ["--mode", "json", "-p"],
192
210
  task,
193
211
  sessionEnabled: shared.sessionEnabled,
@@ -218,6 +236,8 @@ async function runSingleAttempt(
218
236
  parentSessionId: options.parentSessionId,
219
237
  structuredOutput: options.structuredOutput,
220
238
  toolBudget: options.toolBudget,
239
+ childWatchdog,
240
+ waitToolEnabled: options.waitToolEnabled,
221
241
  });
222
242
 
223
243
  const result: SingleResult = {
@@ -271,6 +291,7 @@ async function runSingleAttempt(
271
291
  result.progress = progress;
272
292
  const attemptTimeout = resolveAttemptTimeout(options);
273
293
  if (attemptTimeout?.remainingMs === 0) {
294
+ cleanupTempDir(tempDir);
274
295
  result.exitCode = 1;
275
296
  result.timedOut = true;
276
297
  result.error = attemptTimeout.message;
@@ -296,7 +317,6 @@ async function runSingleAttempt(
296
317
  windowsHide: true,
297
318
  });
298
319
  const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
299
- let buf = "";
300
320
  let processClosed = false;
301
321
  let settled = false;
302
322
  let detached = false;
@@ -311,6 +331,7 @@ async function runSingleAttempt(
311
331
  let turnBudgetSoftReached = false;
312
332
  let turnBudgetTerminationTimer: NodeJS.Timeout | undefined;
313
333
  let turnBudgetHardKillTimer: NodeJS.Timeout | undefined;
334
+ let protocolHardKillTimer: NodeJS.Timeout | undefined;
314
335
  const clearTurnBudgetTimers = () => {
315
336
  if (turnBudgetTerminationTimer) {
316
337
  clearTimeout(turnBudgetTerminationTimer);
@@ -358,8 +379,22 @@ async function runSingleAttempt(
358
379
  let childExited = false;
359
380
  let forcedTerminationSignal = false;
360
381
  let cleanTerminalAssistantStopReceived = false;
382
+ let agentSettledReceived = false;
361
383
  let finalDrainTimer: NodeJS.Timeout | undefined;
362
384
  let finalHardKillTimer: NodeJS.Timeout | undefined;
385
+ let watchdogTailTimer: NodeJS.Timeout | undefined;
386
+ let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
387
+ const updateChildWatchdogState = (snapshot: ChildWatchdogStateSnapshot): void => {
388
+ childWatchdogState = snapshot;
389
+ result.watchdog = snapshot;
390
+ progress.watchdog = snapshot;
391
+ };
392
+ const clearWatchdogTailTimer = () => {
393
+ if (watchdogTailTimer) {
394
+ clearTimeout(watchdogTailTimer);
395
+ watchdogTailTimer = undefined;
396
+ }
397
+ };
363
398
  const clearFinalDrainTimers = () => {
364
399
  if (finalDrainTimer) {
365
400
  clearTimeout(finalDrainTimer);
@@ -371,14 +406,18 @@ async function runSingleAttempt(
371
406
  }
372
407
  };
373
408
  const startFinalDrain = () => {
409
+ if (childWatchdogIsActive(childWatchdogState)) {
410
+ armWatchdogTail();
411
+ return;
412
+ }
374
413
  if (childExited || finalDrainTimer || settled || processClosed || detached) return;
375
414
  finalDrainTimer = setTimeout(() => {
376
415
  if (settled || processClosed || detached) return;
377
416
  const termSent = trySignalChild(proc, "SIGTERM");
378
417
  if (!termSent) return;
379
418
  forcedTerminationSignal = true;
380
- if (!cleanTerminalAssistantStopReceived && !assistantError) {
381
- result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
419
+ if (!cleanTerminalAssistantStopReceived && !agentSettledReceived && !assistantError) {
420
+ result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Forcing termination.`;
382
421
  }
383
422
  finalHardKillTimer = setTimeout(() => {
384
423
  if (settled || processClosed || detached) return;
@@ -388,13 +427,45 @@ async function runSingleAttempt(
388
427
  }, FINAL_STOP_GRACE_MS);
389
428
  finalDrainTimer.unref?.();
390
429
  };
430
+ function armWatchdogTail(): void {
431
+ if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || settled || processClosed || detached) return;
432
+ watchdogTailTimer = setTimeout(() => {
433
+ watchdogTailTimer = undefined;
434
+ updateChildWatchdogState({
435
+ phase: "stale",
436
+ seq: (childWatchdogState?.seq ?? 0) + 1,
437
+ lastUpdate: Date.now(),
438
+ followUpPending: false,
439
+ reason: "child watchdog tail timeout",
440
+ timedOut: true,
441
+ });
442
+ startFinalDrain();
443
+ fireUpdate();
444
+ }, childWatchdog?.watchdogTailTimeoutMs ?? 120_000);
445
+ watchdogTailTimer.unref?.();
446
+ }
447
+ const applyChildLifecycle = (action: ChildLifecycleAction): void => {
448
+ if (action === "cancel-drain") {
449
+ clearFinalDrainTimers();
450
+ clearWatchdogTailTimer();
451
+ return;
452
+ }
453
+ if (action === "start-drain") startFinalDrain();
454
+ };
391
455
 
392
456
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
393
- if (!options.allowIntercomDetach || detached || processClosed || !intercomStarted) return;
457
+ if (!options.allowIntercomDetach || detached || processClosed) return;
394
458
  if (!payload || typeof payload !== "object") return;
395
- const requestId = (payload as { requestId?: unknown }).requestId;
459
+ const event = payload as { requestId?: unknown; runId?: unknown; agent?: unknown; childIndex?: unknown };
460
+ const requestId = event.requestId;
396
461
  if (typeof requestId !== "string" || requestId.length === 0) return;
397
- options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted: true });
462
+ const hasRoute = event.runId !== undefined || event.agent !== undefined || event.childIndex !== undefined;
463
+ if (hasRoute) {
464
+ if (typeof event.runId === "string" && event.runId !== options.runId) return;
465
+ if (typeof event.agent === "string" && event.agent !== agent.name) return;
466
+ if (typeof event.childIndex === "number" && event.childIndex !== (options.index ?? 0)) return;
467
+ } else if (!intercomStarted) return;
468
+ options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted: true, runId: options.runId, agent: agent.name, childIndex: options.index ?? 0 });
398
469
  detachForIntercom();
399
470
  });
400
471
 
@@ -402,9 +473,14 @@ async function runSingleAttempt(
402
473
  if (settled) return;
403
474
  settled = true;
404
475
  clearFinalDrainTimers();
476
+ clearWatchdogTailTimer();
405
477
  clearStdioGuard();
406
478
  clearTimeoutTimers();
407
479
  clearTurnBudgetTimers();
480
+ if (protocolHardKillTimer) {
481
+ clearTimeout(protocolHardKillTimer);
482
+ protocolHardKillTimer = undefined;
483
+ }
408
484
  if (activityTimer) {
409
485
  clearInterval(activityTimer);
410
486
  activityTimer = undefined;
@@ -504,7 +580,7 @@ async function runSingleAttempt(
504
580
  turnBudgetHardKillTimer.unref?.();
505
581
  };
506
582
 
507
- const updateTurnBudget = (turnCount: number, terminalAssistantStop: boolean) => {
583
+ const updateTurnBudget = (turnCount: number, terminalAssistantStop: boolean, toolWorkActiveOrStarting: boolean) => {
508
584
  const budget = options.turnBudget;
509
585
  if (!budget || result.timedOut || result.turnBudgetExceeded) return;
510
586
  if (turnCount < budget.maxTurns) {
@@ -516,10 +592,17 @@ async function runSingleAttempt(
516
592
  result.wrapUpRequested = true;
517
593
  appendRecentOutput(progress, [turnBudgetSoftNote(budget, turnCount)]);
518
594
  }
519
- result.turnBudget = turnBudgetState(budget, turnCount, false);
520
- if (shouldAbortForTurnBudget(budget, turnCount, terminalAssistantStop)) {
521
- requestTurnBudgetAbort(turnCount);
595
+ const decision = turnBudgetDecision(budget, turnCount, terminalAssistantStop, toolWorkActiveOrStarting);
596
+ if (decision === "defer") {
597
+ result.turnBudget = turnBudgetDeferredState(
598
+ budget,
599
+ turnCount,
600
+ result.turnBudget?.terminationDeferredAtTurn,
601
+ );
602
+ return;
522
603
  }
604
+ result.turnBudget = turnBudgetState(budget, turnCount, false);
605
+ if (decision === "abort") requestTurnBudgetAbort(turnCount);
523
606
  };
524
607
 
525
608
  const updateActivityState = (now: number): boolean => {
@@ -528,6 +611,7 @@ async function runSingleAttempt(
528
611
  config: controlConfig,
529
612
  startedAt: startTime,
530
613
  lastActivityAt: progress.lastActivityAt,
614
+ currentTool: progress.currentTool,
531
615
  now,
532
616
  });
533
617
  if (idleState === "needs_attention") {
@@ -569,15 +653,39 @@ async function runSingleAttempt(
569
653
  const processLine = (line: string) => {
570
654
  if (!line.trim()) return;
571
655
  jsonlWriter.writeLine(line);
572
- let evt: { type?: string; message?: Message; toolName?: string; args?: unknown };
656
+ let evt: { type?: string; message?: Message; toolName?: string; args?: unknown; willRetry?: unknown };
573
657
  try {
574
- evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
658
+ evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown; willRetry?: unknown };
575
659
  } catch {
576
660
  shared.transcriptWriter?.writeStdoutLine(line);
577
661
  // Non-JSON stdout lines are expected; only structured events are parsed.
578
662
  return;
579
663
  }
580
664
  shared.transcriptWriter?.writeChildEvent(evt);
665
+ if (evt.type === "agent_settled") agentSettledReceived = true;
666
+ applyChildLifecycle(projectChildLifecycle(evt));
667
+
668
+ if (isChildWatchdogStatusEvent(evt)) {
669
+ if (!childWatchdog) return;
670
+ const next = acceptChildWatchdogEvent({
671
+ current: childWatchdogState,
672
+ event: evt,
673
+ runId: options.runId,
674
+ agent: agent.name,
675
+ childIndex: options.index ?? 0,
676
+ });
677
+ if (!next) return;
678
+ updateChildWatchdogState(next);
679
+ if (childWatchdogIsActive(next)) {
680
+ clearFinalDrainTimers();
681
+ armWatchdogTail();
682
+ } else {
683
+ clearWatchdogTailTimer();
684
+ if (cleanTerminalAssistantStopReceived || agentSettledReceived) startFinalDrain();
685
+ }
686
+ fireUpdate();
687
+ return;
688
+ }
581
689
 
582
690
  const now = Date.now();
583
691
  progress.durationMs = now - startTime;
@@ -588,11 +696,8 @@ async function runSingleAttempt(
588
696
  const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
589
697
  ? evt.args as Record<string, unknown>
590
698
  : {};
591
- let shouldDetachForBlockingIntercom = false;
592
699
  if (options.allowIntercomDetach && (evt.toolName === "intercom" || evt.toolName === "contact_supervisor")) {
593
700
  intercomStarted = true;
594
- shouldDetachForBlockingIntercom = (evt.toolName === "intercom" && toolArgs.action === "ask")
595
- || (evt.toolName === "contact_supervisor" && (toolArgs.reason === "need_decision" || toolArgs.reason === "interview_request"));
596
701
  }
597
702
  progress.toolCount++;
598
703
  if (options.toolBudget) {
@@ -606,9 +711,6 @@ async function runSingleAttempt(
606
711
  observedMutationAttempt = observedMutationAttempt || mutates;
607
712
  pendingToolResult = { tool: evt.toolName ?? "tool", path: progress.currentPath, mutates, startedAt: now };
608
713
  fireUpdate();
609
- if (shouldDetachForBlockingIntercom && !detached && !processClosed) {
610
- detachForIntercom();
611
- }
612
714
  }
613
715
 
614
716
  if (evt.type === "tool_execution_end") {
@@ -635,7 +737,7 @@ async function runSingleAttempt(
635
737
  const hasToolCall = Array.isArray(evt.message.content)
636
738
  && evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
637
739
  const terminalAssistantStop = stopReason === "stop" && !hasToolCall;
638
- updateTurnBudget(result.usage.turns, terminalAssistantStop);
740
+ updateTurnBudget(result.usage.turns, terminalAssistantStop, hasToolCall || Boolean(progress.currentTool));
639
741
  const u = evt.message.usage;
640
742
  if (u) {
641
743
  result.usage.input += u.input || 0;
@@ -653,7 +755,7 @@ async function runSingleAttempt(
653
755
  if (terminalAssistantStop) {
654
756
  if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
655
757
  cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
656
- startFinalDrain();
758
+ applyChildLifecycle(projectChildLifecycle(evt, true));
657
759
  }
658
760
  }
659
761
  updateActivityState(now);
@@ -731,17 +833,36 @@ async function runSingleAttempt(
731
833
  timeoutTimer.unref?.();
732
834
  }
733
835
 
734
- let stderrBuf = "";
836
+ const stderrTail = createBoundedByteTail();
837
+ const failProtocol = (limit: ProtocolOutputLimit): void => {
838
+ if (result.protocolError) return;
839
+ result.protocolError = limit;
840
+ result.error = formatProtocolOutputLimit(limit);
841
+ progress.status = "failed";
842
+ progress.error = result.error;
843
+ progress.durationMs = Date.now() - startTime;
844
+ fireUpdate();
845
+ if (!childExited) {
846
+ trySignalChild(proc, "SIGTERM");
847
+ protocolHardKillTimer = setTimeout(() => {
848
+ if (!childExited) trySignalChild(proc, "SIGKILL");
849
+ }, 3000);
850
+ protocolHardKillTimer.unref?.();
851
+ }
852
+ };
853
+ const stdoutReader = createBoundedLineReader({ onLine: processLine, onLimit: failProtocol });
854
+ const stderrReader = createBoundedLineReader({
855
+ stream: "stderr",
856
+ maxPendingLineBytes: MAX_CHILD_STDERR_BYTES,
857
+ onLine: (line) => shared.transcriptWriter?.writeStderrLine(line),
858
+ onLimit: (limit) => shared.transcriptWriter?.writeStderrLine(formatProtocolOutputLimit(limit)),
859
+ });
735
860
 
736
861
  const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
737
- proc.stdout.on("data", (d) => {
738
- buf += d.toString();
739
- const lines = buf.split("\n");
740
- buf = lines.pop() || "";
741
- lines.forEach(processLine);
742
- });
743
- proc.stderr.on("data", (d) => {
744
- stderrBuf += d.toString();
862
+ proc.stdout.on("data", (chunk: Buffer) => stdoutReader.push(chunk));
863
+ proc.stderr.on("data", (chunk: Buffer) => {
864
+ stderrTail.push(chunk);
865
+ stderrReader.push(chunk);
745
866
  });
746
867
  proc.on("exit", () => {
747
868
  childExited = true;
@@ -753,38 +874,67 @@ async function runSingleAttempt(
753
874
  void jsonlWriter.close().catch(() => {
754
875
  // JSONL artifact flush is best effort.
755
876
  });
877
+ const toolDiagnosticError = readChildToolDiagnosticError(toolDiagnosticPath);
756
878
  cleanupTempDir(tempDir);
757
- if (buf.trim()) processLine(buf);
758
- if (stderrBuf.trim()) shared.transcriptWriter?.writeStderrText(stderrBuf);
759
- if (!result.error && assistantError) result.error = assistantError;
760
- const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !result.error;
761
- if (code !== 0 && stderrBuf.trim() && !result.error && !forcedDrainAfterFinalSuccess) {
762
- result.error = stderrBuf.trim();
879
+ stdoutReader.end();
880
+ stderrReader.end();
881
+ const stderr = stderrTail.text();
882
+ let closeError = result.error ?? toolDiagnosticError ?? assistantError;
883
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
884
+ if (code !== 0 && stderr.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
885
+ closeError = stderr.trim();
763
886
  }
764
887
  const finalCode = forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
765
888
  if (detached) {
766
- result.exitCode = result.error && finalCode === 0 ? 1 : finalCode;
767
- progress.status = result.exitCode === 0 ? "completed" : "failed";
768
- progress.durationMs = Date.now() - startTime;
769
- if (result.error) progress.error = result.error;
770
- result.progressSummary = {
771
- toolCount: progress.toolCount,
772
- tokens: progress.tokens,
773
- durationMs: progress.durationMs,
889
+ const recoveredProgress = snapshotProgress(progress);
890
+ const recoveredResult = snapshotResult(result, recoveredProgress);
891
+ if (!recoveredResult.error && closeError) recoveredResult.error = closeError;
892
+ recoveredResult.exitCode = recoveredResult.error && finalCode === 0 ? 1 : finalCode;
893
+ recoveredProgress.status = recoveredResult.exitCode === 0 ? "completed" : "failed";
894
+ recoveredProgress.durationMs = Date.now() - startTime;
895
+ if (recoveredResult.error) recoveredProgress.error = recoveredResult.error;
896
+ recoveredResult.progressSummary = {
897
+ toolCount: recoveredProgress.toolCount,
898
+ tokens: recoveredProgress.tokens,
899
+ durationMs: recoveredProgress.durationMs,
774
900
  };
775
- const finalOutput = getFinalOutput(result.messages);
776
- result.finalOutput = finalOutput.trim() || result.error || result.finalOutput || "Detached child exited without final output.";
777
- if (result.artifactPaths && options.artifactConfig?.enabled !== false && options.artifactConfig?.includeOutput !== false) {
901
+ const acceptanceOutput = getFinalOutput(recoveredResult.messages ?? []).trim()
902
+ || recoveredResult.error
903
+ || recoveredResult.finalOutput
904
+ || "Detached child exited without final output.";
905
+ acceptanceOutputByResult.set(recoveredResult, acceptanceOutput);
906
+ let fullOutput = stripAcceptanceReport(acceptanceOutput);
907
+ fullOutput = fullOutput.trim() || recoveredResult.error || recoveredResult.finalOutput || "Detached child exited without final output.";
908
+ recoveredResult.outputMode = options.outputMode ?? "inline";
909
+ if (options.outputPath && recoveredResult.exitCode === 0) {
910
+ const resolvedOutput = resolveSingleOutput(options.outputPath, fullOutput, shared.outputSnapshot);
911
+ fullOutput = stripAcceptanceReport(resolvedOutput.fullOutput);
912
+ recoveredResult.savedOutputPath = resolvedOutput.savedPath;
913
+ recoveredResult.outputSaveError = resolvedOutput.saveError;
914
+ if (resolvedOutput.savedPath) {
915
+ recoveredResult.outputReference = formatSavedOutputReference(resolvedOutput.savedPath, fullOutput);
916
+ } else {
917
+ recoveredResult.exitCode = 1;
918
+ recoveredResult.error = `Output file was not finalized after detached child exit: ${resolvedOutput.saveError ?? options.outputPath}`;
919
+ recoveredProgress.status = "failed";
920
+ recoveredProgress.error = recoveredResult.error;
921
+ }
922
+ }
923
+ recoveredResult.finalOutput = options.outputMode === "file-only" && recoveredResult.savedOutputPath && recoveredResult.outputReference
924
+ ? recoveredResult.outputReference.message
925
+ : fullOutput;
926
+ if (recoveredResult.artifactPaths && options.artifactConfig?.enabled !== false && options.artifactConfig?.includeOutput !== false) {
778
927
  try {
779
- writeArtifact(result.artifactPaths.outputPath, result.finalOutput);
928
+ writeArtifact(recoveredResult.artifactPaths.outputPath, fullOutput);
780
929
  } catch {
781
930
  // Detached children may outlive test/temp cleanup; recovered status is best-effort.
782
931
  }
783
932
  }
784
- options.onDetachedExit?.(snapshotResult(result, snapshotProgress(progress)));
933
+ options.onDetachedExit?.(recoveredResult);
785
934
  finish(-2);
786
935
  return;
787
936
  }
937
+ if (!result.error && closeError) result.error = closeError;
788
938
  processClosed = true;
789
939
  finish(finalCode);
790
940
  });
@@ -795,7 +945,8 @@ async function runSingleAttempt(
795
945
  // JSONL artifact flush is best effort.
796
946
  });
797
947
  cleanupTempDir(tempDir);
798
- if (stderrBuf.trim()) shared.transcriptWriter?.writeStderrText(stderrBuf);
948
+ stdoutReader.end();
949
+ stderrReader.end();
799
950
  if (!result.error) {
800
951
  result.error = error instanceof Error ? error.message : String(error);
801
952
  }
@@ -805,10 +956,6 @@ async function runSingleAttempt(
805
956
  if (options.signal) {
806
957
  const kill = () => {
807
958
  if (processClosed || detached) return;
808
- if (options.allowIntercomDetach && intercomStarted && !detached) {
809
- detachForIntercom();
810
- return;
811
- }
812
959
  proc.kill("SIGTERM");
813
960
  setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
814
961
  };
@@ -861,8 +1008,12 @@ async function runSingleAttempt(
861
1008
  return result;
862
1009
  }
863
1010
  if (result.detached) {
864
- result.exitCode = 0;
865
- result.finalOutput = "Detached for intercom coordination.";
1011
+ result.exitCode = -2;
1012
+ result.finalOutput = "Detached for intercom coordination before task completion.";
1013
+ result.outputMode = options.outputMode ?? "inline";
1014
+ if (options.outputPath) {
1015
+ result.outputSaveError = "Output file was not finalized because the subagent detached for intercom coordination.";
1016
+ }
866
1017
  return result;
867
1018
  }
868
1019
 
@@ -928,6 +1079,9 @@ async function runSingleAttempt(
928
1079
  : timeoutMessage;
929
1080
  } else if (result.turnBudgetExceeded && result.turnBudget) {
930
1081
  fullOutput = formatTurnBudgetOutput(turnBudgetExceededMessage(result.turnBudget, result.turnBudget.turnCount), fullOutput);
1082
+ } else if (result.turnBudget?.outcome === "termination-deferred") {
1083
+ const note = turnBudgetDeferredNote(result.turnBudget, result.turnBudget.terminationDeferredAtTurn ?? result.turnBudget.turnCount);
1084
+ fullOutput = fullOutput.trim() ? `${note}\n\n${fullOutput}` : note;
931
1085
  } else if (result.wrapUpRequested && result.turnBudget?.outcome === "wrap-up-requested") {
932
1086
  const note = turnBudgetSoftNote(result.turnBudget, result.turnBudget.wrapUpRequestedAtTurn ?? result.turnBudget.turnCount);
933
1087
  fullOutput = fullOutput.trim() ? `${note}\n\n${fullOutput}` : note;
@@ -1028,6 +1182,7 @@ export async function runSync(
1028
1182
  const effectiveAcceptance = resolveEffectiveAcceptance({
1029
1183
  explicit: options.acceptance,
1030
1184
  agentName,
1185
+ acceptanceRole: agent.acceptanceRole,
1031
1186
  task,
1032
1187
  mode: options.acceptanceContext?.mode ?? "single",
1033
1188
  async: options.acceptanceContext?.async,
@@ -1039,7 +1194,13 @@ export async function runSync(
1039
1194
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
1040
1195
  const skillNames = options.skills ?? agent.skills ?? [];
1041
1196
  const skillCwd = options.cwd ?? runtimeCwd;
1042
- const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, skillCwd, runtimeCwd);
1197
+ const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(
1198
+ skillNames,
1199
+ skillCwd,
1200
+ runtimeCwd,
1201
+ agent.skillPath,
1202
+ agent.filePath ? path.dirname(agent.filePath) : skillCwd,
1203
+ );
1043
1204
  if (skillNames.some((skill) => skill.trim() === "pi-subagents") && missingSkills.includes("pi-subagents")) {
1044
1205
  return {
1045
1206
  agent: agentName,
@@ -1059,7 +1220,7 @@ export async function runSync(
1059
1220
  if (memoryInjection) {
1060
1221
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
1061
1222
  }
1062
- systemPrompt = injectOutputPathSystemPrompt(systemPrompt, options.outputPath);
1223
+ systemPrompt = injectOutputPathSystemPrompt(systemPrompt, options.outputPath, agent);
1063
1224
 
1064
1225
  const candidates = buildModelCandidates(
1065
1226
  options.modelOverride ?? agent.model,
@@ -1100,12 +1261,73 @@ export async function runSync(
1100
1261
  }
1101
1262
  }
1102
1263
 
1264
+ const persistResultMetadata = (target: SingleResult): void => {
1265
+ if (!artifactPathsResult || options.artifactConfig?.enabled === false || options.artifactConfig?.includeMetadata === false) return;
1266
+ writeMetadata(artifactPathsResult.metadataPath, {
1267
+ runId: options.runId,
1268
+ agent: agentName,
1269
+ task,
1270
+ exitCode: target.exitCode,
1271
+ usage: target.usage,
1272
+ model: target.model,
1273
+ attemptedModels: target.attemptedModels,
1274
+ modelAttempts: target.modelAttempts,
1275
+ durationMs: target.progressSummary?.durationMs,
1276
+ toolCount: target.progressSummary?.toolCount,
1277
+ error: target.error,
1278
+ acceptance: target.acceptance,
1279
+ ...(transcriptWriter ? { transcriptPath: artifactPathsResult.transcriptPath } : {}),
1280
+ transcriptError: target.transcriptError,
1281
+ skills: target.skills,
1282
+ skillsWarning: target.skillsWarning,
1283
+ timestamp: Date.now(),
1284
+ });
1285
+ };
1286
+
1287
+ const detachedAwareOptions: RunSyncOptions = options.onDetachedExit
1288
+ ? {
1289
+ ...options,
1290
+ onDetachedExit: (recoveredResult) => {
1291
+ void (async () => {
1292
+ const childWrittenOutput = options.outputPath
1293
+ ? extractChildWrittenOutput(recoveredResult.messages, options.outputPath, options.cwd ?? runtimeCwd)
1294
+ : undefined;
1295
+ recoveredResult.acceptance = await evaluateAcceptance({
1296
+ acceptance: effectiveAcceptance,
1297
+ output: acceptanceOutputByResult.get(recoveredResult) ?? recoveredResult.finalOutput ?? "",
1298
+ fileOutput: childWrittenOutput !== undefined && options.outputPath
1299
+ ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only" }
1300
+ : undefined,
1301
+ cwd: options.cwd ?? runtimeCwd,
1302
+ });
1303
+ const acceptanceFailure = acceptanceFailureMessage(recoveredResult.acceptance);
1304
+ stripAcceptanceReportsFromMessages(recoveredResult.messages);
1305
+ if (acceptanceFailure && recoveredResult.acceptance.explicit && recoveredResult.exitCode === 0) {
1306
+ recoveredResult.exitCode = 1;
1307
+ recoveredResult.error = recoveredResult.error ? `${recoveredResult.error}\n${acceptanceFailure}` : acceptanceFailure;
1308
+ if (recoveredResult.progress) {
1309
+ recoveredResult.progress.status = "failed";
1310
+ recoveredResult.progress.error = recoveredResult.error;
1311
+ }
1312
+ }
1313
+ persistResultMetadata(recoveredResult);
1314
+ options.onDetachedExit?.(recoveredResult);
1315
+ })().catch((error) => {
1316
+ const message = error instanceof Error ? error.message : String(error);
1317
+ recoveredResult.exitCode = 1;
1318
+ recoveredResult.error = recoveredResult.error ? `${recoveredResult.error}\nAcceptance evaluation failed: ${message}` : `Acceptance evaluation failed: ${message}`;
1319
+ options.onDetachedExit?.(recoveredResult);
1320
+ });
1321
+ },
1322
+ }
1323
+ : options;
1324
+
1103
1325
  let lastResult: SingleResult | undefined;
1104
1326
  const modelsToTry = candidates.length > 0 ? candidates : [undefined];
1105
1327
  for (let i = 0; i < modelsToTry.length; i++) {
1106
1328
  const candidate = modelsToTry[i];
1107
1329
  const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
1108
- const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, options, {
1330
+ const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, detachedAwareOptions, {
1109
1331
  sessionEnabled,
1110
1332
  systemPrompt,
1111
1333
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
@@ -1132,7 +1354,7 @@ export async function runSync(
1132
1354
  usage: { ...result.usage },
1133
1355
  };
1134
1356
  modelAttempts.push(attempt);
1135
- if (result.timedOut || result.turnBudgetExceeded) {
1357
+ if (result.detached || result.timedOut || result.turnBudgetExceeded) {
1136
1358
  break;
1137
1359
  }
1138
1360
  if (attemptSucceeded) {
@@ -1176,27 +1398,6 @@ export async function runSync(
1176
1398
  if (options.artifactConfig?.includeOutput !== false) {
1177
1399
  writeArtifact(artifactPathsResult.outputPath, artifactOutputByResult.get(result) ?? result.finalOutput ?? "");
1178
1400
  }
1179
- if (options.artifactConfig?.includeMetadata !== false) {
1180
- writeMetadata(artifactPathsResult.metadataPath, {
1181
- runId: options.runId,
1182
- agent: agentName,
1183
- task,
1184
- exitCode: result.exitCode,
1185
- usage: result.usage,
1186
- model: result.model,
1187
- attemptedModels: result.attemptedModels,
1188
- modelAttempts: result.modelAttempts,
1189
- durationMs: result.progressSummary?.durationMs,
1190
- toolCount: result.progressSummary?.toolCount,
1191
- error: result.error,
1192
- ...(transcriptWriter ? { transcriptPath: artifactPathsResult.transcriptPath } : {}),
1193
- transcriptError: result.transcriptError,
1194
- skills: result.skills,
1195
- skillsWarning: result.skillsWarning,
1196
- timestamp: Date.now(),
1197
- });
1198
- }
1199
-
1200
1401
  if (options.maxOutput) {
1201
1402
  const config = { ...DEFAULT_MAX_OUTPUT, ...options.maxOutput };
1202
1403
  const truncationResult = truncateOutput(result.finalOutput ?? "", config, artifactPathsResult.outputPath);
@@ -1215,15 +1416,27 @@ export async function runSync(
1215
1416
  if (sessionFile) result.sessionFile = sessionFile;
1216
1417
  }
1217
1418
 
1218
- result.acceptance = result.timedOut
1219
- ? buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "timeout", message: "Acceptance was not evaluated because the subagent timed out." })
1220
- : result.turnBudgetExceeded
1221
- ? buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "turn-budget", message: "Acceptance was not evaluated because the subagent exceeded its turn budget." })
1222
- : await evaluateAcceptance({
1419
+ const childWrittenOutput = options.outputPath
1420
+ ? extractChildWrittenOutput(result.messages, options.outputPath, options.cwd ?? runtimeCwd)
1421
+ : undefined;
1422
+ if (result.detached) {
1423
+ result.acceptance = buildPendingAcceptanceLedger(effectiveAcceptance);
1424
+ } else if (result.stopped) {
1425
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "stopped", message: "Acceptance was not evaluated because the subagent was stopped." });
1426
+ } else if (result.timedOut) {
1427
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "timeout", message: "Acceptance was not evaluated because the subagent timed out." });
1428
+ } else if (result.turnBudgetExceeded) {
1429
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "turn-budget", message: "Acceptance was not evaluated because the subagent exceeded its turn budget." });
1430
+ } else {
1431
+ result.acceptance = await evaluateAcceptance({
1223
1432
  acceptance: effectiveAcceptance,
1224
1433
  output: acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "",
1434
+ fileOutput: childWrittenOutput !== undefined && options.outputPath
1435
+ ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only" }
1436
+ : undefined,
1225
1437
  cwd: options.cwd ?? runtimeCwd,
1226
1438
  });
1439
+ }
1227
1440
  const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
1228
1441
  stripAcceptanceReportsFromMessages(result.messages);
1229
1442
  if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted && !result.timedOut) {
@@ -1234,6 +1447,7 @@ export async function runSync(
1234
1447
  result.progress.error = result.error;
1235
1448
  }
1236
1449
  }
1450
+ persistResultMetadata(result);
1237
1451
 
1238
1452
  return result;
1239
1453
  }