@animalabs/connectome-host 0.3.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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,2074 @@
1
+ /**
2
+ * SubagentModule — spawn and fork ephemeral subagents.
3
+ *
4
+ * Tools:
5
+ * subagent--spawn — Fresh agent with system prompt + task, no inherited context
6
+ * subagent--fork — Agent inheriting parent's compiled context
7
+ * subagent--hud — Toggle fleet status HUD overlay
8
+ *
9
+ * By default, spawn/fork are async: they return immediately and deliver
10
+ * results as user messages + inference-request events. Pass `sync: true`
11
+ * to block until completion.
12
+ *
13
+ * Sync tasks are detachable: user can push them to background mid-flight
14
+ * via Ctrl+B in TUI, or they auto-detach after `timeoutMs` if specified.
15
+ * Both spawn and fork accept `timeoutMs` for per-task execution deadlines.
16
+ */
17
+
18
+ import type {
19
+ Module,
20
+ ModuleContext,
21
+ ProcessState,
22
+ ProcessEvent,
23
+ EventResponse,
24
+ ToolDefinition,
25
+ ToolCall,
26
+ ToolResult,
27
+ TraceEvent,
28
+ ContextManager,
29
+ } from '@animalabs/agent-framework';
30
+ import type { AgentFramework } from '@animalabs/agent-framework';
31
+ import { KnowledgeStrategy } from '@animalabs/agent-framework';
32
+ import { isToolResultContent, isToolUseContent } from '@animalabs/membrane';
33
+ import type { ContentBlock } from '@animalabs/membrane';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface SubagentModuleConfig {
40
+ /** Maximum fork/spawn depth (default: 3) */
41
+ maxDepth?: number;
42
+ /** Current depth (incremented for child subagent modules) */
43
+ currentDepth?: number;
44
+ /** Default model for subagents */
45
+ defaultModel?: string;
46
+ /** Default max tokens per subagent inference */
47
+ defaultMaxTokens?: number;
48
+ /** Which parent agent this module serves (for fork context access) */
49
+ parentAgentName?: string;
50
+ /** Max concurrent subagent executions (default: 3) */
51
+ maxConcurrent?: number;
52
+ /** Max prompt tokens before failing fast (default: 190000) */
53
+ maxPromptTokens?: number;
54
+ /** Max execution time per subagent in ms (default: 600000 = 10 min) */
55
+ maxExecutionMs?: number;
56
+ /** Max restart attempts on transient errors (default: 2) */
57
+ maxRetries?: number;
58
+ }
59
+
60
+ export interface SubagentResult {
61
+ summary: string;
62
+ findings: string[];
63
+ issues: string[];
64
+ toolCallsCount: number;
65
+ }
66
+
67
+ interface SpawnInput {
68
+ name: string;
69
+ systemPrompt: string;
70
+ task: string;
71
+ model?: string;
72
+ maxTokens?: number;
73
+ tools?: string[];
74
+ sync?: boolean;
75
+ timeoutMs?: number;
76
+ }
77
+
78
+ interface ForkInput {
79
+ name: string;
80
+ task: string;
81
+ systemPrompt?: string;
82
+ model?: string;
83
+ maxTokens?: number;
84
+ sync?: boolean;
85
+ timeoutMs?: number;
86
+ }
87
+
88
+ /** Handle for an async (fire-and-forget) subagent. */
89
+ interface AsyncSubagentHandle {
90
+ name: string;
91
+ type: 'spawn' | 'fork';
92
+ promise: Promise<SubagentResult>;
93
+ parentAgentName: string;
94
+ }
95
+
96
+ /**
97
+ * Handle for a sync subagent that can be detached mid-flight.
98
+ * When detached, the blocking tool call resolves immediately and
99
+ * the subagent continues running, delivering results async.
100
+ */
101
+ interface DetachableHandle {
102
+ name: string;
103
+ type: 'spawn' | 'fork';
104
+ promise: Promise<SubagentResult>;
105
+ parentAgentName: string;
106
+ detach: () => void;
107
+ }
108
+
109
+ /**
110
+ * Non-retryable termination of a subagent. All abnormal-but-expected exits
111
+ * (user cancel, zombie reclaim, depth limit, etc.) use this so the catch
112
+ * path can distinguish "killed" from "transient network error".
113
+ */
114
+ export class SubagentTerminated extends Error {
115
+ constructor(
116
+ public readonly reason: 'cancelled' | 'zombie' | 'killed',
117
+ public readonly partialOutput: string,
118
+ message?: string,
119
+ ) {
120
+ super(message ?? `Subagent terminated: ${reason}`);
121
+ this.name = 'SubagentTerminated';
122
+ }
123
+ }
124
+
125
+ /** Persisted subagent state (stored in Chronicle module state).
126
+ * 'cancelled' = terminal-but-benign (user cancel, zombie reclaim, supersession).
127
+ * Postmortem 2026-05-28 P1 #4: separated from 'failed' so the TUI subagent
128
+ * list and the web admin tree agree on terminal cause. */
129
+ interface PersistedSubagent {
130
+ name: string;
131
+ type: 'spawn' | 'fork';
132
+ task: string;
133
+ status: 'running' | 'completed' | 'failed' | 'cancelled';
134
+ startedAt: number;
135
+ /**
136
+ * Last time we saw a trace event addressed to this subagent (token, tool
137
+ * call, completion, etc.). Used as the staleness metric for zombie
138
+ * detection: `startedAt` alone can't distinguish a slow-but-progressing
139
+ * subagent from one that has been silently stuck for hours. Bumped on
140
+ * every inference-lifecycle event in the trace listener; persisted so
141
+ * it survives branch ops and session restores.
142
+ */
143
+ lastActivityAt: number;
144
+ completedAt?: number;
145
+ toolCallsCount: number;
146
+ findingsCount: number;
147
+ statusMessage?: string;
148
+ parent?: string;
149
+ }
150
+
151
+ /** Observable state of an active subagent, for TUI display. See
152
+ * PersistedSubagent.status — 'cancelled' is the same dedicated benign-
153
+ * terminal state introduced for the postmortem 2026-05-28 P1 #4 fix. */
154
+ export interface ActiveSubagent {
155
+ name: string;
156
+ type: 'spawn' | 'fork';
157
+ task: string;
158
+ status: 'running' | 'completed' | 'failed' | 'cancelled';
159
+ startedAt: number;
160
+ /** Last addressed trace event timestamp. See PersistedSubagent.lastActivityAt. */
161
+ lastActivityAt: number;
162
+ completedAt?: number;
163
+ statusMessage?: string;
164
+ toolCallsCount: number;
165
+ findingsCount: number;
166
+ }
167
+
168
+ /** Live state captured for peek observability. */
169
+ interface LiveSubagentState {
170
+ frameworkAgentName: string;
171
+ displayName: string;
172
+ systemPrompt: string;
173
+ contextManager: ContextManager;
174
+ currentStream: string;
175
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
176
+ /** Track callIds from tool_calls_yielded so we can route tool:* events back. */
177
+ activeCallIds: Set<string>;
178
+ /** When set, an inference request has been dispatched but no token has
179
+ * arrived yet — i.e., the agent is provably alive waiting on the LLM
180
+ * provider. Postmortem 2026-05-28 F3: between `inference:started` /
181
+ * `inference:stream_resumed` and the first `inference:tokens`, both
182
+ * `currentStream` and `pendingToolCalls` are empty even though the
183
+ * request is on the wire. The reaper / peek predicates must treat this
184
+ * as a protected state, otherwise slow rounds (Opus on 100–165K-token
185
+ * contexts routinely exceed 30s TTFT) get reaped mid-request. Cleared
186
+ * on first token, completion, failure, or new tool yield. */
187
+ requestInFlightSince?: number;
188
+ }
189
+
190
+ /** Streaming event pushed to peek subscribers. */
191
+ export type SubagentStreamEvent =
192
+ | { type: 'inference:started' }
193
+ | { type: 'tokens'; content: string }
194
+ | { type: 'tool_calls'; calls: Array<{ name: string; input?: unknown }> }
195
+ | { type: 'tool:started'; tool: string; input?: unknown }
196
+ | { type: 'tool:completed'; tool: string; durationMs: number }
197
+ | { type: 'tool:failed'; tool: string; error: string }
198
+ | { type: 'inference:completed' }
199
+ | { type: 'inference:failed'; error: string }
200
+ | { type: 'stream_resumed' }
201
+ | { type: 'done'; summary: string; lastInputTokens?: number };
202
+
203
+ export type SubagentStreamCallback = (event: SubagentStreamEvent) => void;
204
+
205
+ /** Snapshot returned by peek(). */
206
+ export interface SubagentPeekSnapshot {
207
+ name: string;
208
+ type: 'spawn' | 'fork';
209
+ task: string;
210
+ status: 'running' | 'completed' | 'failed' | 'cancelled';
211
+ startedAt: number;
212
+ elapsedMs: number;
213
+ messageCount: number;
214
+ lastMessageSnippet: string;
215
+ currentStream: string;
216
+ pendingToolCalls: Array<{ name: string; input?: unknown }>;
217
+ toolCallsCount: number;
218
+ /** True if the subagent appears stalled: running status, no active stream, elapsed > threshold. */
219
+ isZombie: boolean;
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Fork materialisation
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Build the tool_result text the fork stream sees in place of the generic
228
+ * "Subagent X forked. Running in background." that the parent stream sees.
229
+ *
230
+ * The wording frames the fork as a parallel continuation of the same self
231
+ * rather than a separate agent — both streams inherit everything, the fork
232
+ * stream's job is to set aside the broader agenda and pursue one intention.
233
+ *
234
+ * This addresses fork-disorientation by naming the dual-stream situation
235
+ * explicitly and salient-placing it as the matching tool_result of the
236
+ * fork tool_use the model just emitted.
237
+ */
238
+ export function buildIntentionFramedForkResult(
239
+ name: string,
240
+ task: string,
241
+ depth: number,
242
+ maxDepth: number,
243
+ ): string {
244
+ const depthLine = depth < maxDepth
245
+ ? `You are at depth ${depth} of ${maxDepth} (${maxDepth - depth} sub-fork levels remaining).`
246
+ : `You are at depth ${depth} of ${maxDepth} — at max depth, you cannot sub-fork.`;
247
+ return (
248
+ `Two parallel streams of you continue from this point, both inheriting everything you've done so far. ` +
249
+ `An instance of you still runs as the parent stream, carrying on the broader agenda; ` +
250
+ `the self reading this is the fork — set aside that broader agenda and focus exclusively on the intention ` +
251
+ `you set for this stream: ${task}\n\n` +
252
+ `${depthLine}\n\n` +
253
+ `When this intention is complete, return your findings via subagent--return so the parent stream can integrate them.`
254
+ );
255
+ }
256
+
257
+ /** Structural subset of context-manager's Message — accepting this wider
258
+ * shape lets the helper be unit-tested without importing the framework. */
259
+ interface MinimalMessage {
260
+ participant: string;
261
+ content: ContentBlock[];
262
+ }
263
+
264
+ /**
265
+ * Transform parent's compiled context into the fork stream's inherited view:
266
+ * 1. find the assistant turn containing the fork tool_use with id === callToolUseId
267
+ * 2. strip sibling subagent--fork tool_use blocks from that assistant turn
268
+ * 3. find (or synthesise) the matching tool_result user turn; strip sibling
269
+ * fork tool_results from it; rewrite the matching one with intention framing
270
+ * 4. drop every message after the matching tool_result (post-fork tail —
271
+ * peek calls, zombie returns, parent narrative — is the other major
272
+ * load-bearing source of parent-coherent evidence)
273
+ *
274
+ * Returns null if the matching tool_use cannot be located (e.g. compressed
275
+ * away by the parent's strategy). Callers should fall back to wholesale
276
+ * copy + synthetic intention-framed append in that case.
277
+ */
278
+ export function materialiseStructuralFork(
279
+ compiled: ReadonlyArray<MinimalMessage>,
280
+ callToolUseId: string,
281
+ forkName: string,
282
+ forkTask: string,
283
+ depth: number,
284
+ maxDepth: number,
285
+ ): MinimalMessage[] | null {
286
+ // 1. Locate the assistant turn whose content includes the fork tool_use we're materialising.
287
+ let forkAssistantIdx = -1;
288
+ for (let i = 0; i < compiled.length; i++) {
289
+ const content = compiled[i].content;
290
+ if (!Array.isArray(content)) continue;
291
+ const hit = content.some(b =>
292
+ isToolUseContent(b) && b.id === callToolUseId && b.name === 'subagent--fork',
293
+ );
294
+ if (hit) { forkAssistantIdx = i; break; }
295
+ }
296
+ if (forkAssistantIdx < 0) return null;
297
+
298
+ const forkAssistantMsg = compiled[forkAssistantIdx];
299
+ const siblingForkIds = new Set<string>();
300
+ for (const b of forkAssistantMsg.content) {
301
+ if (!isToolUseContent(b)) continue;
302
+ if (b.name === 'subagent--fork' && b.id !== callToolUseId) {
303
+ siblingForkIds.add(b.id);
304
+ }
305
+ }
306
+
307
+ // 2. Find the next user-side message holding the matching tool_result (if any).
308
+ // Stop searching at the next assistant turn — the matching tool_result must
309
+ // be in the immediately following tool-result turn or it's not there yet.
310
+ let matchingResultIdx = -1;
311
+ for (let i = forkAssistantIdx + 1; i < compiled.length; i++) {
312
+ const m = compiled[i];
313
+ if (!Array.isArray(m.content)) continue;
314
+ const hit = m.content.some(b => isToolResultContent(b) && b.toolUseId === callToolUseId);
315
+ if (hit) { matchingResultIdx = i; break; }
316
+ // If this message contains any tool_use, it's a new assistant turn — bail.
317
+ if (m.content.some(b => isToolUseContent(b))) break;
318
+ }
319
+
320
+ const out: MinimalMessage[] = [];
321
+
322
+ // Pre-fork history verbatim. Blocks are shared by reference everywhere in
323
+ // the framework's context flow — no cloning here either.
324
+ for (let i = 0; i < forkAssistantIdx; i++) {
325
+ out.push({ participant: compiled[i].participant, content: compiled[i].content });
326
+ }
327
+
328
+ // Fork assistant turn with sibling fork tool_use blocks stripped. The
329
+ // matching tool_use is excluded from siblingForkIds by construction, so
330
+ // this filter always keeps at least one block.
331
+ const trimmedAssistantContent = forkAssistantMsg.content.filter(b => {
332
+ if (!isToolUseContent(b)) return true;
333
+ return !(b.name === 'subagent--fork' && siblingForkIds.has(b.id));
334
+ });
335
+ out.push({ participant: forkAssistantMsg.participant, content: trimmedAssistantContent });
336
+
337
+ // Matching tool_result user turn — rewritten with intention framing; siblings stripped.
338
+ const intentionFramed = buildIntentionFramedForkResult(forkName, forkTask, depth, maxDepth);
339
+ if (matchingResultIdx >= 0) {
340
+ const matchingMsg = compiled[matchingResultIdx];
341
+ const trimmedResultContent: ContentBlock[] = [];
342
+ for (const b of matchingMsg.content) {
343
+ if (isToolResultContent(b)) {
344
+ if (siblingForkIds.has(b.toolUseId)) continue;
345
+ if (b.toolUseId === callToolUseId) {
346
+ trimmedResultContent.push({
347
+ type: 'tool_result',
348
+ toolUseId: callToolUseId,
349
+ content: intentionFramed,
350
+ });
351
+ continue;
352
+ }
353
+ }
354
+ trimmedResultContent.push(b);
355
+ }
356
+ if (trimmedResultContent.length === 0) {
357
+ // All siblings, nothing else — synthesise the matching result alone.
358
+ trimmedResultContent.push({
359
+ type: 'tool_result',
360
+ toolUseId: callToolUseId,
361
+ content: intentionFramed,
362
+ });
363
+ }
364
+ out.push({ participant: matchingMsg.participant, content: trimmedResultContent });
365
+ } else {
366
+ // Parent's tool_result for this fork isn't in compiled context yet — synthesise it.
367
+ out.push({
368
+ participant: 'user',
369
+ content: [{
370
+ type: 'tool_result',
371
+ toolUseId: callToolUseId,
372
+ content: intentionFramed,
373
+ }],
374
+ });
375
+ }
376
+
377
+ // Post-fork tail intentionally dropped.
378
+ return out;
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Module
383
+ // ---------------------------------------------------------------------------
384
+
385
+ export class SubagentModule implements Module {
386
+ readonly name = 'subagent';
387
+
388
+ private ctx: ModuleContext | null = null;
389
+ private config: SubagentModuleConfig;
390
+ private framework: AgentFramework | null = null;
391
+ private maxDepth: number;
392
+ private currentDepth: number;
393
+ private asyncHandles = new Map<string, AsyncSubagentHandle>();
394
+ private detachableHandles = new Map<string, DetachableHandle>();
395
+
396
+ // Concurrency control — adaptive rate-limit-aware semaphore
397
+ private configuredMaxConcurrent: number; // User's ceiling
398
+ private effectiveConcurrent: number; // Current effective limit (may be reduced)
399
+ private activeConcurrent = 0;
400
+ private waitQueue: Array<() => void> = [];
401
+ private consecutiveSuccesses = 0;
402
+ private lastRateLimitAt = 0;
403
+ private rateLimitCooldownMs = 30_000; // Delay after rate limit before releasing next slot
404
+
405
+ // Periodic zombie reaper. The pre-existing reaper only ran on-demand
406
+ // inside acquireSlot, which meant zombies persisted indefinitely when
407
+ // no new spawns happened — production traces showed a stale subagent
408
+ // holding a slot for 7 days before a fresh spawn finally tripped the
409
+ // demand-driven reap. This interval gives us a steady heartbeat reap
410
+ // independent of spawn activity.
411
+ private reaperTimer: ReturnType<typeof setInterval> | null = null;
412
+ // Postmortem 2026-05-28 F3: bumped from 30_000ms. With the in-flight guard
413
+ // closing the "request on wire" false-positive window, the threshold is
414
+ // less load-bearing — but 30s was still tight enough that JIT pauses, GC,
415
+ // or transient hiccups under heavy fleet load could trip the reaper on a
416
+ // genuinely-progressing-but-pausing agent. 60s sweep cadence is also
417
+ // friendlier when the fleet is already throttled by 429s (reaping under
418
+ // throttling is counterproductive — the slot isn't stuck, it's queued).
419
+ private static readonly REAPER_INTERVAL_MS = 60_000;
420
+
421
+ // Prompt size guard
422
+ private maxPromptTokens: number;
423
+
424
+ // Per-subagent execution deadline
425
+ private maxExecutionMs: number;
426
+
427
+ // Retry on transient errors
428
+ private maxRetries: number;
429
+
430
+ /** Observable registry of active/recent subagents for TUI display. */
431
+ readonly activeSubagents = new Map<string, ActiveSubagent>();
432
+
433
+ /** Parent agent name for each subagent (for fleet tree reconstruction). */
434
+ readonly parentMap = new Map<string, string>();
435
+
436
+ // Stashed results from subagent--return tool calls, keyed by framework agent name
437
+ private returnedResults = new Map<string, string>();
438
+
439
+ // Live state for peek observability
440
+ private liveSubagents = new Map<string, LiveSubagentState>(); // keyed by displayName
441
+ private frameworkNameIndex = new Map<string, string>(); // frameworkAgentName → displayName
442
+ private callIdIndex = new Map<string, string>(); // toolCallId → displayName
443
+ private streamSubscribers = new Map<string, Set<SubagentStreamCallback>>(); // displayName → callbacks
444
+ private lastInputTokens = new Map<string, number>(); // displayName → last known input token count
445
+ private cancellationHandles = new Map<string, { reject: (err: Error) => void }>(); // displayName → cancel
446
+ private agentDepths = new Map<string, number>(); // framework agent name → fork depth
447
+
448
+ constructor(config: SubagentModuleConfig = {}) {
449
+ this.config = config;
450
+ this.maxDepth = config.maxDepth ?? 3;
451
+ this.currentDepth = config.currentDepth ?? 0;
452
+ this.configuredMaxConcurrent = config.maxConcurrent ?? 5;
453
+ this.effectiveConcurrent = this.configuredMaxConcurrent;
454
+ this.maxPromptTokens = config.maxPromptTokens ?? 190_000;
455
+ this.maxExecutionMs = config.maxExecutionMs ?? 600_000;
456
+ this.maxRetries = config.maxRetries ?? 2;
457
+ }
458
+
459
+ /** Set the framework reference. Must be called after framework creation. */
460
+ setFramework(framework: AgentFramework): void {
461
+ this.framework = framework;
462
+
463
+ // Subscribe to traces for peek observability + streaming fanout
464
+ framework.onTrace((event: TraceEvent) => {
465
+ // Events with agentName: inference lifecycle
466
+ const agentName = 'agentName' in event ? (event as { agentName: string }).agentName : null;
467
+
468
+ if (agentName) {
469
+ const displayName = this.frameworkNameIndex.get(agentName);
470
+ if (!displayName) return;
471
+ const live = this.liveSubagents.get(displayName);
472
+ if (!live) return;
473
+
474
+ // Bump lastActivityAt on every addressed event. This is the staleness
475
+ // metric the periodic reaper consults — without it, the only signal
476
+ // is `startedAt`, which can't distinguish a 10-hour-but-progressing
477
+ // subagent from one that has been silently stuck. Look up the
478
+ // ActiveSubagent record by entry-key (a separate index, since
479
+ // displayName isn't the activeSubagents key directly).
480
+ for (const sa of this.activeSubagents.values()) {
481
+ if (sa.name === displayName) {
482
+ sa.lastActivityAt = Date.now();
483
+ break;
484
+ }
485
+ }
486
+
487
+ // inference:usage is emitted at runtime but not in the TraceEvent union — handle it first
488
+ if ((event as { type: string }).type === 'inference:usage') {
489
+ const roundUsage = (event as { tokenUsage?: { input?: number } }).tokenUsage;
490
+ if (roundUsage?.input) this.lastInputTokens.set(displayName, roundUsage.input);
491
+ return;
492
+ }
493
+
494
+ // Postmortem 2026-05-28 F3: track the request-in-flight window.
495
+ // `inference:started` and `inference:stream_resumed` dispatch a new
496
+ // LLM request; the first `inference:tokens`, `inference:completed`,
497
+ // `inference:failed`, or `inference:tool_calls_yielded` signals the
498
+ // provider has responded. Between those, the reaper / peek predicates
499
+ // treat `requestInFlightSince` as proof the agent isn't stuck.
500
+ switch (event.type) {
501
+ case 'inference:started':
502
+ live.currentStream = '';
503
+ live.pendingToolCalls = [];
504
+ live.activeCallIds.clear();
505
+ live.requestInFlightSince = Date.now();
506
+ this.emit(displayName, { type: 'inference:started' });
507
+ break;
508
+ case 'inference:tokens': {
509
+ const content = (event as { content?: string }).content ?? '';
510
+ live.currentStream += content;
511
+ live.requestInFlightSince = undefined;
512
+ this.emit(displayName, { type: 'tokens', content });
513
+ break;
514
+ }
515
+ case 'inference:tool_calls_yielded': {
516
+ const calls = (event as { calls?: Array<{ id: string; name: string; input?: unknown }> }).calls ?? [];
517
+ live.pendingToolCalls = calls.map(c => ({ name: c.name, input: c.input }));
518
+ live.currentStream = '';
519
+ live.requestInFlightSince = undefined;
520
+ // Index callIds so we can route tool:* events back
521
+ for (const c of calls) {
522
+ live.activeCallIds.add(c.id);
523
+ this.callIdIndex.set(c.id, displayName);
524
+ }
525
+ this.emit(displayName, { type: 'tool_calls', calls: calls.map(c => ({ name: c.name, input: c.input })) });
526
+ break;
527
+ }
528
+ case 'inference:stream_resumed':
529
+ live.currentStream = '';
530
+ live.pendingToolCalls = [];
531
+ live.requestInFlightSince = Date.now();
532
+ this.emit(displayName, { type: 'stream_resumed' });
533
+ break;
534
+ case 'inference:completed': {
535
+ const usage = (event as { tokenUsage?: { input?: number } }).tokenUsage;
536
+ if (usage?.input) this.lastInputTokens.set(displayName, usage.input);
537
+ live.requestInFlightSince = undefined;
538
+ this.emit(displayName, { type: 'inference:completed' });
539
+ break;
540
+ }
541
+ case 'inference:failed': {
542
+ const error = (event as { error?: string }).error ?? 'Unknown error';
543
+ live.requestInFlightSince = undefined;
544
+ this.emit(displayName, { type: 'inference:failed', error });
545
+ break;
546
+ }
547
+ }
548
+ return;
549
+ }
550
+
551
+ // Events with callId: tool lifecycle (no agentName)
552
+ const callId = 'callId' in event ? (event as { callId: string }).callId : null;
553
+ if (callId) {
554
+ const displayName = this.callIdIndex.get(callId);
555
+ if (!displayName) return;
556
+
557
+ switch (event.type) {
558
+ case 'tool:started': {
559
+ const e = event as { tool: string; input?: unknown };
560
+ this.emit(displayName, { type: 'tool:started', tool: e.tool, input: e.input });
561
+ break;
562
+ }
563
+ case 'tool:completed': {
564
+ const e = event as { tool: string; durationMs: number };
565
+ this.callIdIndex.delete(callId);
566
+ this.emit(displayName, { type: 'tool:completed', tool: e.tool, durationMs: e.durationMs });
567
+ break;
568
+ }
569
+ case 'tool:failed': {
570
+ const e = event as { tool: string; error: string };
571
+ this.callIdIndex.delete(callId);
572
+ this.emit(displayName, { type: 'tool:failed', tool: e.tool, error: e.error });
573
+ break;
574
+ }
575
+ }
576
+ }
577
+ });
578
+ }
579
+
580
+ private getFramework(): AgentFramework {
581
+ if (!this.framework) throw new Error('SubagentModule: framework not set. Call setFramework() after creating the framework.');
582
+ return this.framework;
583
+ }
584
+
585
+ async start(ctx: ModuleContext): Promise<void> {
586
+ this.ctx = ctx;
587
+ // Restore in-memory state from Chronicle (for session restore / branch switch)
588
+ this.restoreFromStore();
589
+
590
+ // Periodic zombie reap independent of acquireSlot demand. See reaperTimer
591
+ // comment above. Wrapped in try/catch — a reap-loop bug must not crash
592
+ // the module.
593
+ this.reaperTimer = setInterval(() => {
594
+ try {
595
+ const reclaimed = this.reclaimZombieSlots();
596
+ if (reclaimed > 0) {
597
+ // Persist updated statuses so chronicle reflects the reap.
598
+ this.persistState();
599
+ }
600
+ } catch (error) {
601
+ console.error('[subagent] periodic reaper error:', error);
602
+ }
603
+ }, SubagentModule.REAPER_INTERVAL_MS);
604
+ // Don't keep the event loop alive just for the reaper.
605
+ if (typeof (this.reaperTimer as { unref?: () => void }).unref === 'function') {
606
+ (this.reaperTimer as { unref: () => void }).unref();
607
+ }
608
+ }
609
+
610
+ async stop(): Promise<void> {
611
+ if (this.reaperTimer) {
612
+ clearInterval(this.reaperTimer);
613
+ this.reaperTimer = null;
614
+ }
615
+ this.ctx = null;
616
+ }
617
+
618
+ /**
619
+ * Persist subagent registry to Chronicle module state.
620
+ * Called after each lifecycle transition so branch ops get correct fleet.
621
+ */
622
+ private persistState(): void {
623
+ if (!this.ctx) return;
624
+ const agents: Record<string, PersistedSubagent> = {};
625
+ for (const [key, sa] of this.activeSubagents) {
626
+ agents[key] = {
627
+ name: sa.name,
628
+ type: sa.type,
629
+ task: sa.task,
630
+ status: sa.status,
631
+ startedAt: sa.startedAt,
632
+ lastActivityAt: sa.lastActivityAt,
633
+ completedAt: sa.completedAt,
634
+ toolCallsCount: sa.toolCallsCount,
635
+ findingsCount: sa.findingsCount,
636
+ statusMessage: sa.statusMessage,
637
+ parent: this.parentMap.get(sa.name),
638
+ };
639
+ }
640
+ this.ctx.setState({ agents });
641
+ }
642
+
643
+ /**
644
+ * Restore activeSubagents + parentMap from Chronicle module state.
645
+ * Marks any 'running' entries as 'cancelled' since the actual processes are gone.
646
+ * Postmortem 2026-05-28 P1 #4: use 'cancelled' (not 'completed') so the
647
+ * operator can tell host-interruption from genuine completion in the TUI.
648
+ */
649
+ restoreFromStore(): void {
650
+ if (!this.ctx) return;
651
+ const persisted = this.ctx.getState<{ agents?: Record<string, PersistedSubagent> }>();
652
+ if (!persisted?.agents) return;
653
+
654
+ this.activeSubagents.clear();
655
+ this.parentMap.clear();
656
+
657
+ for (const [key, pa] of Object.entries(persisted.agents)) {
658
+ const sa: ActiveSubagent = {
659
+ name: pa.name,
660
+ type: pa.type,
661
+ task: pa.task,
662
+ status: pa.status === 'running' ? 'cancelled' : pa.status,
663
+ startedAt: pa.startedAt,
664
+ // Fallback for records from versions before lastActivityAt was added:
665
+ // treat them as if their last activity was their start time. Affects
666
+ // only the staleness heuristic, which will simply flag them as stale
667
+ // immediately on next reaper sweep — correct, because by definition
668
+ // the process owning them is gone.
669
+ lastActivityAt: pa.lastActivityAt ?? pa.startedAt,
670
+ completedAt: pa.completedAt ?? (pa.status === 'running' ? Date.now() : undefined),
671
+ toolCallsCount: pa.toolCallsCount,
672
+ findingsCount: pa.findingsCount,
673
+ statusMessage: pa.status === 'running' ? 'interrupted (branch/session switch)' : pa.statusMessage,
674
+ };
675
+ this.activeSubagents.set(key, sa);
676
+ if (pa.parent) {
677
+ this.parentMap.set(pa.name, pa.parent);
678
+ }
679
+ }
680
+ }
681
+
682
+ getTools(): ToolDefinition[] {
683
+ return [
684
+ {
685
+ name: 'spawn',
686
+ description: 'Spawn a fresh subagent with a system prompt and task. Async by default — returns immediately and delivers results as a message. Pass sync:true to block until completion.',
687
+ inputSchema: {
688
+ type: 'object',
689
+ properties: {
690
+ name: { type: 'string', description: 'Short name for the subagent' },
691
+ systemPrompt: { type: 'string', description: 'System prompt for the subagent' },
692
+ task: { type: 'string', description: 'The task for the subagent to perform' },
693
+ model: { type: 'string', description: 'Model override (optional)' },
694
+ maxTokens: { type: 'number', description: 'Max output tokens per inference (optional). Defaults to the recipe-level subagent default, else the parent agent\'s maxTokens.' },
695
+ tools: {
696
+ type: 'array',
697
+ items: { type: 'string' },
698
+ description: 'Tool names the subagent can use (default: all). Note: subagent--return is always included automatically.',
699
+ },
700
+ sync: { type: 'boolean', description: 'If true, block until subagent completes (default: false)' },
701
+ timeoutMs: { type: 'number', description: 'Execution timeout in milliseconds. Sync tasks default to 600s (auto-detaches to background). Async tasks have no default timeout — only set this if you need a hard deadline.' },
702
+ },
703
+ required: ['name', 'systemPrompt', 'task'],
704
+ },
705
+ },
706
+ {
707
+ name: 'fork',
708
+ description:
709
+ "Fork the current conversation into two parallel streams that share all memory of what you've done so far. " +
710
+ "Both streams are continuations of the same self — one carries on with the broader agenda, the other focuses " +
711
+ "exclusively on a new intention (the `task`). The tool_result you receive identifies which stream you are. " +
712
+ "Async by default; pass sync:true to block until completion.",
713
+ inputSchema: {
714
+ type: 'object',
715
+ properties: {
716
+ name: { type: 'string', description: 'Short name for the forked stream' },
717
+ task: { type: 'string', description: 'The intention this fork stream should pursue' },
718
+ systemPrompt: { type: 'string', description: 'Override system prompt (optional, defaults to parent)' },
719
+ model: { type: 'string', description: 'Model override (optional)' },
720
+ maxTokens: { type: 'number', description: 'Max output tokens per inference (optional). Defaults to the recipe-level subagent default, else the parent agent\'s maxTokens.' },
721
+ sync: { type: 'boolean', description: 'If true, block until fork completes (default: false)' },
722
+ timeoutMs: { type: 'number', description: 'Execution timeout in milliseconds. Sync tasks default to 600s (auto-detaches to background). Async tasks have no default timeout — only set this if you need a hard deadline.' },
723
+ },
724
+ required: ['name', 'task'],
725
+ },
726
+ },
727
+ {
728
+ name: 'hud',
729
+ description: 'Toggle the subagent fleet HUD overlay. When enabled, a compact fleet status summary is injected before each inference.',
730
+ inputSchema: {
731
+ type: 'object',
732
+ properties: {
733
+ enabled: { type: 'boolean', description: 'Enable or disable the fleet HUD' },
734
+ },
735
+ required: ['enabled'],
736
+ },
737
+ },
738
+ {
739
+ name: 'concurrency',
740
+ description: 'View or adjust subagent concurrency. Omit maxConcurrent to just view status. Concurrency auto-adapts to rate limits (halves on 429, recovers after successes).',
741
+ inputSchema: {
742
+ type: 'object',
743
+ properties: {
744
+ maxConcurrent: { type: 'number', description: 'Set new concurrency ceiling (min 1)' },
745
+ },
746
+ },
747
+ },
748
+ {
749
+ name: 'peek',
750
+ description: 'Peek at a running subagent\'s live state: status, elapsed time, message count, last message snippet, current streaming output, and pending tool calls. Omit name to peek at all running subagents.',
751
+ inputSchema: {
752
+ type: 'object',
753
+ properties: {
754
+ name: { type: 'string', description: 'Subagent name to peek at (omit for all)' },
755
+ },
756
+ },
757
+ },
758
+ {
759
+ name: 'return',
760
+ description: 'Return results from a fork or spawn back to the parent agent. Call this when you have completed your task. Your result text will be delivered to the parent as the tool result of the fork/spawn call. This ends your execution.',
761
+ inputSchema: {
762
+ type: 'object',
763
+ properties: {
764
+ result: { type: 'string', description: 'Your findings, summary, or results to return to the parent' },
765
+ },
766
+ required: ['result'],
767
+ },
768
+ },
769
+ ];
770
+ }
771
+
772
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
773
+ const caller = call.callerAgentName;
774
+ switch (call.name) {
775
+ case 'spawn':
776
+ return this.handleSpawn(call.input as SpawnInput, caller);
777
+ case 'fork':
778
+ return this.handleFork(call.input as ForkInput, caller, call.id);
779
+ case 'hud':
780
+ return this.handleHud(call.input as { enabled: boolean });
781
+ case 'concurrency':
782
+ return this.handleConcurrency(call.input as { maxConcurrent?: number });
783
+ case 'peek':
784
+ return this.handlePeek(call.input as { name?: string }, caller);
785
+ case 'return': {
786
+ // Stash the result keyed by the tool call ID. The completion path
787
+ // in runSpawn/runFork will pick it up via the callIdIndex → displayName.
788
+ const result = (call.input as { result: string }).result;
789
+ // Find which subagent is calling this via the callIdIndex
790
+ const callerName = this.callIdIndex.get(call.id);
791
+ if (callerName) {
792
+ this.returnedResults.set(callerName, result);
793
+ }
794
+ return { success: true, data: 'Result received.', endTurn: true };
795
+ }
796
+ default:
797
+ return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
798
+ }
799
+ }
800
+
801
+ async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
802
+ // When an async subagent completes, deliverAsyncResult() pushes an
803
+ // inference-request event. Convert it into a proper requestInference
804
+ // response so the framework (and EventGate) can route it normally.
805
+ if (event.type === 'inference-request' && 'source' in event && event.source === 'subagent') {
806
+ return { requestInference: [(event as { agentName: string }).agentName] };
807
+ }
808
+ return {};
809
+ }
810
+
811
+ async gatherContext(agentName: string): Promise<import('@animalabs/context-manager').ContextInjection[]> {
812
+ if (!this.ctx) return [];
813
+ const persisted = this.ctx.getState<{ hudEnabled?: boolean }>() ?? {};
814
+ if (!persisted.hudEnabled) return [];
815
+
816
+ // Postmortem 2026-05-28 P3 #8: scope the Fleet Status HUD to the
817
+ // calling agent's descendants. Pre-fix the HUD injected the whole
818
+ // fleet roster every turn, which (a) encouraged peer-coordination
819
+ // anti-patterns and (b) amplified false zombie/failed flags across
820
+ // the orchestrator's context.
821
+ const allowed = this.getDescendantDisplayNames(agentName);
822
+
823
+ const lines: string[] = [];
824
+ for (const [, sa] of this.activeSubagents) {
825
+ if (!allowed.has(sa.name)) continue;
826
+ const elapsed = sa.completedAt
827
+ ? Math.floor((sa.completedAt - sa.startedAt) / 1000)
828
+ : Math.floor((Date.now() - sa.startedAt) / 1000);
829
+ const parent = this.parentMap.get(sa.name) ?? 'agent';
830
+ const parentShort = parent.replace(/^(spawn|fork)-/, '').replace(/-d\d+-\d+$/, '').replace(/-retry\d+$/, '');
831
+ const task = sa.task.length > 50 ? sa.task.slice(0, 47) + '...' : sa.task;
832
+ lines.push(` ${sa.name} [${sa.type}] ${sa.status} ${elapsed}s ${sa.toolCallsCount}calls parent:${parentShort} "${task}"`);
833
+ }
834
+
835
+ // Also show async handles still running, but only those owned by the
836
+ // caller (or its descendants — but async handles aren't parent-indexed
837
+ // separately, so conservatively skip them when scoping is in effect
838
+ // and the caller has no descendants to begin with).
839
+ for (const [name] of this.asyncHandles) {
840
+ if (!allowed.has(name)) continue;
841
+ if (!this.activeSubagents.has(name) && !this.activeSubagents.has(`spawn-${name}`)) {
842
+ lines.push(` ${name} [async] pending`);
843
+ }
844
+ }
845
+
846
+ if (lines.length === 0) return [];
847
+
848
+ return [{
849
+ namespace: 'subagent-fleet',
850
+ position: 'afterUser',
851
+ content: [{ type: 'text', text: `[Fleet Status]\n${lines.join('\n')}` }],
852
+ }];
853
+ }
854
+
855
+ // =========================================================================
856
+ // Concurrency Control (adaptive, rate-limit-aware)
857
+ // =========================================================================
858
+
859
+ /**
860
+ * Acquire a concurrency slot. Returns how long the caller waited (0 = immediate).
861
+ * Throws if the slot is not acquired within `slotTimeoutMs`.
862
+ */
863
+ private async acquireSlot(slotTimeoutMs = 120_000): Promise<{ waitedMs: number }> {
864
+ if (this.activeConcurrent < this.effectiveConcurrent) {
865
+ this.activeConcurrent++;
866
+ return { waitedMs: 0 };
867
+ }
868
+
869
+ // Before queueing, try to reclaim slots from zombie subagents
870
+ const reclaimedZombies = this.reclaimZombieSlots();
871
+ if (reclaimedZombies > 0 && this.activeConcurrent < this.effectiveConcurrent) {
872
+ this.activeConcurrent++;
873
+ return { waitedMs: 0 };
874
+ }
875
+
876
+ const startWait = Date.now();
877
+ return new Promise<{ waitedMs: number }>((resolve, reject) => {
878
+ const timer = setTimeout(() => {
879
+ // Remove ourselves from the wait queue
880
+ const idx = this.waitQueue.indexOf(onSlot);
881
+ if (idx >= 0) this.waitQueue.splice(idx, 1);
882
+
883
+ // Last-chance zombie reclamation before failing
884
+ const reclaimed = this.reclaimZombieSlots();
885
+ if (reclaimed > 0 && this.activeConcurrent < this.effectiveConcurrent) {
886
+ this.activeConcurrent++;
887
+ resolve({ waitedMs: Date.now() - startWait });
888
+ return;
889
+ }
890
+
891
+ // Surface which subagents currently hold the slots, with silence
892
+ // and total runtime. Without this the caller only sees "5/5 in
893
+ // use" and has no signal for which to cancel. In production this
894
+ // lack of detail meant the parent agent couldn't self-rescue —
895
+ // it had to wait days for the demand-driven reaper to fire on
896
+ // its own.
897
+ const holders = this.describeSlotHolders();
898
+ const holderLines = holders.length > 0
899
+ ? `\nSlots held by:\n${holders.map(h => ` - ${h}`).join('\n')}`
900
+ : '';
901
+
902
+ reject(new Error(
903
+ `Timed out waiting for a concurrency slot after ${slotTimeoutMs}ms ` +
904
+ `(${this.activeConcurrent}/${this.effectiveConcurrent} slots in use, ` +
905
+ `${this.waitQueue.length} still queued). ` +
906
+ `Limit parallel forks/spawns to ${this.effectiveConcurrent}.` +
907
+ holderLines
908
+ ));
909
+ }, slotTimeoutMs);
910
+
911
+ const onSlot = () => {
912
+ clearTimeout(timer);
913
+ resolve({ waitedMs: Date.now() - startWait });
914
+ };
915
+
916
+ this.waitQueue.push(onSlot);
917
+ });
918
+ }
919
+
920
+ /**
921
+ * Summarize subagents currently holding concurrency slots. Used to enrich
922
+ * the slot-acquisition timeout error so the calling agent can decide
923
+ * which stale forks to cancel rather than helplessly waiting on the
924
+ * demand-driven reaper.
925
+ */
926
+ private describeSlotHolders(): string[] {
927
+ const lines: string[] = [];
928
+ const now = Date.now();
929
+ for (const sa of this.activeSubagents.values()) {
930
+ if (sa.status !== 'running') continue;
931
+ const silentS = Math.floor((now - sa.lastActivityAt) / 1000);
932
+ const runtimeS = Math.floor((now - sa.startedAt) / 1000);
933
+ const live = this.liveSubagents.get(sa.name);
934
+ const streamState = live?.currentStream ? 'streaming' :
935
+ live?.pendingToolCalls?.length ? `awaiting ${live.pendingToolCalls.length} tool(s)` :
936
+ 'idle';
937
+ lines.push(
938
+ `${sa.name} [${sa.type}] runtime=${runtimeS}s silent=${silentS}s ${streamState}`
939
+ );
940
+ }
941
+ return lines;
942
+ }
943
+
944
+ /**
945
+ * Scan for zombie subagents and force-release their concurrency slots.
946
+ * A zombie is a subagent in 'running' status whose `lastActivityAt` is
947
+ * older than ZOMBIE_THRESHOLD_MS AND has no active inference stream AND
948
+ * no pending tool calls. Using `lastActivityAt` instead of `startedAt`
949
+ * lets us distinguish a 10-hour-but-progressing subagent from one that
950
+ * has been silently stuck for hours — the latter is what kept locking
951
+ * concurrency slots for days in production.
952
+ *
953
+ * Returns the number of slots reclaimed.
954
+ */
955
+ private reclaimZombieSlots(): number {
956
+ // Postmortem 2026-05-28 F3: bumped from 30_000ms — 30s was too tight for
957
+ // Opus on 100–165K-token contexts with rate-limited MCP tools; single-
958
+ // round TTFT routinely exceeds it. The `requestInFlightSince` guard
959
+ // (below) is the primary defence; the threshold bump is belt-and-braces
960
+ // for legitimate post-request pauses.
961
+ const ZOMBIE_THRESHOLD_MS = 120_000;
962
+ let reclaimed = 0;
963
+
964
+ for (const [displayName, live] of this.liveSubagents) {
965
+ let entry: ActiveSubagent | undefined;
966
+ for (const e of this.activeSubagents.values()) {
967
+ if (e.name === displayName) { entry = e; break; }
968
+ }
969
+ if (!entry || entry.status !== 'running') continue;
970
+
971
+ const silentMs = Date.now() - entry.lastActivityAt;
972
+ // Postmortem 2026-05-28 F3: `!live.requestInFlightSince` protects the
973
+ // "request dispatched, awaiting first token" window. Before this, the
974
+ // guard `!currentStream && pendingToolCalls.length===0` was true while
975
+ // the next round's request was on the wire, and the reaper killed
976
+ // progressing agents mid-request. Forensic signature in production:
977
+ // last message in store is an unconsumed `tool_result`.
978
+ const isZombie = silentMs > ZOMBIE_THRESHOLD_MS
979
+ && !live.currentStream
980
+ && live.pendingToolCalls.length === 0
981
+ && !live.requestInFlightSince;
982
+
983
+ if (isZombie) {
984
+ const elapsedTotal = Date.now() - entry.startedAt;
985
+ console.error(
986
+ `[subagent] Reclaiming zombie slot: "${displayName}" ` +
987
+ `(silent for ${(silentMs / 1000).toFixed(0)}s, total runtime ` +
988
+ `${(elapsedTotal / 1000).toFixed(0)}s, no active stream)`
989
+ );
990
+
991
+ // Cancel the zombie's framework agent
992
+ try {
993
+ const agent = this.getFramework().getAgent(live.frameworkAgentName);
994
+ if (agent) agent.cancelStream();
995
+ } catch { /* best-effort */ }
996
+
997
+ // Cancel via cancellation handle (unblocks the Promise.race in runSpawn/runFork)
998
+ const handle = this.cancellationHandles.get(displayName);
999
+ if (handle) {
1000
+ const partial = live.currentStream ?? '';
1001
+ handle.reject(new SubagentTerminated(
1002
+ 'zombie',
1003
+ partial,
1004
+ `Zombie detected: "${displayName}" silent for ${(silentMs / 1000).toFixed(0)}s ` +
1005
+ `(total runtime ${(elapsedTotal / 1000).toFixed(0)}s). Slot reclaimed.`,
1006
+ ));
1007
+ this.cancellationHandles.delete(displayName);
1008
+ }
1009
+
1010
+ // Postmortem 2026-05-28 P1 #4: zombie reclaim is terminal-but-benign,
1011
+ // not a fault. Land on 'cancelled' so the TUI and the reducer-driven
1012
+ // web tree agree (the reducer maps the trace-side `inference:aborted`
1013
+ // emitted by the cancellation handle to 'cancelled' as well).
1014
+ entry.status = 'cancelled';
1015
+ entry.completedAt = Date.now();
1016
+ entry.statusMessage = 'zombie — slot reclaimed';
1017
+
1018
+ // Release the slot (the finally block in runSpawn/runFork will also
1019
+ // call releaseSlot, but that's safe — activeConcurrent just goes to
1020
+ // max(0, activeConcurrent-1) effectively)
1021
+ this.activeConcurrent = Math.max(0, this.activeConcurrent - 1);
1022
+ reclaimed++;
1023
+ }
1024
+ }
1025
+
1026
+ return reclaimed;
1027
+ }
1028
+
1029
+ private releaseSlot(): void {
1030
+ if (this.activeConcurrent <= 0) return; // Guard against double-release (e.g., zombie reclamation + finally)
1031
+ this.activeConcurrent--;
1032
+ if (this.waitQueue.length > 0 && this.activeConcurrent < this.effectiveConcurrent) {
1033
+ this.activeConcurrent++;
1034
+ this.waitQueue.shift()!();
1035
+ }
1036
+ }
1037
+
1038
+ /** Format a concurrency notice for tool results (empty string if no wait). */
1039
+ private concurrencyNotice(waitedMs: number): string {
1040
+ if (waitedMs <= 0) return '';
1041
+ const secs = (waitedMs / 1000).toFixed(1);
1042
+ return `[Concurrency notice: this agent waited ${secs}s for a slot ` +
1043
+ `(${this.effectiveConcurrent} concurrent limit). ` +
1044
+ `To avoid delays, limit parallel forks/spawns to ${this.effectiveConcurrent}.]\n\n`;
1045
+ }
1046
+
1047
+ /** Call on successful subagent completion — gradually recovers concurrency. */
1048
+ private onSubagentSuccess(): void {
1049
+ this.consecutiveSuccesses++;
1050
+ // After 3 consecutive successes, try increasing by 1
1051
+ if (this.consecutiveSuccesses >= 3 && this.effectiveConcurrent < this.configuredMaxConcurrent) {
1052
+ this.effectiveConcurrent++;
1053
+ this.consecutiveSuccesses = 0;
1054
+ }
1055
+ }
1056
+
1057
+ /** Call on rate limit error — halves concurrency and applies cooldown. */
1058
+ private async onRateLimitHit(): Promise<void> {
1059
+ const prev = this.effectiveConcurrent;
1060
+ this.effectiveConcurrent = Math.max(1, Math.floor(this.effectiveConcurrent / 2));
1061
+ this.consecutiveSuccesses = 0;
1062
+ this.lastRateLimitAt = Date.now();
1063
+ console.error(
1064
+ `[subagent] Rate limit hit — concurrency ${prev} → ${this.effectiveConcurrent}, ` +
1065
+ `cooling down ${this.rateLimitCooldownMs}ms`
1066
+ );
1067
+ await new Promise(resolve => setTimeout(resolve, this.rateLimitCooldownMs));
1068
+ }
1069
+
1070
+ private isRateLimitError(err: unknown): boolean {
1071
+ if (!(err instanceof Error)) return false;
1072
+ const msg = err.message.toLowerCase();
1073
+ return msg.includes('rate') || msg.includes('429') || msg.includes('too many');
1074
+ }
1075
+
1076
+ /** Transient = worth retrying the whole subagent from scratch. */
1077
+ private isTransientError(err: Error): boolean {
1078
+ const msg = err.message.toLowerCase();
1079
+ return (
1080
+ msg.includes('timeout') ||
1081
+ msg.includes('idle') ||
1082
+ msg.includes('econnreset') ||
1083
+ msg.includes('socket hang up') ||
1084
+ msg.includes('network') ||
1085
+ msg.includes('stream aborted') ||
1086
+ msg.includes('overloaded') ||
1087
+ msg.includes('529') ||
1088
+ msg.includes('500') ||
1089
+ msg.includes('502') ||
1090
+ msg.includes('503') ||
1091
+ this.isRateLimitError(err)
1092
+ );
1093
+ }
1094
+
1095
+ /** Set concurrency ceiling at runtime. Also raises effective if below new ceiling. */
1096
+ setConcurrency(n: number): void {
1097
+ this.configuredMaxConcurrent = Math.max(1, n);
1098
+ if (this.effectiveConcurrent > this.configuredMaxConcurrent) {
1099
+ this.effectiveConcurrent = this.configuredMaxConcurrent;
1100
+ }
1101
+ // If we were throttled below the new ceiling, let waiters through
1102
+ while (this.waitQueue.length > 0 && this.activeConcurrent < this.effectiveConcurrent) {
1103
+ this.activeConcurrent++;
1104
+ this.waitQueue.shift()!();
1105
+ }
1106
+ }
1107
+
1108
+ /** Get current concurrency status for observability. */
1109
+ getConcurrencyStatus(): { configured: number; effective: number; active: number; queued: number } {
1110
+ return {
1111
+ configured: this.configuredMaxConcurrent,
1112
+ effective: this.effectiveConcurrent,
1113
+ active: this.activeConcurrent,
1114
+ queued: this.waitQueue.length,
1115
+ };
1116
+ }
1117
+
1118
+ // =========================================================================
1119
+ // Subagent Cancellation
1120
+ // =========================================================================
1121
+
1122
+ /**
1123
+ * Force-stop a running subagent. Aborts the active HTTP stream and
1124
+ * causes runSpawn/runFork to return with a "[Stopped by user]" result.
1125
+ * Returns true if the subagent was found and cancelled.
1126
+ */
1127
+ cancelSubagent(displayName: string): boolean {
1128
+ // Cancel children first (bottom-up) so their results propagate before the parent dies
1129
+ this.cancelChildren(displayName);
1130
+
1131
+ const handle = this.cancellationHandles.get(displayName);
1132
+ if (!handle) return false;
1133
+
1134
+ // Abort the active inference stream (cancels the HTTP request)
1135
+ const live = this.liveSubagents.get(displayName);
1136
+ const partial = live?.currentStream ?? '';
1137
+ if (live) {
1138
+ try {
1139
+ const agent = this.getFramework().getAgent(live.frameworkAgentName);
1140
+ if (agent) agent.cancelStream();
1141
+ } catch { /* best-effort */ }
1142
+ }
1143
+
1144
+ // Unblock the Promise.race in runSpawn/runFork
1145
+ handle.reject(new SubagentTerminated('cancelled', partial, `Subagent "${displayName}" cancelled by user`));
1146
+ return true;
1147
+ }
1148
+
1149
+ /**
1150
+ * Cancel all running subagents (e.g. on user Esc).
1151
+ * Returns the number of subagents cancelled.
1152
+ */
1153
+ cancelAll(): number {
1154
+ // Collect all cancellable names first to avoid mutation during iteration
1155
+ const names = [...this.cancellationHandles.keys()];
1156
+ let count = 0;
1157
+ for (const name of names) {
1158
+ if (this.cancelSubagent(name)) count++;
1159
+ }
1160
+ return count;
1161
+ }
1162
+
1163
+ /**
1164
+ * Compute the set of descendant display names rooted at the given framework
1165
+ * agent name. Walks `parentMap` (displayName → callerFrameworkAgentName)
1166
+ * top-down. Excludes the root itself.
1167
+ *
1168
+ * Postmortem 2026-05-28 P3 #8: used by peek() and gatherContext() to
1169
+ * scope observability surfaces to the caller's subtree. Prevents peer
1170
+ * agents from inspecting each other and saturating context with
1171
+ * cross-fleet roster information they have no legitimate use for.
1172
+ */
1173
+ private getDescendantDisplayNames(rootFrameworkAgentName: string): Set<string> {
1174
+ const result = new Set<string>();
1175
+ const queue: string[] = [rootFrameworkAgentName];
1176
+ while (queue.length > 0) {
1177
+ const parentFw = queue.shift()!;
1178
+ for (const [childDisplayName, parentName] of this.parentMap) {
1179
+ if (parentName === parentFw && !result.has(childDisplayName)) {
1180
+ result.add(childDisplayName);
1181
+ const childLive = this.liveSubagents.get(childDisplayName);
1182
+ if (childLive) queue.push(childLive.frameworkAgentName);
1183
+ }
1184
+ }
1185
+ }
1186
+ return result;
1187
+ }
1188
+
1189
+ /**
1190
+ * Cancel all children (direct + transitive) of the given display name.
1191
+ * Uses the parentMap to find descendants via framework agent names.
1192
+ */
1193
+ private cancelChildren(displayName: string): void {
1194
+ const live = this.liveSubagents.get(displayName);
1195
+ if (!live) return;
1196
+
1197
+ // Find direct children: entries in parentMap whose parent is this agent's framework name
1198
+ const frameworkName = live.frameworkAgentName;
1199
+ const children: string[] = [];
1200
+ for (const [childName, parentFrameworkName] of this.parentMap) {
1201
+ if (parentFrameworkName === frameworkName) {
1202
+ children.push(childName);
1203
+ }
1204
+ }
1205
+
1206
+ // Cancel each child (which recursively cancels its children)
1207
+ for (const child of children) {
1208
+ this.cancelSubagent(child);
1209
+ }
1210
+ }
1211
+
1212
+ // =========================================================================
1213
+ // Peek Observability
1214
+ // =========================================================================
1215
+
1216
+ private registerLive(
1217
+ displayName: string,
1218
+ frameworkAgentName: string,
1219
+ systemPrompt: string,
1220
+ contextManager: ContextManager,
1221
+ ): void {
1222
+ this.liveSubagents.set(displayName, {
1223
+ frameworkAgentName,
1224
+ displayName,
1225
+ systemPrompt,
1226
+ contextManager,
1227
+ currentStream: '',
1228
+ pendingToolCalls: [],
1229
+ activeCallIds: new Set(),
1230
+ });
1231
+ this.frameworkNameIndex.set(frameworkAgentName, displayName);
1232
+ }
1233
+
1234
+ private unregisterLive(displayName: string, frameworkAgentName: string): void {
1235
+ // Clean up callId index entries for this subagent
1236
+ const live = this.liveSubagents.get(displayName);
1237
+ if (live) {
1238
+ for (const callId of live.activeCallIds) {
1239
+ this.callIdIndex.delete(callId);
1240
+ }
1241
+ }
1242
+ this.liveSubagents.delete(displayName);
1243
+ this.frameworkNameIndex.delete(frameworkAgentName);
1244
+ }
1245
+
1246
+ /** Fan out a stream event to all subscribers for this subagent + wildcard. */
1247
+ private emit(displayName: string, event: SubagentStreamEvent): void {
1248
+ for (const key of [displayName, '*']) {
1249
+ const subs = this.streamSubscribers.get(key);
1250
+ if (!subs) continue;
1251
+ for (const cb of subs) {
1252
+ try { cb(event); } catch { /* subscriber error — don't break the loop */ }
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Subscribe to a running subagent's live stream. Receives all inference
1259
+ * and tool events as they happen. Returns an unsubscribe function.
1260
+ *
1261
+ * If name is '*', subscribes to events from ALL subagents (events are
1262
+ * the same type — use peek() to get the subagent name if needed).
1263
+ */
1264
+ onPeekStream(name: string, callback: SubagentStreamCallback): () => void {
1265
+ if (!this.streamSubscribers.has(name)) {
1266
+ this.streamSubscribers.set(name, new Set());
1267
+ }
1268
+ this.streamSubscribers.get(name)!.add(callback);
1269
+
1270
+ return () => {
1271
+ const subs = this.streamSubscribers.get(name);
1272
+ if (subs) {
1273
+ subs.delete(callback);
1274
+ if (subs.size === 0) this.streamSubscribers.delete(name);
1275
+ }
1276
+ };
1277
+ }
1278
+
1279
+ /**
1280
+ * Peek at a running subagent's live state: full context, streaming output,
1281
+ * pending tool calls. Returns null if the subagent is not running.
1282
+ * If name is omitted, returns snapshots for all running subagents.
1283
+ *
1284
+ * Postmortem 2026-05-28 P3 #8: when a `callerFrameworkAgentName` is
1285
+ * supplied (the normal tool-call path threads it through `handlePeek`),
1286
+ * the result is scoped to the caller's descendants — peers/cousins are
1287
+ * filtered out. Internal callers without a known identity (omit the arg)
1288
+ * keep the global view for backward compatibility.
1289
+ */
1290
+ async peek(name?: string, callerFrameworkAgentName?: string): Promise<SubagentPeekSnapshot[]> {
1291
+ const allowed = callerFrameworkAgentName
1292
+ ? this.getDescendantDisplayNames(callerFrameworkAgentName)
1293
+ : null;
1294
+ if (name) {
1295
+ if (allowed && !allowed.has(name)) return [];
1296
+ const snapshot = await this.peekOne(name);
1297
+ return snapshot ? [snapshot] : [];
1298
+ }
1299
+ const results: SubagentPeekSnapshot[] = [];
1300
+ for (const displayName of this.liveSubagents.keys()) {
1301
+ if (allowed && !allowed.has(displayName)) continue;
1302
+ const snapshot = await this.peekOne(displayName);
1303
+ if (snapshot) results.push(snapshot);
1304
+ }
1305
+ return results;
1306
+ }
1307
+
1308
+ private async peekOne(displayName: string): Promise<SubagentPeekSnapshot | null> {
1309
+ const live = this.liveSubagents.get(displayName);
1310
+ if (!live) return null;
1311
+
1312
+ // Find the matching ActiveSubagent entry for status/metadata
1313
+ let entry: ActiveSubagent | undefined;
1314
+ for (const e of this.activeSubagents.values()) {
1315
+ if (e.name === displayName) { entry = e; break; }
1316
+ }
1317
+
1318
+ let messageCount = 0;
1319
+ let lastMessageSnippet = '';
1320
+ try {
1321
+ const compiled = await live.contextManager.compile();
1322
+ messageCount = compiled.messages.length;
1323
+ // Extract a short snippet from the last message for observability
1324
+ // without dumping the entire context into the caller's window.
1325
+ if (compiled.messages.length > 0) {
1326
+ const last = compiled.messages[compiled.messages.length - 1];
1327
+ const textBlocks = last.content
1328
+ .filter((b: ContentBlock) => b.type === 'text')
1329
+ .map((b: ContentBlock) => (b as { type: 'text'; text: string }).text);
1330
+ const fullText = textBlocks.join(' ');
1331
+ lastMessageSnippet = fullText.length > 500 ? fullText.slice(-500) : fullText;
1332
+ }
1333
+ } catch {
1334
+ // Context manager may be mid-modification; return what we have
1335
+ }
1336
+
1337
+ const elapsedMs = entry ? Date.now() - entry.startedAt : 0;
1338
+
1339
+ // Zombie detection. Postmortem 2026-05-28 F2: this previously keyed off
1340
+ // `startedAt`, which flagged any subagent >30s old as a zombie the moment
1341
+ // it was between tokens — saturating peers' context with false alarms and
1342
+ // driving an orchestrator-level retry storm. Now keyed off `lastActivityAt`
1343
+ // and gated by `!requestInFlightSince` to match the reaper.
1344
+ const ZOMBIE_THRESHOLD_MS = 120_000;
1345
+ const silentMs = entry ? Date.now() - entry.lastActivityAt : 0;
1346
+ const isZombie = (entry?.status === 'running')
1347
+ && silentMs > ZOMBIE_THRESHOLD_MS
1348
+ && !live.currentStream
1349
+ && live.pendingToolCalls.length === 0
1350
+ && !live.requestInFlightSince;
1351
+
1352
+ return {
1353
+ name: displayName,
1354
+ type: entry?.type ?? 'spawn',
1355
+ task: entry?.task ?? '',
1356
+ status: entry?.status ?? 'running',
1357
+ startedAt: entry?.startedAt ?? 0,
1358
+ elapsedMs,
1359
+ messageCount,
1360
+ lastMessageSnippet,
1361
+ currentStream: live.currentStream,
1362
+ pendingToolCalls: live.pendingToolCalls,
1363
+ toolCallsCount: entry?.toolCallsCount ?? 0,
1364
+ isZombie,
1365
+ };
1366
+ }
1367
+
1368
+ // =========================================================================
1369
+ // Execution Timeout
1370
+ // =========================================================================
1371
+
1372
+ /**
1373
+ * Resolve a subagent's maxTokens budget through the cascade:
1374
+ * 1. per-call `maxTokens` on the fork/spawn input
1375
+ * 2. recipe-level `defaultMaxTokens` on this module's config
1376
+ * 3. parent agent's `maxTokens` (by default, subagents inherit their caller's budget)
1377
+ * 4. last-resort framework fallback (4096) — only reached if there's no parent at all
1378
+ */
1379
+ private resolveMaxTokens(callMaxTokens: number | undefined, parentAgentName?: string): number {
1380
+ if (callMaxTokens !== undefined) return callMaxTokens;
1381
+ if (this.config.defaultMaxTokens !== undefined) return this.config.defaultMaxTokens;
1382
+ const parentName = parentAgentName ?? this.config.parentAgentName;
1383
+ if (parentName) {
1384
+ const parent = this.framework?.getAgent(parentName);
1385
+ if (parent) return parent.maxTokens;
1386
+ }
1387
+ return 4096;
1388
+ }
1389
+
1390
+ private withTimeout<T>(promise: Promise<T>, name: string, timeoutMs?: number): Promise<T> {
1391
+ if (timeoutMs === undefined) return promise;
1392
+ return Promise.race([
1393
+ promise,
1394
+ new Promise<never>((_, reject) =>
1395
+ setTimeout(
1396
+ () => reject(new Error(`Subagent ${name} timed out after ${timeoutMs}ms`)),
1397
+ timeoutMs,
1398
+ )
1399
+ ),
1400
+ ]);
1401
+ }
1402
+
1403
+ // =========================================================================
1404
+ // Prompt Size Estimation
1405
+ // =========================================================================
1406
+
1407
+ private estimatePromptTokens(
1408
+ systemPrompt: string,
1409
+ messages: Array<{ content: ContentBlock[] }>,
1410
+ tools: ToolDefinition[],
1411
+ ): number {
1412
+ let tokens = Math.ceil(systemPrompt.length / 4) + 50; // system + overhead
1413
+
1414
+ for (const msg of messages) {
1415
+ tokens += 50; // per-message overhead (role, formatting)
1416
+ for (const block of msg.content) {
1417
+ tokens += Math.ceil(JSON.stringify(block).length / 4);
1418
+ }
1419
+ }
1420
+
1421
+ for (const tool of tools) {
1422
+ tokens += 100; // per-tool overhead
1423
+ tokens += Math.ceil(JSON.stringify(tool).length / 4);
1424
+ }
1425
+
1426
+ return tokens;
1427
+ }
1428
+
1429
+ // =========================================================================
1430
+ // Tool Handlers
1431
+ // =========================================================================
1432
+
1433
+ private async handleSpawn(input: SpawnInput, callerAgentName?: string): Promise<ToolResult> {
1434
+ const callerDepth = callerAgentName ? (this.agentDepths.get(callerAgentName) ?? 0) : 0;
1435
+ if (callerDepth >= this.maxDepth) {
1436
+ return {
1437
+ success: false,
1438
+ isError: true,
1439
+ error: `Max subagent depth ${this.maxDepth} reached (caller at depth ${callerDepth})`,
1440
+ };
1441
+ }
1442
+
1443
+ const parentAgentName = callerAgentName ?? this.config.parentAgentName ?? 'agent';
1444
+
1445
+ // Sync mode: block until completion, but detachable mid-flight.
1446
+ // Default timeout applies (600s) — auto-detaches to background.
1447
+ if (input.sync) {
1448
+ const timeoutMs = input.timeoutMs ?? this.maxExecutionMs;
1449
+ const promise = this.runSpawn(input, callerAgentName, callerDepth, timeoutMs);
1450
+ const result = await this.runDetachable(input.name, 'spawn', promise, parentAgentName, input.timeoutMs);
1451
+ return result;
1452
+ }
1453
+
1454
+ // Async mode (default): fire-and-forget, deliver result as message.
1455
+ // No default timeout — async agents run until they finish unless
1456
+ // the caller explicitly sets timeoutMs.
1457
+ const promise = this.runSpawn(input, callerAgentName, callerDepth, input.timeoutMs);
1458
+ this.asyncHandles.set(input.name, { name: input.name, type: 'spawn', promise, parentAgentName });
1459
+
1460
+ promise
1461
+ .then(result => this.deliverAsyncResult(input.name, result, parentAgentName))
1462
+ .catch(err => this.deliverAsyncError(input.name, err, parentAgentName));
1463
+
1464
+ return { success: true, data: `Subagent '${input.name}' spawned. Running in background.` };
1465
+ }
1466
+
1467
+ private async handleFork(input: ForkInput, callerAgentName?: string, callToolUseId?: string): Promise<ToolResult> {
1468
+ const callerDepth = callerAgentName ? (this.agentDepths.get(callerAgentName) ?? 0) : 0;
1469
+ if (callerDepth >= this.maxDepth) {
1470
+ return {
1471
+ success: false,
1472
+ isError: true,
1473
+ error: `Max subagent depth ${this.maxDepth} reached (caller at depth ${callerDepth})`,
1474
+ };
1475
+ }
1476
+
1477
+ const parentAgentName = callerAgentName ?? this.config.parentAgentName ?? 'agent';
1478
+
1479
+ // Sync mode: block until completion, but detachable mid-flight.
1480
+ // Default timeout applies (600s) — auto-detaches to background.
1481
+ if (input.sync) {
1482
+ const timeoutMs = input.timeoutMs ?? this.maxExecutionMs;
1483
+ const promise = this.runFork(input, callerAgentName, callerDepth, timeoutMs, callToolUseId);
1484
+ const result = await this.runDetachable(input.name, 'fork', promise, parentAgentName, input.timeoutMs);
1485
+ return result;
1486
+ }
1487
+
1488
+ // Async mode (default): fire-and-forget, deliver result as message.
1489
+ // No default timeout — async agents run until they finish unless
1490
+ // the caller explicitly sets timeoutMs.
1491
+ const promise = this.runFork(input, callerAgentName, callerDepth, input.timeoutMs, callToolUseId);
1492
+ this.asyncHandles.set(input.name, { name: input.name, type: 'fork', promise, parentAgentName });
1493
+
1494
+ promise
1495
+ .then(result => this.deliverAsyncResult(input.name, result, parentAgentName))
1496
+ .catch(err => this.deliverAsyncError(input.name, err, parentAgentName));
1497
+
1498
+ return { success: true, data: `Subagent '${input.name}' forked. Running in background.` };
1499
+ }
1500
+
1501
+ private deliverAsyncResult(name: string, result: SubagentResult, parentAgentName: string): void {
1502
+ this.asyncHandles.delete(name);
1503
+ if (!this.ctx) return;
1504
+
1505
+ this.ctx.addMessage('user', [{
1506
+ type: 'text',
1507
+ text: `[Subagent '${name}' returned]\n\n${result.summary}`,
1508
+ }]);
1509
+ this.ctx.pushEvent({
1510
+ type: 'inference-request',
1511
+ agentName: parentAgentName,
1512
+ reason: `subagent-completed:${name}`,
1513
+ source: 'subagent',
1514
+ });
1515
+ }
1516
+
1517
+ private deliverAsyncError(name: string, err: unknown, parentAgentName: string): void {
1518
+ this.asyncHandles.delete(name);
1519
+ if (!this.ctx) return;
1520
+
1521
+ const message = err instanceof Error ? err.message : String(err);
1522
+ this.ctx.addMessage('user', [{
1523
+ type: 'text',
1524
+ text: `[Subagent '${name}' failed]\n\nError: ${message}`,
1525
+ }]);
1526
+ this.ctx.pushEvent({
1527
+ type: 'inference-request',
1528
+ agentName: parentAgentName,
1529
+ reason: `subagent-failed:${name}`,
1530
+ source: 'subagent',
1531
+ });
1532
+ }
1533
+
1534
+ // =========================================================================
1535
+ // Detachable sync → async transition
1536
+ // =========================================================================
1537
+
1538
+ /**
1539
+ * Run a sync subagent with the ability to detach mid-flight.
1540
+ * Returns a ToolResult — either the completed result (if it finishes in time)
1541
+ * or a "moved to background" acknowledgment (if detached by user or timeout).
1542
+ */
1543
+ private async runDetachable(
1544
+ name: string,
1545
+ type: 'spawn' | 'fork',
1546
+ promise: Promise<SubagentResult>,
1547
+ parentAgentName: string,
1548
+ autoDetachMs?: number,
1549
+ ): Promise<ToolResult> {
1550
+ let detachResolve: ((value: 'detached') => void) | null = null;
1551
+ const detachPromise = new Promise<'detached'>(resolve => { detachResolve = resolve; });
1552
+
1553
+ const handle: DetachableHandle = {
1554
+ name,
1555
+ type,
1556
+ promise,
1557
+ parentAgentName,
1558
+ detach: () => detachResolve?.('detached'),
1559
+ };
1560
+ this.detachableHandles.set(name, handle);
1561
+
1562
+ // Optional auto-detach timeout (sync → async after N ms)
1563
+ let autoTimer: ReturnType<typeof setTimeout> | null = null;
1564
+ if (autoDetachMs !== undefined) {
1565
+ autoTimer = setTimeout(() => {
1566
+ if (this.detachableHandles.has(name)) {
1567
+ handle.detach();
1568
+ }
1569
+ }, autoDetachMs);
1570
+ }
1571
+
1572
+ type RaceResult =
1573
+ | { kind: 'completed'; result: SubagentResult }
1574
+ | { kind: 'error'; error: unknown }
1575
+ | { kind: 'detached' };
1576
+
1577
+ try {
1578
+ const winner: RaceResult = await Promise.race([
1579
+ promise.then(
1580
+ (result): RaceResult => ({ kind: 'completed', result }),
1581
+ (err): RaceResult => ({ kind: 'error', error: err }),
1582
+ ),
1583
+ detachPromise.then((): RaceResult => ({ kind: 'detached' })),
1584
+ ]);
1585
+
1586
+ if (autoTimer) clearTimeout(autoTimer);
1587
+ this.detachableHandles.delete(name);
1588
+
1589
+ if (winner.kind === 'completed') {
1590
+ return { success: true, data: winner.result };
1591
+ }
1592
+
1593
+ if (winner.kind === 'error') {
1594
+ const err = winner.error;
1595
+ return {
1596
+ success: false,
1597
+ isError: true,
1598
+ error: err instanceof Error ? err.message : String(err),
1599
+ };
1600
+ }
1601
+
1602
+ // Detached: transition to async — wire up result delivery
1603
+ this.asyncHandles.set(name, { name, type, promise, parentAgentName });
1604
+ promise
1605
+ .then(result => this.deliverAsyncResult(name, result, parentAgentName))
1606
+ .catch(err => this.deliverAsyncError(name, err, parentAgentName));
1607
+
1608
+ return {
1609
+ success: true,
1610
+ data: `Subagent '${name}' moved to background. Results will be delivered as a message when complete.`,
1611
+ };
1612
+ } catch {
1613
+ if (autoTimer) clearTimeout(autoTimer);
1614
+ this.detachableHandles.delete(name);
1615
+ return { success: false, isError: true, error: `Unexpected error in detachable handler for '${name}'` };
1616
+ }
1617
+ }
1618
+
1619
+ /**
1620
+ * Detach a currently-blocking sync subagent, converting it to async.
1621
+ * Returns true if the subagent was found and detached.
1622
+ */
1623
+ detachSubagent(name: string): boolean {
1624
+ const handle = this.detachableHandles.get(name);
1625
+ if (!handle) return false;
1626
+ handle.detach();
1627
+ return true;
1628
+ }
1629
+
1630
+ /**
1631
+ * Detach all currently-blocking sync subagents.
1632
+ * Returns the number of subagents detached.
1633
+ */
1634
+ detachAll(): number {
1635
+ let count = 0;
1636
+ for (const handle of this.detachableHandles.values()) {
1637
+ handle.detach();
1638
+ count++;
1639
+ }
1640
+ return count;
1641
+ }
1642
+
1643
+ /**
1644
+ * Check if any sync subagents are currently blocking (and thus detachable).
1645
+ */
1646
+ hasDetachable(): boolean {
1647
+ return this.detachableHandles.size > 0;
1648
+ }
1649
+
1650
+ private handleHud(input: { enabled: boolean }): ToolResult {
1651
+ if (!this.ctx) return { success: false, isError: true, error: 'Module not started' };
1652
+ const persisted = this.ctx.getState<{ agents?: unknown; hudEnabled?: boolean }>() ?? {};
1653
+ this.ctx.setState({ ...persisted, hudEnabled: input.enabled });
1654
+ return { success: true, data: { hudEnabled: input.enabled } };
1655
+ }
1656
+
1657
+ private handleConcurrency(input: { maxConcurrent?: number }): ToolResult {
1658
+ if (input.maxConcurrent !== undefined) {
1659
+ this.setConcurrency(input.maxConcurrent);
1660
+ }
1661
+ return { success: true, data: this.getConcurrencyStatus() };
1662
+ }
1663
+
1664
+ private async handlePeek(input: { name?: string }, caller?: string): Promise<ToolResult> {
1665
+ const snapshots = await this.peek(input.name, caller);
1666
+ if (snapshots.length === 0) {
1667
+ return {
1668
+ success: true,
1669
+ data: { message: input.name ? `No running subagent named '${input.name}'` : 'No running subagents' },
1670
+ };
1671
+ }
1672
+ return { success: true, data: snapshots };
1673
+ }
1674
+
1675
+ // =========================================================================
1676
+ // Subagent Execution
1677
+ // =========================================================================
1678
+
1679
+ private async runSpawn(input: SpawnInput, _callerAgentName?: string, callerDepth = 0, executionTimeoutMs?: number): Promise<SubagentResult> {
1680
+ const { waitedMs } = await this.acquireSlot();
1681
+ const childDepth = callerDepth + 1;
1682
+
1683
+ const now = Date.now();
1684
+ const entry: ActiveSubagent = {
1685
+ name: input.name, type: 'spawn', task: input.task,
1686
+ status: 'running', startedAt: now, lastActivityAt: now,
1687
+ toolCallsCount: 0, findingsCount: 0,
1688
+ };
1689
+ const entryKey = `spawn-${input.name}`;
1690
+ this.activeSubagents.set(entryKey, entry);
1691
+ if (_callerAgentName) this.parentMap.set(input.name, _callerAgentName);
1692
+ this.persistState();
1693
+
1694
+ try {
1695
+ const framework = this.getFramework();
1696
+ const model = input.model ?? this.config.defaultModel ?? 'claude-haiku-4-5-20251001';
1697
+ let lastError: Error | null = null;
1698
+
1699
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
1700
+ const agentName = `spawn-${input.name}-${Date.now()}`;
1701
+ const { agent, contextManager, cleanup } = await framework.createEphemeralAgent({
1702
+ name: agentName,
1703
+ model,
1704
+ systemPrompt: input.systemPrompt,
1705
+ maxTokens: this.resolveMaxTokens(input.maxTokens, _callerAgentName),
1706
+ maxStreamTokens: 500_000,
1707
+ strategy: new KnowledgeStrategy({
1708
+ headWindowTokens: 2_000,
1709
+ recentWindowTokens: 80_000,
1710
+ compressionModel: model,
1711
+ autoTickOnNewMessage: true,
1712
+ maxMessageTokens: 10_000,
1713
+ }),
1714
+ allowedTools: this.filterToolNames(input.tools, callerDepth),
1715
+ });
1716
+
1717
+ // Track depth for recursive fork/spawn calls from this agent
1718
+ this.agentDepths.set(agentName, childDepth);
1719
+
1720
+ // Register live state for peek observability
1721
+ this.registerLive(input.name, agentName, input.systemPrompt, contextManager);
1722
+
1723
+ try {
1724
+ contextManager.addMessage('user', [{ type: 'text', text: input.task }]);
1725
+
1726
+ // Pre-validate prompt size
1727
+ const { messages } = await contextManager.compile();
1728
+ const tools = framework.getAllTools().filter(t => agent.canUseTool(t.name));
1729
+ const est = this.estimatePromptTokens(agent.systemPrompt, messages, tools);
1730
+ if (est > this.maxPromptTokens) {
1731
+ throw new Error(
1732
+ `Prompt too large for subagent ${input.name}: ~${est} tokens ` +
1733
+ `(limit: ${this.maxPromptTokens}). Reduce context or task size.`
1734
+ );
1735
+ }
1736
+
1737
+ // Race execution against both timeout and user cancellation
1738
+ const cancelPromise = new Promise<never>((_, reject) => {
1739
+ this.cancellationHandles.set(input.name, { reject });
1740
+ });
1741
+
1742
+ let { speech, toolCallsCount } = await Promise.race([
1743
+ this.withTimeout(
1744
+ framework.runEphemeralToCompletion(agent, contextManager),
1745
+ input.name,
1746
+ executionTimeoutMs,
1747
+ ),
1748
+ cancelPromise,
1749
+ ]);
1750
+
1751
+ this.cancellationHandles.delete(input.name);
1752
+
1753
+ // Prefer explicit return over speech capture
1754
+ const returned = this.returnedResults.get(input.name);
1755
+ if (returned) {
1756
+ speech = returned;
1757
+ this.returnedResults.delete(input.name);
1758
+ } else if (!speech.trim()) {
1759
+ speech = this.extractLastAssistantText(contextManager);
1760
+ }
1761
+
1762
+ entry.status = 'completed';
1763
+ entry.completedAt = Date.now();
1764
+ entry.toolCallsCount = toolCallsCount;
1765
+ this.onSubagentSuccess();
1766
+ const notice = this.concurrencyNotice(waitedMs);
1767
+ const finalSummary = notice + speech;
1768
+ this.emit(input.name, { type: 'done', summary: finalSummary, lastInputTokens: this.lastInputTokens.get(input.name) });
1769
+ return { summary: finalSummary, findings: [], issues: [], toolCallsCount };
1770
+ } catch (err) {
1771
+ lastError = err instanceof Error ? err : new Error(String(err));
1772
+ this.cancellationHandles.delete(input.name);
1773
+
1774
+ // Non-retryable termination (user cancel, zombie reclaim, etc.).
1775
+ // Postmortem 2026-05-28 P1 #4: terminal-but-benign — 'cancelled',
1776
+ // not 'completed'. Before this, the TUI subagent list rendered these
1777
+ // as "done" while the reducer-driven web tree rendered them red.
1778
+ if (lastError instanceof SubagentTerminated) {
1779
+ entry.status = 'cancelled';
1780
+ entry.completedAt = Date.now();
1781
+ entry.statusMessage = lastError.reason;
1782
+ const notice = this.concurrencyNotice(waitedMs);
1783
+ const label = lastError.reason === 'cancelled' ? 'Stopped by user' : `Terminated: ${lastError.reason}`;
1784
+ const summary = notice + `[${label}] ` + (lastError.partialOutput || '(no output yet)');
1785
+ this.emit(input.name, { type: 'done', summary, lastInputTokens: this.lastInputTokens.get(input.name) });
1786
+ return { summary, findings: [], issues: [], toolCallsCount: entry.toolCallsCount };
1787
+ }
1788
+
1789
+ if (this.isRateLimitError(lastError)) await this.onRateLimitHit();
1790
+ if (!this.isTransientError(lastError) || attempt === this.maxRetries) break;
1791
+
1792
+ const delay = Math.min(5_000 * (attempt + 1), 30_000);
1793
+ console.error(
1794
+ `[subagent] ${input.name} attempt ${attempt + 1}/${this.maxRetries + 1} failed: ` +
1795
+ `${lastError.message}. Restarting in ${delay}ms...`
1796
+ );
1797
+ entry.statusMessage = `Retry ${attempt + 1}: ${lastError.message}`;
1798
+ await new Promise(resolve => setTimeout(resolve, delay));
1799
+ } finally {
1800
+ this.agentDepths.delete(agentName);
1801
+ this.unregisterLive(input.name, agentName);
1802
+ cleanup();
1803
+ }
1804
+ }
1805
+
1806
+ entry.status = 'failed';
1807
+ entry.completedAt = Date.now();
1808
+ entry.statusMessage = lastError!.message;
1809
+ throw lastError!;
1810
+ } finally {
1811
+ this.persistState();
1812
+ this.releaseSlot();
1813
+ }
1814
+ }
1815
+
1816
+ private async runFork(input: ForkInput, callerAgentName?: string, callerDepth = 0, executionTimeoutMs?: number, callToolUseId?: string): Promise<SubagentResult> {
1817
+ const { waitedMs } = await this.acquireSlot();
1818
+ const childDepth = callerDepth + 1;
1819
+
1820
+ const now = Date.now();
1821
+ const entry: ActiveSubagent = {
1822
+ name: input.name, type: 'fork', task: input.task,
1823
+ status: 'running', startedAt: now, lastActivityAt: now,
1824
+ toolCallsCount: 0, findingsCount: 0,
1825
+ };
1826
+ this.activeSubagents.set(input.name, entry);
1827
+ if (callerAgentName) this.parentMap.set(input.name, callerAgentName);
1828
+ this.persistState();
1829
+
1830
+ try {
1831
+ const framework = this.getFramework();
1832
+
1833
+ // Dynamic parent resolution: prefer the caller agent (enables recursive forks),
1834
+ // fall back to the configured parent agent for backward compat.
1835
+ const parentAgent = callerAgentName
1836
+ ? framework.getAgent(callerAgentName)
1837
+ : (this.config.parentAgentName ? framework.getAgent(this.config.parentAgentName) : null);
1838
+
1839
+ const systemPrompt = input.systemPrompt
1840
+ ?? (parentAgent ? parentAgent.systemPrompt : 'You are a research assistant.');
1841
+
1842
+ const model = input.model ?? this.config.defaultModel ?? 'claude-haiku-4-5-20251001';
1843
+ let lastError: Error | null = null;
1844
+
1845
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
1846
+ // Unique agent name: include depth + timestamp to prevent collisions when
1847
+ // a child fork uses the same display name as its parent (e.g. both called
1848
+ // "fork-level-2"). Without this, the child overwrites the parent in the
1849
+ // framework's agents Map, and the parent's completion promise never resolves.
1850
+ const suffix = attempt === 0 ? `d${childDepth}-${Date.now()}` : `d${childDepth}-retry${attempt}-${Date.now()}`;
1851
+ const agentName = `${input.name}-${suffix}`;
1852
+
1853
+ const { agent, contextManager, cleanup } = await framework.createEphemeralAgent({
1854
+ name: agentName,
1855
+ model,
1856
+ systemPrompt,
1857
+ maxTokens: this.resolveMaxTokens(input.maxTokens, callerAgentName),
1858
+ maxStreamTokens: 500_000,
1859
+ strategy: new KnowledgeStrategy({
1860
+ headWindowTokens: 2_000,
1861
+ recentWindowTokens: 80_000,
1862
+ compressionModel: model,
1863
+ autoTickOnNewMessage: true,
1864
+ maxMessageTokens: 10_000,
1865
+ }),
1866
+ allowedTools: this.filterToolNames(undefined, callerDepth),
1867
+ });
1868
+
1869
+ // Track depth for recursive fork/spawn calls from this agent
1870
+ this.agentDepths.set(agentName, childDepth);
1871
+
1872
+ // Register live state for peek observability
1873
+ this.registerLive(input.name, agentName, systemPrompt, contextManager);
1874
+
1875
+ try {
1876
+ // Materialise the fork's inherited context structurally:
1877
+ // - locate the parent's matching subagent--fork tool_use by id
1878
+ // - strip sibling fork tool_use blocks (and their tool_results) from
1879
+ // the parent's last assistant turn — they read as fleet-dispatch
1880
+ // evidence and convince the model it's the parent
1881
+ // - rewrite the matching tool_result with intention-stream framing
1882
+ // that names the dual-self situation explicitly
1883
+ // - drop everything after that tool_result (post-fork peek/wait
1884
+ // turns are the other big chunk of parent-coherence signal)
1885
+ // If parent context is compressed past the fork point and the
1886
+ // matching tool_use can't be located, fall back to wholesale copy
1887
+ // plus a synthetic intention-framed fork tool_use/tool_result.
1888
+ if (parentAgent) {
1889
+ const parentCM = parentAgent.getContextManager();
1890
+ const { messages: compiled } = await parentCM.compile();
1891
+ const transformed = callToolUseId
1892
+ ? materialiseStructuralFork(
1893
+ compiled,
1894
+ callToolUseId,
1895
+ input.name,
1896
+ input.task,
1897
+ childDepth,
1898
+ this.maxDepth,
1899
+ )
1900
+ : null;
1901
+
1902
+ const seen = new Set<string>();
1903
+ const addMsg = (msg: { participant: string; content: ContentBlock[] }) => {
1904
+ const key = msg.participant + '\0' + JSON.stringify(msg.content);
1905
+ if (seen.has(key)) return;
1906
+ seen.add(key);
1907
+ const participant = msg.participant === parentAgent.name ? agentName : msg.participant;
1908
+ contextManager.addMessage(participant, msg.content);
1909
+ };
1910
+
1911
+ if (transformed) {
1912
+ for (const msg of transformed) addMsg(msg);
1913
+ } else {
1914
+ // Fallback path: matching tool_use not located (parent compressed it
1915
+ // away, or no callToolUseId). Wholesale copy is safe here because
1916
+ // the cascade-bait — the matching fork tool_use and its siblings —
1917
+ // got compressed away with the rest of the assistant turn. Append
1918
+ // a synthetic intention-framed result so the child still gets the
1919
+ // dual-stream framing as the salient last message.
1920
+ for (const msg of compiled) addMsg(msg);
1921
+
1922
+ const fallbackForkId = `fork-${input.name}-${crypto.randomUUID()}`;
1923
+ contextManager.addMessage(agentName, [{
1924
+ type: 'tool_use',
1925
+ id: fallbackForkId,
1926
+ name: 'subagent--fork',
1927
+ input: { name: input.name, task: input.task },
1928
+ }] as ContentBlock[]);
1929
+ contextManager.addMessage('user', [{
1930
+ type: 'tool_result',
1931
+ toolUseId: fallbackForkId,
1932
+ content: buildIntentionFramedForkResult(input.name, input.task, childDepth, this.maxDepth),
1933
+ }] as ContentBlock[]);
1934
+ }
1935
+ }
1936
+
1937
+ // Pre-validate prompt size
1938
+ const { messages } = await contextManager.compile();
1939
+ const tools = framework.getAllTools().filter(t => agent.canUseTool(t.name));
1940
+ const est = this.estimatePromptTokens(agent.systemPrompt, messages, tools);
1941
+ if (est > this.maxPromptTokens) {
1942
+ throw new Error(
1943
+ `Prompt too large for subagent ${input.name}: ~${est} tokens ` +
1944
+ `(limit: ${this.maxPromptTokens}). Reduce context or task size.`
1945
+ );
1946
+ }
1947
+
1948
+ // Race execution against both timeout and user cancellation
1949
+ const cancelPromise = new Promise<never>((_, reject) => {
1950
+ this.cancellationHandles.set(input.name, { reject });
1951
+ });
1952
+
1953
+ let { speech, toolCallsCount } = await Promise.race([
1954
+ this.withTimeout(
1955
+ framework.runEphemeralToCompletion(agent, contextManager),
1956
+ input.name,
1957
+ executionTimeoutMs,
1958
+ ),
1959
+ cancelPromise,
1960
+ ]);
1961
+
1962
+ this.cancellationHandles.delete(input.name);
1963
+
1964
+ // Prefer explicit return over speech capture
1965
+ const returned = this.returnedResults.get(input.name);
1966
+ if (returned) {
1967
+ speech = returned;
1968
+ this.returnedResults.delete(input.name);
1969
+ } else if (!speech.trim()) {
1970
+ speech = this.extractLastAssistantText(contextManager);
1971
+ }
1972
+
1973
+ entry.status = 'completed';
1974
+ entry.completedAt = Date.now();
1975
+ entry.toolCallsCount = toolCallsCount;
1976
+ this.onSubagentSuccess();
1977
+ const notice = this.concurrencyNotice(waitedMs);
1978
+ const finalSummary = notice + speech;
1979
+ this.emit(input.name, { type: 'done', summary: finalSummary, lastInputTokens: this.lastInputTokens.get(input.name) });
1980
+ return { summary: finalSummary, findings: [], issues: [], toolCallsCount };
1981
+ } catch (err) {
1982
+ lastError = err instanceof Error ? err : new Error(String(err));
1983
+ this.cancellationHandles.delete(input.name);
1984
+
1985
+ // Non-retryable termination (user cancel, zombie reclaim, etc.).
1986
+ // See P1 #4 comment in runSpawn — 'cancelled', not 'completed'.
1987
+ if (lastError instanceof SubagentTerminated) {
1988
+ entry.status = 'cancelled';
1989
+ entry.completedAt = Date.now();
1990
+ entry.statusMessage = lastError.reason;
1991
+ const notice = this.concurrencyNotice(waitedMs);
1992
+ const label = lastError.reason === 'cancelled' ? 'Stopped by user' : `Terminated: ${lastError.reason}`;
1993
+ const summary = notice + `[${label}] ` + (lastError.partialOutput || '(no output yet)');
1994
+ this.emit(input.name, { type: 'done', summary, lastInputTokens: this.lastInputTokens.get(input.name) });
1995
+ return { summary, findings: [], issues: [], toolCallsCount: entry.toolCallsCount };
1996
+ }
1997
+
1998
+ if (this.isRateLimitError(lastError)) await this.onRateLimitHit();
1999
+ if (!this.isTransientError(lastError) || attempt === this.maxRetries) break;
2000
+
2001
+ const delay = Math.min(5_000 * (attempt + 1), 30_000);
2002
+ console.error(
2003
+ `[subagent] ${input.name} attempt ${attempt + 1}/${this.maxRetries + 1} failed: ` +
2004
+ `${lastError.message}. Restarting in ${delay}ms...`
2005
+ );
2006
+ entry.statusMessage = `Retry ${attempt + 1}: ${lastError.message}`;
2007
+ await new Promise(resolve => setTimeout(resolve, delay));
2008
+ } finally {
2009
+ this.agentDepths.delete(agentName);
2010
+ this.unregisterLive(input.name, agentName);
2011
+ cleanup();
2012
+ }
2013
+ }
2014
+
2015
+ entry.status = 'failed';
2016
+ entry.completedAt = Date.now();
2017
+ entry.statusMessage = lastError!.message;
2018
+ throw lastError!;
2019
+ } finally {
2020
+ this.persistState();
2021
+ this.releaseSlot();
2022
+ }
2023
+ }
2024
+
2025
+ /**
2026
+ * Extract the last assistant text from a context manager's messages.
2027
+ * Fallback when the streaming speech capture is empty (e.g., agent's
2028
+ * last action was a tool call, or speech was reset on stream_resumed).
2029
+ */
2030
+ private extractLastAssistantText(contextManager: ContextManager): string {
2031
+ try {
2032
+ const messages = contextManager.getAllMessages();
2033
+ for (let i = messages.length - 1; i >= 0; i--) {
2034
+ const msg = messages[i];
2035
+ if (msg.participant === 'user' || msg.participant === 'User') continue;
2036
+ const texts = msg.content
2037
+ .filter((b: ContentBlock) => b.type === 'text')
2038
+ .map((b: ContentBlock) => (b as { type: 'text'; text: string }).text);
2039
+ if (texts.length > 0) return texts.join('\n');
2040
+ }
2041
+ } catch {
2042
+ // best-effort
2043
+ }
2044
+ return '(no text output)';
2045
+ }
2046
+
2047
+ /**
2048
+ * Build the allowedTools list for a subagent.
2049
+ * Removes subagent tools if at depth limit.
2050
+ *
2051
+ */
2052
+ private filterToolNames(allowedTools?: string[], callerDepth = 0): 'all' | string[] {
2053
+ // Always include subagent--return — subagents need it to deliver results
2054
+ const ensureReturn = (list: string[]) => {
2055
+ if (!list.includes('subagent--return')) list.push('subagent--return');
2056
+ return list;
2057
+ };
2058
+
2059
+ // Use per-agent depth (from caller) rather than the module's static depth
2060
+ if (callerDepth + 1 >= this.maxDepth) {
2061
+ const allTools = this.getFramework().getAllTools();
2062
+ const filtered = allTools
2063
+ .filter(t => !t.name.startsWith('subagent--'))
2064
+ .map(t => t.name);
2065
+ if (allowedTools) {
2066
+ const allowed = new Set(allowedTools);
2067
+ return ensureReturn(filtered.filter(n => allowed.has(n)));
2068
+ }
2069
+ return ensureReturn(filtered);
2070
+ }
2071
+ return allowedTools ? ensureReturn(allowedTools) : 'all';
2072
+ }
2073
+
2074
+ }