@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.0

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 (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
+ import type { Usage } from "@oh-my-pi/pi-ai";
10
11
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
12
  import type { Rule } from "../capability/rule";
12
13
  import { ModelRegistry } from "../config/model-registry";
@@ -26,8 +27,9 @@ import type { MCPManager } from "../mcp/manager";
26
27
  import type { MnemopiSessionState } from "../mnemopi/state";
27
28
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
28
29
  import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
30
+ import { AgentLifecycleManager } from "../registry/agent-lifecycle";
29
31
  import { AgentRegistry } from "../registry/agent-registry";
30
- import { createAgentSession, discoverAuthStorage } from "../sdk";
32
+ import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
31
33
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
32
34
  import type { ArtifactManager } from "../session/artifacts";
33
35
  import type { AuthStorage } from "../session/auth-storage";
@@ -35,6 +37,7 @@ import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
35
37
  import { SessionManager } from "../session/session-manager";
36
38
  import { truncateTail } from "../session/streaming-output";
37
39
  import type { ContextFileEntry } from "../tools";
40
+ import { isIrcEnabled } from "../tools/irc";
38
41
  import { normalizeSchema } from "../tools/jtd-to-json-schema";
39
42
  import {
40
43
  buildOutputValidator,
@@ -63,6 +66,30 @@ import {
63
66
 
64
67
  const MCP_CALL_TIMEOUT_MS = 60_000;
65
68
 
69
+ /**
70
+ * Soft per-agent request budgets (assistant requests per run). When a subagent
71
+ * crosses its budget it receives ONE steering notice asking it to wrap up; at
72
+ * 1.5x the budget the run is aborted gracefully so partial output is salvaged.
73
+ * The `default` key applies to agents without an explicit entry and can be
74
+ * overridden via the `task.softRequestBudget` setting (0 disables the guard).
75
+ */
76
+ export const SOFT_REQUEST_BUDGET: Record<string, number> = {
77
+ explore: 40,
78
+ quick_task: 40,
79
+ default: 90,
80
+ };
81
+
82
+ /** Steering notice injected once when a subagent crosses its soft request budget. */
83
+ export function buildBudgetNotice(requests: number): string {
84
+ return `[budget notice] You have used ${requests} requests in this run. Wrap up now: finish the current step and yield your final report.`;
85
+ }
86
+
87
+ /** Flatten whitespace and clip salvage text for the cancelled-child summary line. */
88
+ function formatSalvageSnippet(text: string, maxLength = 500): string {
89
+ const flattened = text.replace(/\s+/g, " ").trim();
90
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength - 1)}…` : flattened;
91
+ }
92
+
66
93
  /** Agent event types to forward for progress tracking. */
67
94
  const agentEventTypes = new Set<AgentEvent["type"]>([
68
95
  "agent_start",
@@ -94,9 +121,13 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
94
121
  function renderIrcPeerRoster(selfId: string): string {
95
122
  const peers = AgentRegistry.global()
96
123
  .list()
97
- .filter(ref => ref.id !== selfId && (ref.status === "running" || ref.status === "idle"));
98
- if (peers.length === 0) return "- (no other live agents)";
99
- return peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`).join("\n");
124
+ .filter(ref => ref.id !== selfId && ref.status !== "aborted");
125
+ if (peers.length === 0) return "- (no other agents)";
126
+ const lines = peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`);
127
+ if (peers.some(peer => peer.status === "idle" || peer.status === "parked")) {
128
+ lines.push("Idle/parked peers are not gone: messaging them wakes (or revives) them.");
129
+ }
130
+ return lines.join("\n");
100
131
  }
101
132
 
102
133
  function withAbortTimeout<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
@@ -152,6 +183,7 @@ export interface ExecutorOptions {
152
183
  agent: AgentDefinition;
153
184
  task: string;
154
185
  assignment?: string;
186
+ /** Shared background from the task call (`task.batch`), rendered into the subagent's system prompt. */
155
187
  context?: string;
156
188
  /**
157
189
  * The session's active overall plan, handed off so subagents spawned during
@@ -162,6 +194,7 @@ export interface ExecutorOptions {
162
194
  description?: string;
163
195
  index: number;
164
196
  id: string;
197
+ parentToolCallId?: string;
165
198
  modelOverride?: string | string[];
166
199
  /**
167
200
  * Active model selector of the parent session, used as an auth-aware fallback
@@ -185,8 +218,6 @@ export interface ExecutorOptions {
185
218
  sessionFile?: string | null;
186
219
  persistArtifacts?: boolean;
187
220
  artifactsDir?: string;
188
- /** Path to parent conversation context file */
189
- contextFile?: string;
190
221
  eventBus?: EventBus;
191
222
  contextFiles?: ContextFileEntry[];
192
223
  skills?: Skill[];
@@ -610,28 +641,67 @@ export function createSubagentSettings(
610
641
  });
611
642
  }
612
643
 
644
+ type AbortReason = "signal" | "terminate" | "timeout" | "budget";
645
+
646
+ /** Inputs for the run monitor driving one subagent assignment. */
647
+ interface RunMonitorArgs {
648
+ index: number;
649
+ id: string;
650
+ agent: AgentDefinition;
651
+ task: string;
652
+ assignment?: string;
653
+ description?: string;
654
+ modelOverride?: string | string[];
655
+ signal?: AbortSignal;
656
+ onProgress?: (progress: AgentProgress) => void;
657
+ eventBus?: EventBus;
658
+ parentToolCallId?: string;
659
+ sessionFile?: string;
660
+ /** Soft assistant-request budget; 0 disables the guard. */
661
+ softRequestBudget: number;
662
+ /** Wall-clock cap in ms; 0 disables the timer. */
663
+ maxRuntimeMs: number;
664
+ }
665
+
613
666
  /**
614
- * Run a single agent in-process.
667
+ * The run-monitoring core of {@link runSubprocess}: progress tracking, event
668
+ * processing, abort/budget machinery, usage accumulation, and output capture
669
+ * for one assignment run.
615
670
  */
616
- export async function runSubprocess(options: ExecutorOptions): Promise<SingleResult> {
617
- const {
618
- cwd,
619
- agent,
620
- task,
621
- assignment,
622
- index,
623
- id,
624
- worktree,
625
- modelOverride,
626
- thinkingLevel,
627
- outputSchema,
628
- enableLsp,
629
- signal,
630
- onProgress,
631
- } = options;
671
+ interface SubagentRunMonitor {
672
+ readonly progress: AgentProgress;
673
+ /** Fires when the run was asked to stop (caller signal, timeout, budget, terminate). */
674
+ readonly abortSignal: AbortSignal;
675
+ readonly accumulatedUsage: Usage;
676
+ hasUsage(): boolean;
677
+ yieldCalled(): boolean;
678
+ runtimeLimitExceeded(): boolean;
679
+ /** True when the abort carries a precise external reason (signal / wall-clock / budget). */
680
+ hasExplicitAbortReason(): boolean;
681
+ /** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */
682
+ isAbortedRun(): boolean;
683
+ requestAbort(reason: AbortReason): void;
684
+ resolveSignalAbortReason(): string;
685
+ resolveAbortReasonText(): string;
686
+ setActiveSession(session: AgentSession | null): void;
687
+ /** Return and clear the active session reference. */
688
+ takeActiveSession(): AgentSession | null;
689
+ /** Subscribe the monitor to a session's events. Returns the unsubscribe function. */
690
+ attach(session: AgentSession): () => void;
691
+ /** Best-effort capture of the last assistant text for cancelled-run salvage. */
692
+ captureSalvage(session: AgentSession): void;
693
+ lastAssistantSalvageText(): string | undefined;
694
+ /** Final raw output: end-of-run assistant text when available, else accumulated chunks. */
695
+ rawOutput(): string;
696
+ scheduleProgress(flush?: boolean): void;
697
+ /** Stop processing events and clear listeners/timers. Call once the run settled. */
698
+ finish(): void;
699
+ }
700
+
701
+ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
702
+ const { index, id, agent, task, assignment, signal, onProgress, softRequestBudget, maxRuntimeMs } = args;
632
703
  const startTime = Date.now();
633
704
 
634
- // Initialize progress
635
705
  const progress: AgentProgress = {
636
706
  index,
637
707
  id,
@@ -640,109 +710,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
640
710
  status: "running",
641
711
  task,
642
712
  assignment,
643
- description: options.description,
713
+ description: args.description,
644
714
  lastIntent: undefined,
645
715
  recentTools: [],
646
716
  recentOutput: [],
647
717
  toolCount: 0,
718
+ requests: 0,
648
719
  tokens: 0,
649
720
  cost: 0,
650
721
  durationMs: 0,
651
- modelOverride,
722
+ modelOverride: args.modelOverride,
652
723
  };
653
724
 
654
- // Check if already aborted
655
- if (signal?.aborted) {
656
- return {
657
- index,
658
- id,
659
- agent: agent.name,
660
- agentSource: agent.source,
661
- task,
662
- assignment,
663
- description: options.description,
664
- exitCode: 1,
665
- output: "",
666
- stderr: "Cancelled before start",
667
- truncated: false,
668
- durationMs: 0,
669
- tokens: 0,
670
- modelOverride,
671
- error: "Cancelled before start",
672
- aborted: true,
673
- abortReason: "Cancelled before start",
674
- };
675
- }
676
-
677
- // Set up artifact paths and write input file upfront if artifacts dir provided
678
- let subtaskSessionFile: string | undefined;
679
- if (options.artifactsDir) {
680
- subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
681
- }
682
-
683
- const settings = options.settings ?? Settings.isolated();
684
- const subagentSettings = createSubagentSettings(
685
- settings,
686
- agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
687
- );
688
- const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
689
- const maxRuntimeMs = Math.max(
690
- 0,
691
- Math.trunc(Number(options.maxRuntimeMs ?? settings.get("task.maxRuntimeMs") ?? 0) || 0),
692
- );
693
- const parentDepth = options.taskDepth ?? 0;
694
- const childDepth = parentDepth + 1;
695
- const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
696
-
697
- // Add tools if specified
698
- let toolNames: string[] | undefined;
699
- if (agent.tools && agent.tools.length > 0) {
700
- toolNames = agent.tools;
701
- // Auto-include task tool if spawns defined but task not in tools
702
- if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
703
- toolNames = [...toolNames, "task"];
704
- }
705
- }
706
-
707
- if (atMaxDepth && toolNames?.includes("task")) {
708
- toolNames = toolNames.filter(name => name !== "task");
709
- }
710
- // IRC is always available; the COOP prompt section advertises it, so a restricted
711
- // whitelist must still carry `irc` for the subagent to actually use it.
712
- if (toolNames && !toolNames.includes("irc")) {
713
- toolNames = [...toolNames, "irc"];
714
- }
715
- if (toolNames?.includes("exec")) {
716
- const allowEvalPy = settings.get("eval.py") ?? true;
717
- const allowEvalJs = settings.get("eval.js") ?? true;
718
- const expanded = toolNames.filter(name => name !== "exec");
719
- if (allowEvalPy || allowEvalJs) expanded.push("eval");
720
- expanded.push("bash");
721
- toolNames = Array.from(new Set(expanded));
722
- }
723
-
724
- const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
725
- const sessionFile = subtaskSessionFile ?? null;
726
- const spawnsEnv = atMaxDepth
727
- ? ""
728
- : agent.spawns === undefined
729
- ? ""
730
- : agent.spawns === "*"
731
- ? "*"
732
- : agent.spawns.join(",");
733
-
734
- const lspEnabled = enableLsp ?? true;
735
- const ircEnabled = subagentSettings.get("irc.enabled") === true;
736
- const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
737
- const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
738
-
739
725
  const outputChunks: string[] = [];
740
726
  const finalOutputChunks: string[] = [];
741
727
  const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
742
728
  let recentOutputTail = "";
743
- let stderr = "";
744
729
  let resolved = false;
745
- type AbortReason = "signal" | "terminate" | "timeout";
746
730
  let abortSent = false;
747
731
  let abortReason: AbortReason | undefined;
748
732
  let runtimeLimitExceeded = false;
@@ -751,11 +735,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
751
735
  const abortController = new AbortController();
752
736
  const abortSignal = abortController.signal;
753
737
  let activeSession: AgentSession | null = null;
754
- let unsubscribe: (() => void) | null = null;
755
738
  let yieldCalled = false;
756
739
 
757
740
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
758
- const accumulatedUsage = {
741
+ const accumulatedUsage: Usage = {
759
742
  input: 0,
760
743
  output: 0,
761
744
  cacheRead: 0,
@@ -764,11 +747,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
764
747
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
765
748
  };
766
749
  let hasUsage = false;
750
+ let budgetSteerSent = false;
751
+ let budgetLimitExceeded = false;
752
+ let lastAssistantSalvageText: string | undefined;
767
753
 
768
754
  const requestAbort = (reason: AbortReason) => {
769
755
  if (reason === "timeout") {
770
756
  runtimeLimitExceeded = true;
771
757
  }
758
+ if (reason === "budget") {
759
+ budgetLimitExceeded = true;
760
+ }
772
761
  if (abortSent) {
773
762
  if (reason === "signal" && abortReason !== "signal" && abortReason !== "timeout") {
774
763
  abortReason = "signal";
@@ -785,11 +774,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
785
774
  };
786
775
 
787
776
  // Handle abort signal
788
- const onAbort = () => {
789
- if (!resolved) requestAbort("signal");
790
- };
791
777
  if (signal) {
792
- signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
778
+ signal.addEventListener(
779
+ "abort",
780
+ () => {
781
+ if (!resolved) requestAbort("signal");
782
+ },
783
+ { once: true, signal: listenerSignal },
784
+ );
793
785
  }
794
786
 
795
787
  // Wall-clock hard limit. Defense-in-depth for the case where a provider stream
@@ -825,6 +817,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
825
817
  if (runtimeLimitExceeded) {
826
818
  return `Subagent runtime limit exceeded (task.maxRuntimeMs=${maxRuntimeMs})`;
827
819
  }
820
+ if (budgetLimitExceeded) {
821
+ return `Soft request budget exceeded (${progress.requests} requests; budget ${softRequestBudget})`;
822
+ }
828
823
  return resolveSignalAbortReason();
829
824
  };
830
825
  const PROGRESS_COALESCE_MS = 150;
@@ -834,15 +829,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
834
829
  const emitProgressNow = () => {
835
830
  progress.durationMs = Date.now() - startTime;
836
831
  onProgress?.({ ...progress });
837
- if (options.eventBus) {
838
- options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
832
+ if (args.eventBus) {
833
+ args.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
839
834
  index,
840
835
  agent: agent.name,
841
836
  agentSource: agent.source,
842
837
  task,
838
+ parentToolCallId: args.parentToolCallId,
843
839
  assignment,
844
840
  progress: { ...progress },
845
- sessionFile: subtaskSessionFile,
841
+ sessionFile: args.sessionFile,
846
842
  });
847
843
  }
848
844
  lastProgressEmitMs = Date.now();
@@ -922,20 +918,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
922
918
  progress.recentOutput = [];
923
919
  };
924
920
 
921
+ const emitSubagentEvent = (event: AgentSessionEvent) => {
922
+ if (!args.eventBus) return;
923
+ args.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
924
+ id,
925
+ event,
926
+ });
927
+ };
928
+
925
929
  const processEvent = (event: AgentEvent) => {
926
930
  if (resolved) return;
927
-
928
- if (options.eventBus) {
929
- options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
930
- index,
931
- agent: agent.name,
932
- agentSource: agent.source,
933
- task,
934
- assignment,
935
- event,
936
- });
937
- }
938
-
939
931
  const now = Date.now();
940
932
  let flushProgress = false;
941
933
 
@@ -1080,6 +1072,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1080
1072
  case "message_end": {
1081
1073
  // Extract text from assistant and toolResult messages (not user prompts)
1082
1074
  const role = event.message?.role;
1075
+ if (role === "assistant") {
1076
+ progress.requests += 1;
1077
+ if (softRequestBudget > 0 && !abortSent) {
1078
+ if (progress.requests >= softRequestBudget * 1.5) {
1079
+ requestAbort("budget");
1080
+ } else if (!budgetSteerSent && progress.requests >= softRequestBudget) {
1081
+ budgetSteerSent = true;
1082
+ const steerSession = activeSession;
1083
+ if (steerSession) {
1084
+ void steerSession
1085
+ .sendUserMessage(buildBudgetNotice(progress.requests), { deliverAs: "steer" })
1086
+ .catch(err => {
1087
+ logger.warn("Subagent budget steer failed", {
1088
+ error: err instanceof Error ? err.message : String(err),
1089
+ });
1090
+ });
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1083
1095
  if (role === "assistant") {
1084
1096
  const messageContent =
1085
1097
  getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
@@ -1149,113 +1161,646 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1149
1161
  scheduleProgress(flushProgress);
1150
1162
  };
1151
1163
 
1152
- const runSubagent = async (): Promise<{
1153
- exitCode: number;
1154
- error?: string;
1155
- aborted?: boolean;
1156
- abortReason?: string;
1157
- durationMs: number;
1158
- }> => {
1159
- const sessionAbortController = new AbortController();
1160
- let exitCode = 0;
1161
- let error: string | undefined;
1162
- let aborted = false;
1163
- let abortReasonText: string | undefined;
1164
- const checkAbort = () => {
1165
- if (abortSignal.aborted) {
1166
- aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1167
- if (aborted) {
1168
- abortReasonText ??= resolveAbortReasonText();
1164
+ const attach = (session: AgentSession): (() => void) =>
1165
+ session.subscribe(event => {
1166
+ emitSubagentEvent(event);
1167
+ if (event.type === "auto_retry_start") {
1168
+ progress.retryState = {
1169
+ attempt: event.attempt,
1170
+ maxAttempts: event.maxAttempts,
1171
+ delayMs: event.delayMs,
1172
+ errorMessage: event.errorMessage,
1173
+ startedAtMs: Date.now(),
1174
+ };
1175
+ progress.retryFailure = undefined;
1176
+ scheduleProgress(true);
1177
+ return;
1178
+ }
1179
+ if (event.type === "auto_retry_end") {
1180
+ const attempt = progress.retryState?.attempt ?? event.attempt;
1181
+ progress.retryState = undefined;
1182
+ if (!event.success) {
1183
+ progress.retryFailure = {
1184
+ attempt,
1185
+ errorMessage: event.finalError ?? "Auto-retry failed",
1186
+ };
1169
1187
  }
1170
- exitCode = 1;
1171
- throw new ToolAbortError();
1188
+ scheduleProgress(true);
1189
+ return;
1172
1190
  }
1173
- };
1174
- const awaitAbortable = async <T>(promise: Promise<T>): Promise<T> => {
1175
- checkAbort();
1176
- const { promise: abortPromise, reject } = Promise.withResolvers<never>();
1177
- const onAbort = () => {
1191
+ if (isAgentEvent(event)) {
1178
1192
  try {
1179
- checkAbort();
1193
+ processEvent(event);
1180
1194
  } catch (err) {
1181
- reject(err);
1195
+ logger.error("Subagent event processing failed", {
1196
+ error: err instanceof Error ? err.message : String(err),
1197
+ });
1198
+ requestAbort("terminate");
1182
1199
  }
1183
- };
1184
- abortSignal.addEventListener("abort", onAbort, { once: true });
1185
- try {
1186
- return await Promise.race([promise, abortPromise]);
1187
- } finally {
1188
- abortSignal.removeEventListener("abort", onAbort);
1189
1200
  }
1190
- };
1201
+ });
1191
1202
 
1203
+ const captureSalvage = (session: AgentSession): void => {
1204
+ // Best-effort salvage: capture the last assistant text so
1205
+ // cancelled/aborted children can surface "last activity" instead of
1206
+ // "(no output)".
1192
1207
  try {
1193
- checkAbort();
1194
- // Pin authStorage to modelRegistry.authStorage — mirrors the createAgentSession invariant.
1195
- const registryFromParent = options.modelRegistry !== undefined;
1196
- const modelRegistry =
1197
- options.modelRegistry ??
1198
- new ModelRegistry(options.authStorage ?? (await awaitAbortable(discoverAuthStorage())));
1199
- const authStorage = modelRegistry.authStorage;
1200
- if (options.authStorage && options.authStorage !== authStorage) {
1201
- throw new Error(
1202
- "options.authStorage and options.modelRegistry.authStorage must be the same instance when both are provided",
1203
- );
1204
- }
1205
- checkAbort();
1206
- if (!registryFromParent) {
1207
- await awaitAbortable(modelRegistry.refresh());
1208
- } else {
1209
- logger.debug("runSubagent: reusing parent modelRegistry; skipping refresh");
1208
+ const lastContent = session.getLastAssistantMessage()?.content;
1209
+ if (Array.isArray(lastContent)) {
1210
+ const text = lastContent
1211
+ .map(block => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
1212
+ .filter(Boolean)
1213
+ .join("\n");
1214
+ if (text.trim()) {
1215
+ lastAssistantSalvageText = text;
1216
+ }
1210
1217
  }
1211
- checkAbort();
1218
+ } catch {
1219
+ // Salvage is best-effort; partial sessions may not implement it
1220
+ }
1221
+ };
1212
1222
 
1213
- const {
1214
- model,
1215
- thinkingLevel: resolvedThinkingLevel,
1216
- explicitThinkingLevel,
1217
- authFallbackUsed,
1218
- } = await awaitAbortable(
1219
- resolveModelOverrideWithAuthFallback(
1220
- modelPatterns,
1221
- options.parentActiveModelPattern,
1222
- modelRegistry,
1223
- settings,
1224
- ),
1225
- );
1226
- if (authFallbackUsed && model) {
1227
- logger.warn("Subagent model has no working credentials; falling back to parent session model", {
1228
- requested: modelPatterns,
1229
- parentModel: options.parentActiveModelPattern,
1230
- resolvedProvider: model.provider,
1231
- resolvedModel: model.id,
1232
- });
1233
- }
1234
- if (model?.contextWindow && model.contextWindow > 0) {
1235
- progress.contextWindow = model.contextWindow;
1223
+ return {
1224
+ progress,
1225
+ abortSignal,
1226
+ accumulatedUsage,
1227
+ hasUsage: () => hasUsage,
1228
+ yieldCalled: () => yieldCalled,
1229
+ runtimeLimitExceeded: () => runtimeLimitExceeded,
1230
+ hasExplicitAbortReason: () => abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded,
1231
+ isAbortedRun: () =>
1232
+ abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
1233
+ requestAbort,
1234
+ resolveSignalAbortReason,
1235
+ resolveAbortReasonText,
1236
+ setActiveSession: session => {
1237
+ activeSession = session;
1238
+ },
1239
+ takeActiveSession: () => {
1240
+ const session = activeSession;
1241
+ activeSession = null;
1242
+ return session;
1243
+ },
1244
+ attach,
1245
+ captureSalvage,
1246
+ lastAssistantSalvageText: () => lastAssistantSalvageText,
1247
+ rawOutput: () => (finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("")),
1248
+ scheduleProgress,
1249
+ finish: () => {
1250
+ resolved = true;
1251
+ listenerController.abort();
1252
+ if (runtimeTimeoutId !== undefined) {
1253
+ clearTimeout(runtimeTimeoutId);
1254
+ runtimeTimeoutId = undefined;
1236
1255
  }
1237
- if (model) {
1238
- progress.resolvedModel = explicitThinkingLevel
1239
- ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1240
- : `${model.provider}/${model.id}`;
1256
+ if (progressTimeoutId) {
1257
+ clearTimeout(progressTimeoutId);
1258
+ progressTimeoutId = null;
1241
1259
  }
1242
- const effectiveThinkingLevel = explicitThinkingLevel
1243
- ? resolvedThinkingLevel
1244
- : (thinkingLevel ?? resolvedThinkingLevel);
1260
+ },
1261
+ };
1262
+ }
1245
1263
 
1246
- const sessionManager = sessionFile
1247
- ? await awaitAbortable(SessionManager.open(sessionFile))
1248
- : SessionManager.inMemory(worktree ?? cwd);
1249
- if (options.parentArtifactManager) {
1250
- sessionManager.adoptArtifactManager(options.parentArtifactManager);
1251
- }
1264
+ interface DriveOutcome {
1265
+ exitCode: number;
1266
+ error?: string;
1267
+ aborted: boolean;
1268
+ abortReasonText?: string;
1269
+ }
1252
1270
 
1253
- const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
1254
- const enableMCP = !options.mcpManager;
1271
+ const MAX_YIELD_RETRIES = 3;
1255
1272
 
1256
- // Derive subagent-scoped telemetry from the parent's config so the
1257
- // child loop's spans nest under the parent's active execute_tool span
1258
- // (OTEL context propagation handles parent linkage automatically),
1273
+ /**
1274
+ * Drive one assignment through a live session: send the prompt, wait for idle,
1275
+ * remind the agent to `yield` (up to {@link MAX_YIELD_RETRIES} times), then
1276
+ * classify the terminal assistant state.
1277
+ */
1278
+ async function driveSessionToYield(
1279
+ session: AgentSession,
1280
+ monitor: SubagentRunMonitor,
1281
+ task: string,
1282
+ ): Promise<DriveOutcome> {
1283
+ const abortSignal = monitor.abortSignal;
1284
+ let exitCode = 0;
1285
+ let error: string | undefined;
1286
+ let aborted = false;
1287
+ let abortReasonText: string | undefined;
1288
+ const checkAbort = () => {
1289
+ if (abortSignal.aborted) {
1290
+ aborted = monitor.isAbortedRun();
1291
+ if (aborted) {
1292
+ abortReasonText ??= monitor.resolveAbortReasonText();
1293
+ }
1294
+ exitCode = 1;
1295
+ throw new ToolAbortError();
1296
+ }
1297
+ };
1298
+ const awaitAbortable = async <T>(promise: Promise<T>): Promise<T> => {
1299
+ checkAbort();
1300
+ const { promise: abortPromise, reject } = Promise.withResolvers<never>();
1301
+ const onAbort = () => {
1302
+ try {
1303
+ checkAbort();
1304
+ } catch (err) {
1305
+ reject(err);
1306
+ }
1307
+ };
1308
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1309
+ try {
1310
+ return await Promise.race([promise, abortPromise]);
1311
+ } finally {
1312
+ abortSignal.removeEventListener("abort", onAbort);
1313
+ }
1314
+ };
1315
+
1316
+ try {
1317
+ await awaitAbortable(session.prompt(task, { attribution: "agent" }));
1318
+ await awaitAbortable(session.waitForIdle());
1319
+
1320
+ const reminderToolChoice = buildNamedToolChoice("yield", session.model);
1321
+
1322
+ let retryCount = 0;
1323
+ while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
1324
+ // Skip reminders when the model returned a terminal error (e.g.
1325
+ // rate-limit cap hit, auth failure). Re-prompting would just
1326
+ // hit the same wall, multiplying the failure noise without
1327
+ // any chance of producing a yield.
1328
+ const lastBeforeReminder = session.getLastAssistantMessage();
1329
+ if (lastBeforeReminder?.stopReason === "error") break;
1330
+ try {
1331
+ retryCount++;
1332
+ const reminder = prompt.render(submitReminderTemplate, {
1333
+ retryCount,
1334
+ maxRetries: MAX_YIELD_RETRIES,
1335
+ });
1336
+
1337
+ const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
1338
+ await awaitAbortable(
1339
+ session.prompt(reminder, {
1340
+ attribution: "agent",
1341
+ synthetic: true,
1342
+ ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1343
+ }),
1344
+ );
1345
+ await awaitAbortable(session.waitForIdle());
1346
+ } catch (err) {
1347
+ if (abortSignal.aborted || err instanceof ToolAbortError) {
1348
+ // Benign control-flow exit — user cancel (^C) or compaction aborting
1349
+ // pending operations both surface here as ToolAbortError. The outer
1350
+ // catch and finally already mark the run aborted; logging at ERROR
1351
+ // would spam operator dashboards with non-failures.
1352
+ logger.debug("Subagent prompt aborted");
1353
+ } else {
1354
+ logger.error("Subagent prompt failed", {
1355
+ error: err instanceof Error ? err.message : String(err),
1356
+ });
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ await awaitAbortable(session.waitForIdle());
1362
+
1363
+ const lastAssistant = session.getLastAssistantMessage();
1364
+ if (lastAssistant) {
1365
+ if (lastAssistant.stopReason === "aborted") {
1366
+ aborted = monitor.isAbortedRun();
1367
+ if (aborted) {
1368
+ // A real caller signal or the wall-clock timer carries a precise
1369
+ // reason (signal.reason / "runtime limit exceeded"). An internal
1370
+ // turn abort does NOT — prefer the assistant message's own
1371
+ // errorMessage ("Request was aborted" or a specific stream error)
1372
+ // over the misleading "Cancelled by caller".
1373
+ abortReasonText ??= monitor.hasExplicitAbortReason()
1374
+ ? monitor.resolveAbortReasonText()
1375
+ : lastAssistant.errorMessage?.trim() || monitor.resolveAbortReasonText();
1376
+ }
1377
+ exitCode = 1;
1378
+ } else if (lastAssistant.stopReason === "error") {
1379
+ exitCode = 1;
1380
+ error ??= lastAssistant.errorMessage || "Subagent failed";
1381
+ }
1382
+ }
1383
+ } catch (err) {
1384
+ exitCode = 1;
1385
+ if (!abortSignal.aborted) {
1386
+ error = err instanceof Error ? err.stack || err.message : String(err);
1387
+ }
1388
+ } finally {
1389
+ if (abortSignal.aborted) {
1390
+ aborted = monitor.isAbortedRun();
1391
+ if (aborted) {
1392
+ abortReasonText ??= monitor.resolveAbortReasonText();
1393
+ }
1394
+ if (exitCode === 0) exitCode = 1;
1395
+ }
1396
+ }
1397
+
1398
+ return { exitCode, error, aborted, abortReasonText };
1399
+ }
1400
+
1401
+ interface FinalizeRunArgs {
1402
+ monitor: SubagentRunMonitor;
1403
+ done: { exitCode: number; error?: string; aborted?: boolean; abortReason?: string; durationMs: number };
1404
+ index: number;
1405
+ id: string;
1406
+ agent: AgentDefinition;
1407
+ task: string;
1408
+ assignment?: string;
1409
+ description?: string;
1410
+ modelOverride?: string | string[];
1411
+ outputSchema?: unknown;
1412
+ signal?: AbortSignal;
1413
+ artifactsDir?: string;
1414
+ eventBus?: EventBus;
1415
+ parentToolCallId?: string;
1416
+ sessionFile?: string;
1417
+ startTime: number;
1418
+ }
1419
+
1420
+ /**
1421
+ * Turn a settled run into a {@link SingleResult}: resolve the yield payload via
1422
+ * {@link finalizeSubprocessOutput}, salvage cancelled-run output, write the
1423
+ * `<id>.md` output artifact, flush final progress, and emit the lifecycle end
1424
+ * event.
1425
+ */
1426
+ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1427
+ const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args;
1428
+ const progress = monitor.progress;
1429
+ let exitCode = done.exitCode;
1430
+ let stderr = done.error ?? "";
1431
+
1432
+ // Use final output if available, otherwise accumulated output
1433
+ let rawOutput = monitor.rawOutput();
1434
+ const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
1435
+ const reportFindingDetails = progress.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
1436
+ const reportFindings: ReviewFinding[] | undefined = reportFindingDetails?.map(toReviewFinding);
1437
+ const finalized = finalizeSubprocessOutput({
1438
+ rawOutput,
1439
+ exitCode,
1440
+ stderr,
1441
+ doneAborted: Boolean(done.aborted),
1442
+ signalAborted: Boolean(signal?.aborted),
1443
+ yieldItems,
1444
+ reportFindings,
1445
+ outputSchema: args.outputSchema,
1446
+ });
1447
+ rawOutput = finalized.rawOutput;
1448
+ exitCode = finalized.exitCode;
1449
+ stderr = finalized.stderr;
1450
+ // Salvage for cancelled/aborted children that produced no completed output:
1451
+ // surface the last assistant text + stats instead of "(no output)" so the
1452
+ // parent doesn't redo work the child already finished.
1453
+ const salvageText = monitor.lastAssistantSalvageText();
1454
+ if (
1455
+ (done.aborted || signal?.aborted || monitor.runtimeLimitExceeded()) &&
1456
+ !rawOutput.trim() &&
1457
+ salvageText !== undefined
1458
+ ) {
1459
+ rawOutput = `[cancelled after ${progress.requests} req, ${progress.tokens} tok — last activity: "${formatSalvageSnippet(salvageText)}"]`;
1460
+ }
1461
+ const lastYield = yieldItems?.[yieldItems.length - 1];
1462
+ const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
1463
+ const { abortedViaYield, hasYield } = finalized;
1464
+ const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
1465
+ maxBytes: MAX_OUTPUT_BYTES,
1466
+ maxLines: MAX_OUTPUT_LINES,
1467
+ });
1468
+
1469
+ // Write output artifact (input and jsonl already written in real-time)
1470
+ // Compute output metadata for agent:// URL integration
1471
+ let outputMeta: { lineCount: number; charCount: number } | undefined;
1472
+ let outputPath: string | undefined;
1473
+ if (args.artifactsDir) {
1474
+ outputPath = path.join(args.artifactsDir, `${id}.md`);
1475
+ try {
1476
+ await Bun.write(outputPath, rawOutput);
1477
+ outputMeta = {
1478
+ lineCount: rawOutput.split("\n").length,
1479
+ charCount: rawOutput.length,
1480
+ };
1481
+ } catch {
1482
+ // Non-fatal
1483
+ }
1484
+ }
1485
+
1486
+ // Update final progress. A wall-clock timeout always wins: if the runtime
1487
+ // limit fired we report aborted/failed regardless of whether a yield landed
1488
+ // while we were tearing the session down. The yield data is still surfaced
1489
+ // to the caller via `progress.extractedToolData`, but the exit status must
1490
+ // reflect the timeout so on-call doesn't mistake a stuck run for success.
1491
+ const runtimeLimitExceeded = monitor.runtimeLimitExceeded();
1492
+ if (runtimeLimitExceeded && exitCode === 0) {
1493
+ exitCode = 1;
1494
+ }
1495
+ const wasAborted =
1496
+ runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
1497
+ const finalAbortReason = wasAborted
1498
+ ? runtimeLimitExceeded
1499
+ ? monitor.resolveAbortReasonText()
1500
+ : abortedViaYield
1501
+ ? yieldAbortReason
1502
+ : (done.abortReason ??
1503
+ (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText()))
1504
+ : undefined;
1505
+ progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1506
+ monitor.scheduleProgress(true);
1507
+
1508
+ // Emit lifecycle end event after finalization so yield status is reflected
1509
+ if (args.eventBus) {
1510
+ args.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1511
+ id,
1512
+ agent: agent.name,
1513
+ parentToolCallId: args.parentToolCallId,
1514
+ agentSource: agent.source,
1515
+ description: args.description,
1516
+ status: progress.status as "completed" | "failed" | "aborted",
1517
+ sessionFile: args.sessionFile,
1518
+ index,
1519
+ });
1520
+ }
1521
+
1522
+ return {
1523
+ index,
1524
+ id,
1525
+ agent: agent.name,
1526
+ agentSource: agent.source,
1527
+ task,
1528
+ assignment,
1529
+ description: args.description,
1530
+ lastIntent: progress.lastIntent,
1531
+ exitCode,
1532
+ output: truncatedOutput,
1533
+ stderr,
1534
+ truncated: Boolean(truncated),
1535
+ durationMs: Date.now() - args.startTime,
1536
+ tokens: progress.tokens,
1537
+ requests: progress.requests,
1538
+ contextTokens: progress.contextTokens,
1539
+ contextWindow: progress.contextWindow,
1540
+ modelOverride,
1541
+ resolvedModel: progress.resolvedModel,
1542
+ error: exitCode !== 0 && stderr ? stderr : undefined,
1543
+ aborted: wasAborted,
1544
+ abortReason: finalAbortReason,
1545
+ usage: monitor.hasUsage() ? monitor.accumulatedUsage : undefined,
1546
+ outputPath,
1547
+ extractedToolData: progress.extractedToolData,
1548
+ retryFailure: progress.retryFailure,
1549
+ outputMeta,
1550
+ };
1551
+ }
1552
+
1553
+ /**
1554
+ * Run a single agent in-process.
1555
+ */
1556
+ export async function runSubprocess(options: ExecutorOptions): Promise<SingleResult> {
1557
+ const {
1558
+ cwd,
1559
+ agent,
1560
+ task,
1561
+ assignment,
1562
+ index,
1563
+ id,
1564
+ worktree,
1565
+ modelOverride,
1566
+ thinkingLevel,
1567
+ outputSchema,
1568
+ enableLsp,
1569
+ signal,
1570
+ onProgress,
1571
+ } = options;
1572
+ const startTime = Date.now();
1573
+
1574
+ // Check if already aborted
1575
+ if (signal?.aborted) {
1576
+ return {
1577
+ index,
1578
+ id,
1579
+ agent: agent.name,
1580
+ agentSource: agent.source,
1581
+ task,
1582
+ assignment,
1583
+ description: options.description,
1584
+ exitCode: 1,
1585
+ output: "",
1586
+ stderr: "Cancelled before start",
1587
+ truncated: false,
1588
+ durationMs: 0,
1589
+ tokens: 0,
1590
+ requests: 0,
1591
+ modelOverride,
1592
+ error: "Cancelled before start",
1593
+ aborted: true,
1594
+ abortReason: "Cancelled before start",
1595
+ };
1596
+ }
1597
+
1598
+ // Set up artifact paths and write input file upfront if artifacts dir provided
1599
+ let subtaskSessionFile: string | undefined;
1600
+ if (options.artifactsDir) {
1601
+ subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
1602
+ }
1603
+
1604
+ const settings = options.settings ?? Settings.isolated();
1605
+ const subagentSettings = createSubagentSettings(
1606
+ settings,
1607
+ agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
1608
+ );
1609
+ const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
1610
+ const maxRuntimeMs = Math.max(
1611
+ 0,
1612
+ Math.trunc(Number(options.maxRuntimeMs ?? settings.get("task.maxRuntimeMs") ?? 0) || 0),
1613
+ );
1614
+ // TTL before an adopted idle subagent is parked by the lifecycle manager.
1615
+ // <= 0 disables parking (the session stays live until process teardown).
1616
+ const agentIdleTtlMs = Math.trunc(Number(settings.get("task.agentIdleTtlMs") ?? 420_000) || 0);
1617
+ const configuredDefaultBudget = Math.max(
1618
+ 0,
1619
+ Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
1620
+ );
1621
+ const softRequestBudget =
1622
+ configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
1623
+ const parentDepth = options.taskDepth ?? 0;
1624
+ const childDepth = parentDepth + 1;
1625
+ const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
1626
+
1627
+ // Add tools if specified
1628
+ let toolNames: string[] | undefined;
1629
+ if (agent.tools && agent.tools.length > 0) {
1630
+ toolNames = agent.tools;
1631
+ // Auto-include task tool if spawns defined but task not in tools
1632
+ if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
1633
+ toolNames = [...toolNames, "task"];
1634
+ }
1635
+ }
1636
+
1637
+ if (atMaxDepth && toolNames?.includes("task")) {
1638
+ toolNames = toolNames.filter(name => name !== "task");
1639
+ }
1640
+ // IRC is always available; the COOP prompt section advertises it, so a restricted
1641
+ // whitelist must still carry `irc` for the subagent to actually use it.
1642
+ if (toolNames && !toolNames.includes("irc")) {
1643
+ toolNames = [...toolNames, "irc"];
1644
+ }
1645
+ if (toolNames?.includes("exec")) {
1646
+ const allowEvalPy = settings.get("eval.py") ?? true;
1647
+ const allowEvalJs = settings.get("eval.js") ?? true;
1648
+ const expanded = toolNames.filter(name => name !== "exec");
1649
+ if (allowEvalPy || allowEvalJs) expanded.push("eval");
1650
+ expanded.push("bash");
1651
+ toolNames = Array.from(new Set(expanded));
1652
+ }
1653
+
1654
+ const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
1655
+ const sessionFile = subtaskSessionFile ?? null;
1656
+ const spawnsEnv = atMaxDepth
1657
+ ? ""
1658
+ : agent.spawns === undefined
1659
+ ? ""
1660
+ : agent.spawns === "*"
1661
+ ? "*"
1662
+ : agent.spawns.join(",");
1663
+
1664
+ const lspEnabled = enableLsp ?? true;
1665
+ const ircEnabled = isIrcEnabled(subagentSettings, childDepth);
1666
+ const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
1667
+
1668
+ const monitor = createSubagentRunMonitor({
1669
+ index,
1670
+ id,
1671
+ agent,
1672
+ task,
1673
+ assignment,
1674
+ description: options.description,
1675
+ modelOverride,
1676
+ signal,
1677
+ onProgress,
1678
+ eventBus: options.eventBus,
1679
+ parentToolCallId: options.parentToolCallId,
1680
+ sessionFile: subtaskSessionFile,
1681
+ softRequestBudget,
1682
+ maxRuntimeMs,
1683
+ });
1684
+ const progress = monitor.progress;
1685
+ let unsubscribe: (() => void) | null = null;
1686
+ let reviveSession: (() => Promise<AgentSession>) | null = null;
1687
+ // Adopted (kept-alive) subagents flip registry status from session events on
1688
+ // later turns: revive/wake → running, turn drained → idle. The subscription
1689
+ // intentionally survives this run; a disposed session emits nothing, so it
1690
+ // needs no teardown.
1691
+ const installRegistryStatusSync = (target: AgentSession): void => {
1692
+ target.subscribe(event => {
1693
+ if (event.type === "agent_start") {
1694
+ AgentRegistry.global().setStatus(id, "running");
1695
+ } else if (event.type === "agent_end") {
1696
+ AgentRegistry.global().setStatus(id, "idle");
1697
+ }
1698
+ });
1699
+ };
1700
+
1701
+ const runSubagent = async (): Promise<{
1702
+ exitCode: number;
1703
+ error?: string;
1704
+ aborted?: boolean;
1705
+ abortReason?: string;
1706
+ durationMs: number;
1707
+ }> => {
1708
+ const sessionAbortController = new AbortController();
1709
+ const abortSignal = monitor.abortSignal;
1710
+ let exitCode = 0;
1711
+ let error: string | undefined;
1712
+ let aborted = false;
1713
+ let abortReasonText: string | undefined;
1714
+ const checkAbort = () => {
1715
+ if (abortSignal.aborted) {
1716
+ throw new ToolAbortError();
1717
+ }
1718
+ };
1719
+ const awaitAbortable = async <T>(promise: Promise<T>): Promise<T> => {
1720
+ checkAbort();
1721
+ const { promise: abortPromise, reject } = Promise.withResolvers<never>();
1722
+ const onAbort = () => {
1723
+ try {
1724
+ checkAbort();
1725
+ } catch (err) {
1726
+ reject(err);
1727
+ }
1728
+ };
1729
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1730
+ try {
1731
+ return await Promise.race([promise, abortPromise]);
1732
+ } finally {
1733
+ abortSignal.removeEventListener("abort", onAbort);
1734
+ }
1735
+ };
1736
+
1737
+ try {
1738
+ checkAbort();
1739
+ // Pin authStorage to modelRegistry.authStorage — mirrors the createAgentSession invariant.
1740
+ const registryFromParent = options.modelRegistry !== undefined;
1741
+ const modelRegistry =
1742
+ options.modelRegistry ??
1743
+ new ModelRegistry(options.authStorage ?? (await awaitAbortable(discoverAuthStorage())));
1744
+ const authStorage = modelRegistry.authStorage;
1745
+ if (options.authStorage && options.authStorage !== authStorage) {
1746
+ throw new Error(
1747
+ "options.authStorage and options.modelRegistry.authStorage must be the same instance when both are provided",
1748
+ );
1749
+ }
1750
+ checkAbort();
1751
+ if (!registryFromParent) {
1752
+ await awaitAbortable(modelRegistry.refresh());
1753
+ } else {
1754
+ logger.debug("runSubagent: reusing parent modelRegistry; skipping refresh");
1755
+ }
1756
+ checkAbort();
1757
+
1758
+ const {
1759
+ model,
1760
+ thinkingLevel: resolvedThinkingLevel,
1761
+ explicitThinkingLevel,
1762
+ authFallbackUsed,
1763
+ } = await awaitAbortable(
1764
+ resolveModelOverrideWithAuthFallback(
1765
+ modelPatterns,
1766
+ options.parentActiveModelPattern,
1767
+ modelRegistry,
1768
+ settings,
1769
+ ),
1770
+ );
1771
+ if (authFallbackUsed && model) {
1772
+ logger.warn("Subagent model has no working credentials; falling back to parent session model", {
1773
+ requested: modelPatterns,
1774
+ parentModel: options.parentActiveModelPattern,
1775
+ resolvedProvider: model.provider,
1776
+ resolvedModel: model.id,
1777
+ });
1778
+ }
1779
+ if (model?.contextWindow && model.contextWindow > 0) {
1780
+ progress.contextWindow = model.contextWindow;
1781
+ }
1782
+ if (model) {
1783
+ progress.resolvedModel = explicitThinkingLevel
1784
+ ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1785
+ : `${model.provider}/${model.id}`;
1786
+ }
1787
+ const effectiveThinkingLevel = explicitThinkingLevel
1788
+ ? resolvedThinkingLevel
1789
+ : (thinkingLevel ?? resolvedThinkingLevel);
1790
+
1791
+ const sessionManager = sessionFile
1792
+ ? await awaitAbortable(SessionManager.open(sessionFile))
1793
+ : SessionManager.inMemory(worktree ?? cwd);
1794
+ if (options.parentArtifactManager) {
1795
+ sessionManager.adoptArtifactManager(options.parentArtifactManager);
1796
+ }
1797
+
1798
+ const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
1799
+ const enableMCP = !options.mcpManager;
1800
+
1801
+ // Derive subagent-scoped telemetry from the parent's config so the
1802
+ // child loop's spans nest under the parent's active execute_tool span
1803
+ // (OTEL context propagation handles parent linkage automatically),
1259
1804
  // carry the subagent's own agent identity, and use the subagent's
1260
1805
  // own session id for `gen_ai.conversation.id`.
1261
1806
  const subagentAgentIdentity: AgentIdentity | undefined = options.parentTelemetry
@@ -1285,7 +1830,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1285
1830
 
1286
1831
  const { normalized: normalizedOutputSchema } = normalizeSchema(outputSchema);
1287
1832
 
1288
- const sessionPromise = createAgentSession({
1833
+ // Captured by the lifecycle reviver: rebuilding an equivalent session from
1834
+ // the same JSONL file re-invokes createAgentSession with the exact options
1835
+ // of the original run (same agent id, tools, model, system prompt,
1836
+ // artifacts dir) — only the SessionManager differs.
1837
+ const buildSubagentSessionOptions = (sessionManagerForRun: SessionManager): CreateAgentSessionOptions => ({
1289
1838
  cwd: worktree ?? cwd,
1290
1839
  authStorage,
1291
1840
  modelRegistry,
@@ -1310,7 +1859,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1310
1859
  planReferencePath: options.planReference?.path ?? "",
1311
1860
  worktree: worktree ?? "",
1312
1861
  outputSchema: normalizedOutputSchema,
1313
- contextFile: contextFileForPrompt,
1314
1862
  ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1315
1863
  ircSelfId: ircEnabled ? id : "",
1316
1864
  });
@@ -1318,7 +1866,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1318
1866
  ? [subagentPrompt]
1319
1867
  : [...defaultPrompt.slice(0, -1), subagentPrompt, defaultPrompt[defaultPrompt.length - 1]];
1320
1868
  },
1321
- sessionManager,
1869
+ sessionManager: sessionManagerForRun,
1322
1870
  hasUI: false,
1323
1871
  spawns: spawnsEnv,
1324
1872
  taskDepth: childDepth,
@@ -1336,6 +1884,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1336
1884
  telemetry: subagentTelemetry,
1337
1885
  parentEvalSessionId: options.parentEvalSessionId,
1338
1886
  });
1887
+
1888
+ const sessionPromise = createAgentSession(buildSubagentSessionOptions(sessionManager));
1339
1889
  let session: AgentSession;
1340
1890
  try {
1341
1891
  ({ session } = await awaitAbortable(sessionPromise));
@@ -1347,13 +1897,30 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1347
1897
  throw err;
1348
1898
  }
1349
1899
 
1350
- activeSession = session;
1900
+ monitor.setActiveSession(session);
1901
+ installRegistryStatusSync(session);
1902
+ if (sessionFile !== null && worktree === undefined) {
1903
+ // Lifecycle reviver: park closed the JSONL writer, so reopening takes
1904
+ // the single-writer lock cleanly and restores the full message history
1905
+ // (createAgentSession → agent.replaceMessages). Isolated runs are not
1906
+ // resumable (worktree is merged + cleaned) and never get a reviver.
1907
+ reviveSession = async () => {
1908
+ const reopened = await SessionManager.open(sessionFile);
1909
+ if (options.parentArtifactManager) {
1910
+ reopened.adoptArtifactManager(options.parentArtifactManager);
1911
+ }
1912
+ const { session: revived } = await createAgentSession(buildSubagentSessionOptions(reopened));
1913
+ installRegistryStatusSync(revived);
1914
+ return revived;
1915
+ };
1916
+ }
1351
1917
 
1352
1918
  // Emit lifecycle start event
1353
1919
  if (options.eventBus) {
1354
1920
  options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1355
1921
  id,
1356
1922
  agent: agent.name,
1923
+ parentToolCallId: options.parentToolCallId,
1357
1924
  agentSource: agent.source,
1358
1925
  description: options.description,
1359
1926
  status: "started",
@@ -1450,43 +2017,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1450
2017
  }
1451
2018
  }
1452
2019
 
1453
- const MAX_YIELD_RETRIES = 3;
1454
- unsubscribe = session.subscribe(event => {
1455
- if (event.type === "auto_retry_start") {
1456
- progress.retryState = {
1457
- attempt: event.attempt,
1458
- maxAttempts: event.maxAttempts,
1459
- delayMs: event.delayMs,
1460
- errorMessage: event.errorMessage,
1461
- startedAtMs: Date.now(),
1462
- };
1463
- progress.retryFailure = undefined;
1464
- scheduleProgress(true);
1465
- return;
1466
- }
1467
- if (event.type === "auto_retry_end") {
1468
- const attempt = progress.retryState?.attempt ?? event.attempt;
1469
- progress.retryState = undefined;
1470
- if (!event.success) {
1471
- progress.retryFailure = {
1472
- attempt,
1473
- errorMessage: event.finalError ?? "Auto-retry failed",
1474
- };
1475
- }
1476
- scheduleProgress(true);
1477
- return;
1478
- }
1479
- if (isAgentEvent(event)) {
1480
- try {
1481
- processEvent(event);
1482
- } catch (err) {
1483
- logger.error("Subagent event processing failed", {
1484
- error: err instanceof Error ? err.message : String(err),
1485
- });
1486
- requestAbort("terminate");
1487
- }
1488
- }
1489
- });
2020
+ unsubscribe = monitor.attach(session);
1490
2021
 
1491
2022
  checkAbort();
1492
2023
  // Autoload skills via sendCustomMessage (same mechanic as /skill:<name>)
@@ -1504,78 +2035,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1504
2035
  );
1505
2036
  }
1506
2037
  }
1507
- await awaitAbortable(session.prompt(task, { attribution: "agent" }));
1508
- await awaitAbortable(session.waitForIdle());
1509
-
1510
- const reminderToolChoice = buildNamedToolChoice("yield", session.model);
1511
-
1512
- let retryCount = 0;
1513
- while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
1514
- // Skip reminders when the model returned a terminal error (e.g.
1515
- // rate-limit cap hit, auth failure). Re-prompting would just
1516
- // hit the same wall, multiplying the failure noise without
1517
- // any chance of producing a yield.
1518
- const lastBeforeReminder = session.getLastAssistantMessage();
1519
- if (lastBeforeReminder?.stopReason === "error") break;
1520
- try {
1521
- retryCount++;
1522
- const reminder = prompt.render(submitReminderTemplate, {
1523
- retryCount,
1524
- maxRetries: MAX_YIELD_RETRIES,
1525
- });
1526
-
1527
- const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
1528
- await awaitAbortable(
1529
- session.prompt(reminder, {
1530
- attribution: "agent",
1531
- synthetic: true,
1532
- ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
1533
- }),
1534
- );
1535
- await awaitAbortable(session.waitForIdle());
1536
- } catch (err) {
1537
- if (abortSignal.aborted || err instanceof ToolAbortError) {
1538
- // Benign control-flow exit — user cancel (^C) or compaction aborting
1539
- // pending operations both surface here as ToolAbortError. The outer
1540
- // catch and finally already mark the run aborted; logging at ERROR
1541
- // would spam operator dashboards with non-failures.
1542
- logger.debug("Subagent prompt aborted", {
1543
- reason: abortReason ?? "signal",
1544
- });
1545
- } else {
1546
- logger.error("Subagent prompt failed", {
1547
- error: err instanceof Error ? err.message : String(err),
1548
- });
1549
- }
1550
- }
1551
- }
1552
-
1553
- await awaitAbortable(session.waitForIdle());
1554
- if (!yieldCalled && !abortSignal.aborted) {
1555
- exitCode = 0;
1556
- }
1557
2038
 
1558
- const lastAssistant = session.getLastAssistantMessage();
1559
- if (lastAssistant) {
1560
- if (lastAssistant.stopReason === "aborted") {
1561
- aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1562
- if (aborted) {
1563
- // A real caller signal or the wall-clock timer carries a precise
1564
- // reason (signal.reason / "runtime limit exceeded"). An internal
1565
- // turn abort (abortReason === undefined) does NOT — prefer the
1566
- // assistant message's own errorMessage ("Request was aborted" or a
1567
- // specific stream error) over the misleading "Cancelled by caller".
1568
- abortReasonText ??=
1569
- abortReason === "signal" || runtimeLimitExceeded
1570
- ? resolveAbortReasonText()
1571
- : lastAssistant.errorMessage?.trim() || resolveAbortReasonText();
1572
- }
1573
- exitCode = 1;
1574
- } else if (lastAssistant.stopReason === "error") {
1575
- exitCode = 1;
1576
- error ??= lastAssistant.errorMessage || "Subagent failed";
1577
- }
1578
- }
2039
+ const outcome = await driveSessionToYield(session, monitor, task);
2040
+ exitCode = outcome.exitCode;
2041
+ error = outcome.error;
2042
+ aborted = outcome.aborted;
2043
+ abortReasonText = outcome.abortReasonText;
1579
2044
  } catch (err) {
1580
2045
  exitCode = 1;
1581
2046
  if (!abortSignal.aborted) {
@@ -1583,9 +2048,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1583
2048
  }
1584
2049
  } finally {
1585
2050
  if (abortSignal.aborted) {
1586
- aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
2051
+ aborted = monitor.isAbortedRun();
1587
2052
  if (aborted) {
1588
- abortReasonText ??= resolveAbortReasonText();
2053
+ abortReasonText ??= monitor.resolveAbortReasonText();
1589
2054
  }
1590
2055
  if (exitCode === 0) exitCode = 1;
1591
2056
  }
@@ -1598,13 +2063,39 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1598
2063
  }
1599
2064
  unsubscribe = null;
1600
2065
  }
1601
- if (activeSession) {
1602
- const session = activeSession;
1603
- activeSession = null;
1604
- try {
1605
- await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
1606
- } catch {
1607
- // Ignore cleanup errors
2066
+ const session = monitor.takeActiveSession();
2067
+ if (session) {
2068
+ monitor.captureSalvage(session);
2069
+ const registry = AgentRegistry.global();
2070
+ if (aborted) {
2071
+ // Hard abort (caller signal / wall-clock / budget): terminal teardown.
2072
+ registry.setStatus(id, "aborted");
2073
+ try {
2074
+ await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
2075
+ } catch {
2076
+ // Ignore cleanup errors
2077
+ }
2078
+ } else if (worktree !== undefined) {
2079
+ // Isolated run: the worktree is merged + cleaned after the run, so
2080
+ // the session is not resumable. Park the ref WITHOUT adopting — the
2081
+ // transcript stays reachable (history://), but ensureLive will throw.
2082
+ // Status must flip to "parked" before dispose so the sdk dispose
2083
+ // wrapper skips unregister.
2084
+ registry.setStatus(id, "parked");
2085
+ try {
2086
+ await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
2087
+ } catch {
2088
+ // Ignore cleanup errors
2089
+ }
2090
+ registry.detachSession(id);
2091
+ } else {
2092
+ // Keep-alive: finished and failed subagents both stay interrogable.
2093
+ // The lifecycle manager owns idle-TTL parking + revival from here on.
2094
+ registry.setStatus(id, "idle");
2095
+ AgentLifecycleManager.global().adopt(id, {
2096
+ idleTtlMs: agentIdleTtlMs,
2097
+ revive: reviveSession ?? undefined,
2098
+ });
1608
2099
  }
1609
2100
  }
1610
2101
  }
@@ -1619,125 +2110,24 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1619
2110
  };
1620
2111
 
1621
2112
  const done = await runSubagent();
1622
- resolved = true;
1623
- listenerController.abort();
1624
- if (runtimeTimeoutId !== undefined) {
1625
- clearTimeout(runtimeTimeoutId);
1626
- runtimeTimeoutId = undefined;
1627
- }
1628
-
1629
- if (progressTimeoutId) {
1630
- clearTimeout(progressTimeoutId);
1631
- progressTimeoutId = null;
1632
- }
1633
-
1634
- let exitCode = done.exitCode;
1635
- if (done.error) {
1636
- stderr = done.error;
1637
- }
1638
-
1639
- // Use final output if available, otherwise accumulated output
1640
- let rawOutput = finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("");
1641
- const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
1642
- const reportFindingDetails = progress.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
1643
- const reportFindings: ReviewFinding[] | undefined = reportFindingDetails?.map(toReviewFinding);
1644
- const finalized = finalizeSubprocessOutput({
1645
- rawOutput,
1646
- exitCode,
1647
- stderr,
1648
- doneAborted: Boolean(done.aborted),
1649
- signalAborted: Boolean(signal?.aborted),
1650
- yieldItems,
1651
- reportFindings,
1652
- outputSchema,
1653
- });
1654
- rawOutput = finalized.rawOutput;
1655
- exitCode = finalized.exitCode;
1656
- stderr = finalized.stderr;
1657
- const lastYield = yieldItems?.[yieldItems.length - 1];
1658
- const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
1659
- const { abortedViaYield, hasYield } = finalized;
1660
- const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
1661
- maxBytes: MAX_OUTPUT_BYTES,
1662
- maxLines: MAX_OUTPUT_LINES,
1663
- });
1664
-
1665
- // Write output artifact (input and jsonl already written in real-time)
1666
- // Compute output metadata for agent:// URL integration
1667
- let outputMeta: { lineCount: number; charCount: number } | undefined;
1668
- let outputPath: string | undefined;
1669
- if (options.artifactsDir) {
1670
- outputPath = path.join(options.artifactsDir, `${id}.md`);
1671
- try {
1672
- await Bun.write(outputPath, rawOutput);
1673
- outputMeta = {
1674
- lineCount: rawOutput.split("\n").length,
1675
- charCount: rawOutput.length,
1676
- };
1677
- } catch {
1678
- // Non-fatal
1679
- }
1680
- }
1681
-
1682
- // Update final progress. A wall-clock timeout always wins: if the runtime
1683
- // limit fired we report aborted/failed regardless of whether a yield landed
1684
- // while we were tearing the session down. The yield data is still surfaced
1685
- // to the caller via `progress.extractedToolData`, but the exit status must
1686
- // reflect the timeout so on-call doesn't mistake a stuck run for success.
1687
- if (runtimeLimitExceeded && exitCode === 0) {
1688
- exitCode = 1;
1689
- }
1690
- const wasAborted =
1691
- runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
1692
- const finalAbortReason = wasAborted
1693
- ? runtimeLimitExceeded
1694
- ? resolveAbortReasonText()
1695
- : abortedViaYield
1696
- ? yieldAbortReason
1697
- : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
1698
- : undefined;
1699
- progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1700
- scheduleProgress(true);
1701
-
1702
- // Emit lifecycle end event after finalization so yield status is reflected
1703
- if (options.eventBus) {
1704
- options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1705
- id,
1706
- agent: agent.name,
1707
- agentSource: agent.source,
1708
- description: options.description,
1709
- status: progress.status as "completed" | "failed" | "aborted",
1710
- sessionFile: subtaskSessionFile,
1711
- index,
1712
- });
1713
- }
2113
+ monitor.finish();
1714
2114
 
1715
- return {
2115
+ return finalizeRunResult({
2116
+ monitor,
2117
+ done,
1716
2118
  index,
1717
2119
  id,
1718
- agent: agent.name,
1719
- agentSource: agent.source,
2120
+ agent,
1720
2121
  task,
1721
2122
  assignment,
1722
2123
  description: options.description,
1723
- lastIntent: progress.lastIntent,
1724
- exitCode,
1725
- output: truncatedOutput,
1726
- stderr,
1727
- truncated: Boolean(truncated),
1728
- durationMs: Date.now() - startTime,
1729
- tokens: progress.tokens,
1730
- contextTokens: progress.contextTokens,
1731
- contextWindow: progress.contextWindow,
1732
2124
  modelOverride,
1733
- resolvedModel: progress.resolvedModel,
1734
- error: exitCode !== 0 && stderr ? stderr : undefined,
1735
- aborted: wasAborted,
1736
- abortReason: finalAbortReason,
1737
- usage: hasUsage ? accumulatedUsage : undefined,
1738
- outputPath,
1739
- extractedToolData: progress.extractedToolData,
1740
- retryFailure: progress.retryFailure,
1741
- outputMeta,
1742
- };
2125
+ outputSchema,
2126
+ signal,
2127
+ artifactsDir: options.artifactsDir,
2128
+ eventBus: options.eventBus,
2129
+ parentToolCallId: options.parentToolCallId,
2130
+ sessionFile: subtaskSessionFile,
2131
+ startTime,
2132
+ });
1743
2133
  }