@tt-a1i/openpi 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -9,6 +9,11 @@
9
9
  * The manager also exposes a synchronous `SubagentReadModel` so the
10
10
  * imperative TUI components (which render synchronously) can read snapshots
11
11
  * and issue fire-and-forget commands without touching the Effect runtime.
12
+ *
13
+ * Every run is guarded by a first-response watchdog: a provider that accepts
14
+ * the request but never emits its first assistant event is settled as a
15
+ * failure (releasing its concurrency slot) instead of hanging forever,
16
+ * mirroring the workflow runner's watchdog.
12
17
  */
13
18
 
14
19
  import {
@@ -22,6 +27,7 @@ import {
22
27
  Stream,
23
28
  } from "effect";
24
29
  import type { SubagentBackend, SubagentSession } from "./backend.ts";
30
+ import type { AgentToolRenderer } from "../../shared/agent-tool-renderer.ts";
25
31
  import { BackendRegistry } from "./backend.ts";
26
32
  import type {
27
33
  BackendName,
@@ -54,6 +60,13 @@ export const MAX_TRACKED = 64;
54
60
  const STOP_TIMEOUT_MS = 5_000;
55
61
  /** Session abort/shutdown (5s) plus bounded direct-worktree cleanup (4s). */
56
62
  const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
63
+ /**
64
+ * First-response watchdog: a run whose provider accepts the request but
65
+ * never emits an assistant event is settled as a failure so it cannot
66
+ * occupy a concurrency slot forever. Matches the workflow runner's
67
+ * MODEL_PROGRESS_TIMEOUT_MS (extensions/workflows/runner.ts).
68
+ */
69
+ export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
57
70
  const ERROR_TEXT_MAX_LENGTH = 4_096;
58
71
  const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
59
72
  const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
@@ -64,6 +77,10 @@ function bounded(text: string) {
64
77
  return text.slice(0, ERROR_TEXT_MAX_LENGTH);
65
78
  }
66
79
 
80
+ function formatWatchdogTimeout(ms: number) {
81
+ return ms % 1_000 === 0 ? `${ms / 1_000} seconds` : `${ms} ms`;
82
+ }
83
+
67
84
  function boundedTranscriptText(text: string) {
68
85
  return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
69
86
  }
@@ -76,6 +93,9 @@ function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
76
93
  snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
77
94
  );
78
95
  }
96
+ // Monotonic version so UI projections can detect transcript changes by
97
+ // O(1) comparison instead of scanning the mutable array.
98
+ snapshot.transcriptVersion++;
79
99
  }
80
100
 
81
101
  // --- Internal state -----------------------------------------------------------
@@ -89,12 +109,16 @@ interface MutableSnapshot {
89
109
  prompt: string;
90
110
  cwd: string;
91
111
  status: SubagentStatus;
112
+ outcome?: SubagentSnapshot["outcome"];
113
+ worktreeBranch?: string;
92
114
  createdAt: number;
93
115
  settledAt?: number;
94
116
  errorText?: string;
95
117
  meta: SubagentMeta;
96
118
  usage: { tokens?: number; contextWindow?: number };
97
119
  transcript: TranscriptItem[];
120
+ /** Bumped on every transcript mutation; UI caches key on this. */
121
+ transcriptVersion: number;
98
122
  liveAssistant?: { text: string; thinking: string };
99
123
  liveTools: LiveToolState[];
100
124
  queued: SubagentSnapshot["queued"];
@@ -108,6 +132,8 @@ interface Entry {
108
132
  scope: Scope.Closeable;
109
133
  pump?: Fiber.Fiber<void>;
110
134
  liveToolMap: Map<string, LiveToolState>;
135
+ /** First-response watchdog timer for the active (or just-armed) run. */
136
+ watchdogTimer?: ReturnType<typeof setTimeout>;
111
137
  /** Idle restart dispatched but RunStarted not folded yet; counts as running
112
138
  * so concurrent restarts cannot race past the cap. */
113
139
  restarting?: boolean;
@@ -119,6 +145,8 @@ interface Entry {
119
145
  export interface SubagentReadModel {
120
146
  list(): ReadonlyArray<SubagentSnapshot>;
121
147
  get(id: string): SubagentSnapshot | undefined;
148
+ /** Native tool projection retained by the live child session, when present. */
149
+ getToolRenderer?(id: string): AgentToolRenderer | undefined;
122
150
  size(): number;
123
151
  /** Any-change notification (footer status, dashboard). */
124
152
  subscribe(listener: () => void): () => void;
@@ -183,589 +211,676 @@ export class SubagentManager extends Context.Service<
183
211
 
184
212
  // --- Implementation --------------------------------------------------------------
185
213
 
186
- const makeManager = Effect.gen(function* () {
187
- const registry = yield* BackendRegistry;
188
- // Detached forker for sync contexts (read-model commands, pruning) that
189
- // preserves the manager's services instead of using the global runtime.
190
- const runDetached = Effect.runForkWith(yield* Effect.context());
191
-
192
- const entries = new Map<string, Entry>();
193
- const waitInterest = new Map<string, number>();
194
- const listeners = new Set<() => void>();
195
- /** One-shot nextChange waiters, swapped out before invocation so waiters
196
- * re-registering during notification are not visited in the same sweep. */
197
- let changeWaiters: Array<() => void> = [];
198
- const idListeners = new Map<string, Set<() => void>>();
199
- const cleanups = new Set<Fiber.Fiber<unknown>>();
200
- let modelCounter = 0;
201
- let btwCounter = 0;
202
- // Reservations are tracked per pool so the model and user "by the way" asides
203
- // never contend for the same slots.
204
- let reservedModel = 0;
205
- let reservedBtw = 0;
206
- let disposed = false;
207
- let onSettled:
208
- | ((snap: SubagentSnapshot, consumed: boolean) => void)
209
- | undefined;
210
-
211
- const notify = (id?: string) => {
212
- const waiters = changeWaiters;
213
- changeWaiters = [];
214
- for (const waiter of waiters) waiter();
215
- for (const listener of [...listeners]) {
216
- try {
217
- listener();
218
- } catch {
219
- // A failed status/render listener must not corrupt lifecycle state.
220
- }
221
- }
222
- if (id) {
223
- for (const listener of idListeners.get(id) ?? []) {
214
+ const makeManager = (config: SubagentManagerConfig = {}) =>
215
+ Effect.gen(function* () {
216
+ const firstResponseTimeoutMs =
217
+ config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
218
+ const registry = yield* BackendRegistry;
219
+ // Detached forker for sync contexts (read-model commands, pruning) that
220
+ // preserves the manager's services instead of using the global runtime.
221
+ const runDetached = Effect.runForkWith(yield* Effect.context());
222
+
223
+ const entries = new Map<string, Entry>();
224
+ const waitInterest = new Map<string, number>();
225
+ const listeners = new Set<() => void>();
226
+ /** One-shot nextChange waiters, swapped out before invocation so waiters
227
+ * re-registering during notification are not visited in the same sweep. */
228
+ let changeWaiters: Array<() => void> = [];
229
+ const idListeners = new Map<string, Set<() => void>>();
230
+ const cleanups = new Set<Fiber.Fiber<unknown>>();
231
+ let modelCounter = config.initialModelCounter ?? 0;
232
+ let btwCounter = config.initialBtwCounter ?? 0;
233
+ // Reservations are tracked per pool so the model and user "by the way" asides
234
+ // never contend for the same slots.
235
+ let reservedModel = 0;
236
+ let reservedBtw = 0;
237
+ let disposed = false;
238
+ let onSettled:
239
+ | ((snap: SubagentSnapshot, consumed: boolean) => void)
240
+ | undefined;
241
+
242
+ const notify = (id?: string) => {
243
+ const waiters = changeWaiters;
244
+ changeWaiters = [];
245
+ for (const waiter of waiters) waiter();
246
+ for (const listener of [...listeners]) {
224
247
  try {
225
248
  listener();
226
249
  } catch {
227
- // Same.
250
+ // A failed status/render listener must not corrupt lifecycle state.
228
251
  }
229
252
  }
230
- }
231
- };
232
-
233
- /** Resolves on the next state change. Interruption unregisters the waiter. */
234
- const nextChange = Effect.callback<void>((resume) => {
235
- const waiter = () => resume(Effect.void);
236
- changeWaiters.push(waiter);
237
- return Effect.sync(() => {
238
- const index = changeWaiters.indexOf(waiter);
239
- if (index >= 0) changeWaiters.splice(index, 1);
253
+ if (id) {
254
+ for (const listener of idListeners.get(id) ?? []) {
255
+ try {
256
+ listener();
257
+ } catch {
258
+ // Same.
259
+ }
260
+ }
261
+ }
262
+ };
263
+
264
+ /** Resolves on the next state change. Interruption unregisters the waiter. */
265
+ const nextChange = Effect.callback<void>((resume) => {
266
+ const waiter = () => resume(Effect.void);
267
+ changeWaiters.push(waiter);
268
+ return Effect.sync(() => {
269
+ const index = changeWaiters.indexOf(waiter);
270
+ if (index >= 0) changeWaiters.splice(index, 1);
271
+ });
240
272
  });
241
- });
242
273
 
243
- /**
244
- * A restart dispatched by `send` occupies a slot immediately, but the
245
- * `RunStarted` that flips `snapshot.status` only arrives on the async pump.
246
- * Every caller that asks "is this busy?" must honor that window, or a
247
- * wait/cancel issued in the same turn as the restart would observe the old
248
- * settled run and return (or cancel) the wrong thing.
249
- */
250
- const isBusy = (entry: Entry | undefined) =>
251
- entry !== undefined &&
252
- (entry.snapshot.status === "running" || entry.restarting === true);
253
-
254
- const runningCount = (origin?: SubagentOrigin) =>
255
- [...entries.values()].filter(
256
- (e) =>
257
- isBusy(e) && (origin === undefined || e.snapshot.origin === origin),
258
- ).length;
259
-
260
- /** Per-pool capacity: model asides and user "by the way" asides never mix. */
261
- const poolLimit = (origin: SubagentOrigin) =>
262
- origin === "btw" ? MAX_RUNNING_BTW : MAX_RUNNING;
263
- const poolReserved = (origin: SubagentOrigin) =>
264
- origin === "btw" ? reservedBtw : reservedModel;
265
- const atPoolCapacity = (origin: SubagentOrigin) =>
266
- runningCount(origin) + poolReserved(origin) >= poolLimit(origin);
267
-
268
- const addInterest = (ids: ReadonlyArray<string>) => {
269
- for (const id of ids) waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
270
- };
271
- const releaseInterest = (ids: ReadonlyArray<string>) => {
272
- for (const id of ids) {
273
- const count = (waitInterest.get(id) ?? 1) - 1;
274
- if (count <= 0) waitInterest.delete(id);
275
- else waitInterest.set(id, count);
276
- }
277
- };
278
-
279
- const closeEntryScope = (entry: Entry) =>
280
- Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
281
-
282
- const pruneSettled = () => {
283
- if (entries.size <= MAX_TRACKED) return;
284
- const candidates = [...entries.values()]
285
- .filter((e) => !isBusy(e) && !waitInterest.has(e.snapshot.id))
286
- .sort(
287
- (a, b) =>
288
- (a.snapshot.settledAt ?? a.snapshot.createdAt) -
289
- (b.snapshot.settledAt ?? b.snapshot.createdAt),
274
+ /**
275
+ * A restart dispatched by `send` occupies a slot immediately, but the
276
+ * `RunStarted` that flips `snapshot.status` only arrives on the async pump.
277
+ * Every caller that asks "is this busy?" must honor that window, or a
278
+ * wait/cancel issued in the same turn as the restart would observe the old
279
+ * settled run and return (or cancel) the wrong thing.
280
+ */
281
+ const isBusy = (entry: Entry | undefined) =>
282
+ entry !== undefined &&
283
+ (entry.snapshot.status === "running" || entry.restarting === true);
284
+
285
+ const runningCount = (origin?: SubagentOrigin) =>
286
+ [...entries.values()].filter(
287
+ (e) =>
288
+ isBusy(e) && (origin === undefined || e.snapshot.origin === origin),
289
+ ).length;
290
+
291
+ /** Per-pool capacity: model asides and user "by the way" asides never mix. */
292
+ const poolLimit = (origin: SubagentOrigin) =>
293
+ origin === "btw" ? MAX_RUNNING_BTW : MAX_RUNNING;
294
+ const poolReserved = (origin: SubagentOrigin) =>
295
+ origin === "btw" ? reservedBtw : reservedModel;
296
+ const atPoolCapacity = (origin: SubagentOrigin) =>
297
+ runningCount(origin) + poolReserved(origin) >= poolLimit(origin);
298
+
299
+ const addInterest = (ids: ReadonlyArray<string>) => {
300
+ for (const id of ids)
301
+ waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
302
+ };
303
+ const releaseInterest = (ids: ReadonlyArray<string>) => {
304
+ for (const id of ids) {
305
+ const count = (waitInterest.get(id) ?? 1) - 1;
306
+ if (count <= 0) waitInterest.delete(id);
307
+ else waitInterest.set(id, count);
308
+ }
309
+ };
310
+
311
+ const closeEntryScope = (entry: Entry) =>
312
+ Scope.close(entry.scope, Exit.void).pipe(
313
+ Effect.tap(() =>
314
+ Effect.sync(() => {
315
+ const receipt = entry.session.cleanupReceipt?.();
316
+ if (!receipt?.uncertain) return;
317
+ const current = entry.snapshot.errorText;
318
+ entry.snapshot.errorText = current
319
+ ? `${current}; ${receipt.message}`
320
+ : receipt.message;
321
+ notify(entry.snapshot.id);
322
+ }),
323
+ ),
324
+ Effect.ignore,
290
325
  );
291
- for (const entry of candidates) {
292
- if (entries.size <= MAX_TRACKED) break;
293
- entries.delete(entry.snapshot.id);
294
- const fiber = runDetached(closeEntryScope(entry));
295
- cleanups.add(fiber);
296
- fiber.addObserver(() => cleanups.delete(fiber));
297
- }
298
- };
299
-
300
- const settle = (entry: Entry, outcome: RunOutcome) => {
301
- const s = entry.snapshot;
302
- const wasRestarting = entry.restarting === true;
303
- entry.restarting = false;
304
- if (s.status !== "running") {
305
- if (!wasRestarting) return;
306
- // A cancel can clear a queued restart before RunStarted reaches the
307
- // manager. Its RunSettled still belongs to the new run, not the old
308
- // settled snapshot, so promote the lifecycle before applying it.
309
- s.status = "running";
310
- s.settledAt = undefined;
311
- s.errorText = undefined;
312
- }
313
- s.settledAt = Date.now();
314
- switch (outcome._tag) {
315
- case "Completed":
316
- s.status = "done";
317
- s.errorText = undefined;
318
- s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
319
- break;
320
- case "Failed":
321
- s.status = "error";
322
- s.errorText = bounded(outcome.errorText);
323
- // Never let a failed run report the previous run's successful output.
324
- s.finalText = (outcome.partialText ?? "").slice(
325
- 0,
326
- FINAL_TEXT_MAX_LENGTH,
327
- );
328
- break;
329
- case "Interrupted":
330
- s.status = "error";
331
- s.errorText = "Run was aborted";
332
- s.finalText = (outcome.partialText ?? "").slice(
333
- 0,
334
- FINAL_TEXT_MAX_LENGTH,
326
+
327
+ const pruneSettled = () => {
328
+ if (entries.size <= MAX_TRACKED) return;
329
+ const candidates = [...entries.values()]
330
+ .filter((e) => !isBusy(e) && !waitInterest.has(e.snapshot.id))
331
+ .sort(
332
+ (a, b) =>
333
+ (a.snapshot.settledAt ?? a.snapshot.createdAt) -
334
+ (b.snapshot.settledAt ?? b.snapshot.createdAt),
335
335
  );
336
- break;
337
- }
338
- s.liveAssistant = undefined;
339
- entry.liveToolMap.clear();
340
- s.liveTools = [];
341
- s.queued = [];
342
- const consumed = (waitInterest.get(s.id) ?? 0) > 0;
343
- notify(s.id);
344
- try {
345
- // During teardown, don't queue results into a shutting-down session.
346
- if (!disposed) onSettled?.(s, consumed);
347
- } catch {
348
- // The parent session may be unavailable; settlement stays final.
349
- }
350
- pruneSettled();
351
- };
352
-
353
- const foldEvent = (entry: Entry, event: SubagentEvent) => {
354
- const s = entry.snapshot;
355
- switch (event._tag) {
356
- case "RunStarted":
357
- entry.restarting = false;
336
+ for (const entry of candidates) {
337
+ if (entries.size <= MAX_TRACKED) break;
338
+ entries.delete(entry.snapshot.id);
339
+ const fiber = runDetached(closeEntryScope(entry));
340
+ cleanups.add(fiber);
341
+ fiber.addObserver(() => cleanups.delete(fiber));
342
+ }
343
+ };
344
+
345
+ const settle = (entry: Entry, outcome: RunOutcome) => {
346
+ clearWatchdog(entry);
347
+ const s = entry.snapshot;
348
+ const wasRestarting = entry.restarting === true;
349
+ entry.restarting = false;
350
+ if (s.status !== "running") {
351
+ if (!wasRestarting) return;
352
+ // A cancel can clear a queued restart before RunStarted reaches the
353
+ // manager. Its RunSettled still belongs to the new run, not the old
354
+ // settled snapshot, so promote the lifecycle before applying it.
358
355
  s.status = "running";
359
356
  s.settledAt = undefined;
360
357
  s.errorText = undefined;
361
- break;
362
- case "RunSettled":
363
- settle(entry, event.outcome);
364
- return; // settle() already notified
365
- case "UserMessage":
366
- appendTranscript(s, {
367
- kind: "user",
368
- text: boundedTranscriptText(event.text),
369
- });
370
- break;
371
- case "AssistantDelta": {
372
- const live = s.liveAssistant ?? { text: "", thinking: "" };
373
- s.liveAssistant =
374
- event.kind === "text"
375
- ? {
376
- ...live,
377
- text: (live.text + event.delta).slice(
378
- -LIVE_ASSISTANT_MAX_LENGTH,
379
- ),
380
- }
381
- : {
382
- ...live,
383
- thinking: (live.thinking + event.delta).slice(
384
- -LIVE_ASSISTANT_MAX_LENGTH,
385
- ),
386
- };
387
- break;
388
358
  }
389
- case "AssistantMessage":
390
- appendTranscript(s, {
391
- kind: "assistant",
392
- parts: event.parts.map((part) =>
393
- part.type === "toolCall"
359
+ s.settledAt = Date.now();
360
+ switch (outcome._tag) {
361
+ case "Completed":
362
+ s.status = "done";
363
+ s.outcome = "completed";
364
+ s.errorText = undefined;
365
+ s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
366
+ break;
367
+ case "Failed":
368
+ s.status = "error";
369
+ s.outcome = "failed";
370
+ s.errorText = bounded(outcome.errorText);
371
+ // Never let a failed run report the previous run's successful output.
372
+ s.finalText = (outcome.partialText ?? "").slice(
373
+ 0,
374
+ FINAL_TEXT_MAX_LENGTH,
375
+ );
376
+ break;
377
+ case "Interrupted":
378
+ s.status = "error";
379
+ s.outcome = "interrupted";
380
+ s.errorText = "Run was aborted";
381
+ s.finalText = (outcome.partialText ?? "").slice(
382
+ 0,
383
+ FINAL_TEXT_MAX_LENGTH,
384
+ );
385
+ break;
386
+ }
387
+ s.liveAssistant = undefined;
388
+ entry.liveToolMap.clear();
389
+ s.liveTools = [];
390
+ s.queued = [];
391
+ const consumed = (waitInterest.get(s.id) ?? 0) > 0;
392
+ notify(s.id);
393
+ try {
394
+ // During teardown, don't queue results into a shutting-down session.
395
+ if (!disposed) onSettled?.(s, consumed);
396
+ } catch {
397
+ // The parent session may be unavailable; settlement stays final.
398
+ }
399
+ pruneSettled();
400
+ };
401
+
402
+ /** Stop the first-response watchdog (first response arrived / run settled). */
403
+ const clearWatchdog = (entry: Entry) => {
404
+ if (entry.watchdogTimer !== undefined) {
405
+ clearTimeout(entry.watchdogTimer);
406
+ entry.watchdogTimer = undefined;
407
+ }
408
+ };
409
+
410
+ /** Settle a run whose provider never emitted a first assistant response. */
411
+ const watchdogExpired = (entry: Entry) => {
412
+ entry.watchdogTimer = undefined;
413
+ if (!isBusy(entry)) return;
414
+ const model = entry.snapshot.meta.modelLabel;
415
+ settle(entry, {
416
+ _tag: "Failed",
417
+ errorText: `Agent received no assistant response event${model ? ` for ${model}` : ""} within ${formatWatchdogTimeout(firstResponseTimeoutMs)}; the provider request may be stalled. Retry the subagent.`,
418
+ });
419
+ // The stalled session cannot be trusted to abort cooperatively; dispose
420
+ // it like the abort-deadline path so it cannot revive into a zombie run.
421
+ const fiber = runDetached(
422
+ closeEntryScope(entry).pipe(
423
+ Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
424
+ Effect.ignore,
425
+ ),
426
+ );
427
+ cleanups.add(fiber);
428
+ fiber.addObserver(() => cleanups.delete(fiber));
429
+ };
430
+
431
+ /** Arm the first-response watchdog for the entry's current run. */
432
+ const armWatchdog = (entry: Entry) => {
433
+ clearWatchdog(entry);
434
+ entry.watchdogTimer = setTimeout(
435
+ () => watchdogExpired(entry),
436
+ firstResponseTimeoutMs,
437
+ );
438
+ };
439
+
440
+ const foldEvent = (entry: Entry, event: SubagentEvent) => {
441
+ const s = entry.snapshot;
442
+ switch (event._tag) {
443
+ case "RunStarted":
444
+ entry.restarting = false;
445
+ s.status = "running";
446
+ s.outcome = undefined;
447
+ s.settledAt = undefined;
448
+ s.errorText = undefined;
449
+ armWatchdog(entry);
450
+ break;
451
+ case "RunSettled":
452
+ settle(entry, event.outcome);
453
+ return; // settle() already notified
454
+ case "UserMessage":
455
+ appendTranscript(s, {
456
+ kind: "user",
457
+ text: boundedTranscriptText(event.text),
458
+ });
459
+ break;
460
+ case "AssistantDelta": {
461
+ clearWatchdog(entry);
462
+ const live = s.liveAssistant ?? { text: "", thinking: "" };
463
+ s.liveAssistant =
464
+ event.kind === "text"
394
465
  ? {
395
- ...part,
396
- argsPreview: part.argsPreview
397
- ? boundedTranscriptText(part.argsPreview)
398
- : undefined,
466
+ ...live,
467
+ text: (live.text + event.delta).slice(
468
+ -LIVE_ASSISTANT_MAX_LENGTH,
469
+ ),
399
470
  }
400
- : { ...part, text: boundedTranscriptText(part.text) },
401
- ),
402
- });
403
- s.liveAssistant = undefined;
404
- s.turns++;
405
- break;
406
- case "ToolStart":
407
- entry.liveToolMap.set(event.toolId, {
408
- toolId: event.toolId,
409
- name: event.name,
410
- argsPreview: event.argsPreview
411
- ? boundedTranscriptText(event.argsPreview)
412
- : undefined,
413
- });
414
- s.liveTools = [...entry.liveToolMap.values()];
415
- break;
416
- case "ToolUpdate": {
417
- const current = entry.liveToolMap.get(event.toolId);
418
- if (current) {
471
+ : {
472
+ ...live,
473
+ thinking: (live.thinking + event.delta).slice(
474
+ -LIVE_ASSISTANT_MAX_LENGTH,
475
+ ),
476
+ };
477
+ break;
478
+ }
479
+ case "AssistantMessage":
480
+ clearWatchdog(entry);
481
+ appendTranscript(s, {
482
+ kind: "assistant",
483
+ parts: event.parts.map((part) =>
484
+ part.type === "toolCall"
485
+ ? {
486
+ ...part,
487
+ argsPreview: part.argsPreview
488
+ ? boundedTranscriptText(part.argsPreview)
489
+ : undefined,
490
+ }
491
+ : { ...part, text: boundedTranscriptText(part.text) },
492
+ ),
493
+ });
494
+ s.liveAssistant = undefined;
495
+ s.turns++;
496
+ break;
497
+ case "ToolStart":
419
498
  entry.liveToolMap.set(event.toolId, {
420
- ...current,
421
- outputPreview: event.outputPreview
422
- ? boundedTranscriptText(event.outputPreview)
423
- : current.outputPreview,
499
+ toolId: event.toolId,
500
+ name: event.name,
501
+ argsPreview: event.argsPreview
502
+ ? boundedTranscriptText(event.argsPreview)
503
+ : undefined,
424
504
  });
425
505
  s.liveTools = [...entry.liveToolMap.values()];
506
+ break;
507
+ case "ToolUpdate": {
508
+ const current = entry.liveToolMap.get(event.toolId);
509
+ if (current) {
510
+ entry.liveToolMap.set(event.toolId, {
511
+ ...current,
512
+ outputPreview: event.outputPreview
513
+ ? boundedTranscriptText(event.outputPreview)
514
+ : current.outputPreview,
515
+ });
516
+ s.liveTools = [...entry.liveToolMap.values()];
517
+ }
518
+ break;
426
519
  }
427
- break;
520
+ case "ToolEnd":
521
+ entry.liveToolMap.delete(event.toolId);
522
+ s.liveTools = [...entry.liveToolMap.values()];
523
+ appendTranscript(s, {
524
+ kind: "toolResult",
525
+ toolId: event.toolId,
526
+ name: event.name,
527
+ isError: event.isError,
528
+ outputPreview: event.outputPreview
529
+ ? boundedTranscriptText(event.outputPreview)
530
+ : undefined,
531
+ });
532
+ break;
533
+ case "QueueChanged":
534
+ s.queued = event.queued;
535
+ break;
536
+ case "UsageChanged":
537
+ s.usage = {
538
+ tokens: event.tokens ?? s.usage.tokens,
539
+ contextWindow: event.contextWindow ?? s.usage.contextWindow,
540
+ };
541
+ break;
542
+ case "MetaChanged":
543
+ s.meta = { ...s.meta, ...event.meta };
544
+ break;
545
+ case "BackendError":
546
+ s.errorText = bounded(event.message);
547
+ break;
428
548
  }
429
- case "ToolEnd":
430
- entry.liveToolMap.delete(event.toolId);
431
- s.liveTools = [...entry.liveToolMap.values()];
432
- appendTranscript(s, {
433
- kind: "toolResult",
434
- toolId: event.toolId,
435
- name: event.name,
436
- isError: event.isError,
437
- outputPreview: event.outputPreview
438
- ? boundedTranscriptText(event.outputPreview)
439
- : undefined,
440
- });
441
- break;
442
- case "QueueChanged":
443
- s.queued = event.queued;
444
- break;
445
- case "UsageChanged":
446
- s.usage = {
447
- tokens: event.tokens ?? s.usage.tokens,
448
- contextWindow: event.contextWindow ?? s.usage.contextWindow,
449
- };
450
- break;
451
- case "MetaChanged":
452
- s.meta = { ...s.meta, ...event.meta };
453
- break;
454
- case "BackendError":
455
- s.errorText = bounded(event.message);
456
- break;
457
- }
458
- notify(s.id);
459
- };
460
-
461
- const spawn = (backendName: BackendName, task: SpawnTask) =>
462
- Effect.gen(function* () {
463
- const origin: SubagentOrigin = task.origin ?? "model";
464
- // Reserve synchronously (before the first yield inside doSpawn) so
465
- // parallel tool calls cannot race past the pool cap.
466
- yield* Effect.suspend(
467
- (): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
468
- if (disposed) {
469
- return new SpawnError({
470
- message: "Subagent manager is shutting down.",
549
+ notify(s.id);
550
+ };
551
+
552
+ const spawn = (backendName: BackendName, task: SpawnTask) =>
553
+ Effect.gen(function* () {
554
+ const origin: SubagentOrigin = task.origin ?? "model";
555
+ // Reserve synchronously (before the first yield inside doSpawn) so
556
+ // parallel tool calls cannot race past the pool cap.
557
+ yield* Effect.suspend(
558
+ (): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
559
+ if (disposed) {
560
+ return new SpawnError({
561
+ message: "Subagent manager is shutting down.",
562
+ });
563
+ }
564
+ if (atPoolCapacity(origin)) {
565
+ return new ConcurrencyLimitError({
566
+ message: `Max ${poolLimit(origin)} ${
567
+ origin === "btw" ? "by-the-way" : "subagent"
568
+ } sessions can run concurrently. Wait for one to finish before spawning another.`,
569
+ });
570
+ }
571
+ if (origin === "btw") reservedBtw++;
572
+ else reservedModel++;
573
+ return Effect.void;
574
+ },
575
+ );
576
+
577
+ const doSpawn = Effect.gen(function* () {
578
+ const backend: SubagentBackend | undefined =
579
+ registry.get(backendName);
580
+ if (!backend) {
581
+ return yield* new BackendUnavailableError({
582
+ message: `Unknown backend "${backendName}".`,
471
583
  });
472
584
  }
473
- if (atPoolCapacity(origin)) {
474
- return new ConcurrencyLimitError({
475
- message: `Max ${poolLimit(origin)} ${
476
- origin === "btw" ? "by-the-way" : "subagent"
477
- } sessions can run concurrently. Wait for one to finish before spawning another.`,
585
+ const scope = yield* Scope.make();
586
+ const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
587
+ Effect.onError(() => Scope.close(scope, Exit.void)),
588
+ );
589
+ if (disposed) {
590
+ yield* Scope.close(scope, Exit.void);
591
+ return yield* new SpawnError({
592
+ message: "Subagent manager shut down while spawning.",
478
593
  });
479
594
  }
480
- if (origin === "btw") reservedBtw++;
481
- else reservedModel++;
482
- return Effect.void;
483
- },
484
- );
485
595
 
486
- const doSpawn = Effect.gen(function* () {
487
- const backend: SubagentBackend | undefined = registry.get(backendName);
488
- if (!backend) {
489
- return yield* new BackendUnavailableError({
490
- message: `Unknown backend "${backendName}".`,
491
- });
492
- }
493
- const scope = yield* Scope.make();
494
- const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
495
- Effect.onError(() => Scope.close(scope, Exit.void)),
496
- );
497
- if (disposed) {
498
- yield* Scope.close(scope, Exit.void);
499
- return yield* new SpawnError({
500
- message: "Subagent manager shut down while spawning.",
501
- });
502
- }
596
+ const id =
597
+ origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
598
+ const meta = yield* session.meta;
599
+ const entry: Entry = {
600
+ snapshot: {
601
+ id,
602
+ origin,
603
+ backend: backendName,
604
+ title: task.title,
605
+ prompt: task.prompt,
606
+ cwd: task.cwd,
607
+ status: "running",
608
+ ...(task.worktree
609
+ ? { worktreeBranch: task.worktree.branch }
610
+ : {}),
611
+ createdAt: Date.now(),
612
+ meta,
613
+ usage: { contextWindow: meta.contextWindow },
614
+ transcript: [],
615
+ transcriptVersion: 0,
616
+ liveTools: [],
617
+ queued: [],
618
+ finalText: "",
619
+ turns: 0,
620
+ },
621
+ session,
622
+ scope,
623
+ liveToolMap: new Map(),
624
+ };
625
+ entries.set(id, entry);
626
+ // The run is live from the caller's perspective before RunStarted
627
+ // reaches the pump; guard that window too.
628
+ armWatchdog(entry);
629
+
630
+ // Pump: fold the event stream into the snapshot. Tied to the entry
631
+ // scope, so closing the scope stops it. If the stream ends while the
632
+ // subagent still looks running, the backend died out from under us.
633
+ const pump = Stream.runForEach(session.events, (event) =>
634
+ Effect.sync(() => foldEvent(entry, event)),
635
+ ).pipe(
636
+ Effect.ensuring(
637
+ Effect.sync(() => {
638
+ if (entry.snapshot.status === "running") {
639
+ settle(entry, {
640
+ _tag: "Failed",
641
+ errorText: "Backend event stream ended unexpectedly",
642
+ });
643
+ }
644
+ }),
645
+ ),
646
+ );
647
+ entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
503
648
 
504
- const id =
505
- origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
506
- const meta = yield* session.meta;
507
- const entry: Entry = {
508
- snapshot: {
509
- id,
510
- origin,
511
- backend: backendName,
512
- title: task.title,
513
- prompt: task.prompt,
514
- cwd: task.cwd,
515
- status: "running",
516
- createdAt: Date.now(),
517
- meta,
518
- usage: { contextWindow: meta.contextWindow },
519
- transcript: [],
520
- liveTools: [],
521
- queued: [],
522
- finalText: "",
523
- turns: 0,
524
- },
525
- session,
526
- scope,
527
- liveToolMap: new Map(),
528
- };
529
- entries.set(id, entry);
530
-
531
- // Pump: fold the event stream into the snapshot. Tied to the entry
532
- // scope, so closing the scope stops it. If the stream ends while the
533
- // subagent still looks running, the backend died out from under us.
534
- const pump = Stream.runForEach(session.events, (event) =>
535
- Effect.sync(() => foldEvent(entry, event)),
536
- ).pipe(
649
+ notify(id);
650
+ return entry.snapshot as SubagentSnapshot;
651
+ });
652
+
653
+ return yield* doSpawn.pipe(
537
654
  Effect.ensuring(
538
655
  Effect.sync(() => {
539
- if (entry.snapshot.status === "running") {
540
- settle(entry, {
541
- _tag: "Failed",
542
- errorText: "Backend event stream ended unexpectedly",
543
- });
544
- }
656
+ if (origin === "btw") reservedBtw--;
657
+ else reservedModel--;
658
+ notify();
545
659
  }),
546
660
  ),
547
661
  );
548
- entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
549
-
550
- notify(id);
551
- return entry.snapshot as SubagentSnapshot;
552
662
  });
553
663
 
554
- return yield* doSpawn.pipe(
555
- Effect.ensuring(
556
- Effect.sync(() => {
557
- if (origin === "btw") reservedBtw--;
558
- else reservedModel--;
559
- notify();
560
- }),
561
- ),
562
- );
563
- });
664
+ const waitFor = (
665
+ ids: ReadonlyArray<string>,
666
+ onPending?: (pending: string[]) => void,
667
+ ) =>
668
+ Effect.suspend(() => {
669
+ const unique = [...new Set(ids)];
670
+ addInterest(unique);
671
+ const loop = Effect.gen(function* () {
672
+ while (true) {
673
+ const pending = unique.filter((id) => isBusy(entries.get(id)));
674
+ if (pending.length === 0) return;
675
+ onPending?.(pending);
676
+ yield* nextChange;
677
+ }
678
+ });
679
+ return loop.pipe(
680
+ Effect.ensuring(
681
+ Effect.sync(() => {
682
+ releaseInterest(unique);
683
+ pruneSettled();
684
+ }),
685
+ ),
686
+ );
687
+ });
564
688
 
565
- const waitFor = (
566
- ids: ReadonlyArray<string>,
567
- onPending?: (pending: string[]) => void,
568
- ) =>
569
- Effect.suspend(() => {
570
- const unique = [...new Set(ids)];
571
- addInterest(unique);
572
- const loop = Effect.gen(function* () {
573
- while (true) {
574
- const pending = unique.filter((id) => isBusy(entries.get(id)));
575
- if (pending.length === 0) return;
576
- onPending?.(pending);
577
- yield* nextChange;
689
+ /** Interrupt one busy entry, including the pre-RunStarted restart window. */
690
+ const abortEntry = (entry: Entry) =>
691
+ Effect.gen(function* () {
692
+ if (!isBusy(entry)) return;
693
+ const graceful = yield* entry.session.interrupt.pipe(
694
+ Effect.timeout(STOP_TIMEOUT_MS),
695
+ Effect.result,
696
+ );
697
+ if (Result.isFailure(graceful)) {
698
+ // Settle before closing the scope so the pump's stream-ended
699
+ // fallback ("Backend event stream ended unexpectedly") cannot win
700
+ // the race and report the wrong terminal reason.
701
+ yield* Effect.sync(() => {
702
+ settle(entry, { _tag: "Interrupted" });
703
+ entry.snapshot.errorText =
704
+ "Abort deadline exceeded; session was force-disposed";
705
+ notify(entry.snapshot.id);
706
+ });
707
+ // Bound the close like disposeAll does: a stuck backend finalizer
708
+ // must not hang cancel after the run is already settled.
709
+ yield* closeEntryScope(entry).pipe(
710
+ Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
711
+ Effect.ignore,
712
+ );
578
713
  }
579
714
  });
580
- return loop.pipe(
581
- Effect.ensuring(
582
- Effect.sync(() => {
583
- releaseInterest(unique);
584
- pruneSettled();
585
- }),
586
- ),
587
- );
588
- });
589
715
 
590
- /** Interrupt one busy entry, including the pre-RunStarted restart window. */
591
- const abortEntry = (entry: Entry) =>
592
- Effect.gen(function* () {
593
- if (!isBusy(entry)) return;
594
- const graceful = yield* entry.session.interrupt.pipe(
595
- Effect.timeout(STOP_TIMEOUT_MS),
596
- Effect.result,
597
- );
598
- if (Result.isFailure(graceful)) {
599
- // Settle before closing the scope so the pump's stream-ended
600
- // fallback ("Backend event stream ended unexpectedly") cannot win
601
- // the race and report the wrong terminal reason.
602
- yield* Effect.sync(() => {
603
- settle(entry, { _tag: "Interrupted" });
604
- entry.snapshot.errorText =
605
- "Abort deadline exceeded; session was force-disposed";
606
- notify(entry.snapshot.id);
716
+ const cancel = (ids: ReadonlyArray<string>) =>
717
+ Effect.suspend(() => {
718
+ const unique = [...new Set(ids)];
719
+ const running = unique
720
+ .map((id) => entries.get(id))
721
+ .filter((entry): entry is Entry => isBusy(entry));
722
+ const runningIds = running.map((entry) => entry.snapshot.id);
723
+ // Mark consumed before interrupting so cancellation does not also
724
+ // enqueue duplicate automatic result messages into the parent.
725
+ addInterest(runningIds);
726
+ const work = Effect.gen(function* () {
727
+ yield* Effect.forEach(running, abortEntry, {
728
+ concurrency: "unbounded",
729
+ });
730
+ while (running.some(isBusy)) yield* nextChange;
607
731
  });
608
- // Bound the close like disposeAll does: a stuck backend finalizer
609
- // must not hang cancel after the run is already settled.
610
- yield* closeEntryScope(entry).pipe(
611
- Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
612
- Effect.ignore,
732
+ return work.pipe(
733
+ Effect.ensuring(
734
+ Effect.sync(() => {
735
+ releaseInterest(runningIds);
736
+ pruneSettled();
737
+ }),
738
+ ),
739
+ Effect.map(
740
+ (): ReadonlyArray<CancelResult> =>
741
+ unique.map((id) => {
742
+ const snapshot = entries.get(id)?.snapshot;
743
+ return {
744
+ id,
745
+ title: snapshot?.title ?? "?",
746
+ status: snapshot?.status ?? "error",
747
+ cancelled: runningIds.includes(id),
748
+ };
749
+ }),
750
+ ),
613
751
  );
614
- }
615
- });
616
-
617
- const cancel = (ids: ReadonlyArray<string>) =>
618
- Effect.suspend(() => {
619
- const unique = [...new Set(ids)];
620
- const running = unique
621
- .map((id) => entries.get(id))
622
- .filter((entry): entry is Entry => isBusy(entry));
623
- const runningIds = running.map((entry) => entry.snapshot.id);
624
- // Mark consumed before interrupting so cancellation does not also
625
- // enqueue duplicate automatic result messages into the parent.
626
- addInterest(runningIds);
627
- const work = Effect.gen(function* () {
628
- yield* Effect.forEach(running, abortEntry, {
629
- concurrency: "unbounded",
630
- });
631
- while (running.some(isBusy)) yield* nextChange;
632
752
  });
633
- return work.pipe(
634
- Effect.ensuring(
635
- Effect.sync(() => {
636
- releaseInterest(runningIds);
637
- pruneSettled();
638
- }),
639
- ),
640
- Effect.map(
641
- (): ReadonlyArray<CancelResult> =>
642
- unique.map((id) => {
643
- const snapshot = entries.get(id)?.snapshot;
644
- return {
645
- id,
646
- title: snapshot?.title ?? "?",
647
- status: snapshot?.status ?? "error",
648
- cancelled: runningIds.includes(id),
649
- };
650
- }),
651
- ),
652
- );
653
- });
654
753
 
655
- const send = (id: string, text: string) =>
656
- Effect.suspend((): Effect.Effect<void, SendError> => {
657
- const entry = entries.get(id);
658
- if (!entry || disposed) {
659
- return new SendError({
660
- message: `Subagent "${id}" is no longer tracked.`,
661
- });
662
- }
663
- // Restarting a settled subagent occupies a running slot again, so it
664
- // must respect the same cap as spawn. Steering an already-running one
665
- // does not consume additional capacity.
666
- if (!isBusy(entry)) {
667
- const origin = entry.snapshot.origin;
668
- if (atPoolCapacity(origin)) {
754
+ const send = (id: string, text: string) =>
755
+ Effect.suspend((): Effect.Effect<void, SendError> => {
756
+ const entry = entries.get(id);
757
+ if (!entry || disposed) {
669
758
  return new SendError({
670
- message: `Max ${poolLimit(origin)} ${
671
- origin === "btw" ? "by-the-way" : "subagent"
672
- } sessions can run concurrently; restarting "${id}" would exceed that.`,
759
+ message: `Subagent "${id}" is no longer tracked.`,
673
760
  });
674
761
  }
675
- // Occupy the slot synchronously: the RunStarted that flips status
676
- // arrives via the async pump, and two concurrent restarts must not
677
- // both pass the check in that window. Cleared by RunStarted/settle,
678
- // or here when the backend rejects the send.
679
- entry.restarting = true;
680
- return entry.session.send(text).pipe(
681
- Effect.onError(() =>
682
- Effect.sync(() => {
683
- entry.restarting = false;
684
- notify(entry.snapshot.id);
685
- }),
762
+ // Restarting a settled subagent occupies a running slot again, so it
763
+ // must respect the same cap as spawn. Steering an already-running one
764
+ // does not consume additional capacity.
765
+ if (!isBusy(entry)) {
766
+ const origin = entry.snapshot.origin;
767
+ if (atPoolCapacity(origin)) {
768
+ return new SendError({
769
+ message: `Max ${poolLimit(origin)} ${
770
+ origin === "btw" ? "by-the-way" : "subagent"
771
+ } sessions can run concurrently; restarting "${id}" would exceed that.`,
772
+ });
773
+ }
774
+ // Occupy the slot synchronously: the RunStarted that flips status
775
+ // arrives via the async pump, and two concurrent restarts must not
776
+ // both pass the check in that window. Cleared by RunStarted/settle,
777
+ // or here when the backend rejects the send.
778
+ entry.restarting = true;
779
+ // A backend that accepts the send but never starts the run would
780
+ // hold the slot forever; guard the restart window the same way the
781
+ // spawn path guards its pre-RunStarted window.
782
+ armWatchdog(entry);
783
+ return entry.session.send(text).pipe(
784
+ Effect.onError(() =>
785
+ Effect.sync(() => {
786
+ entry.restarting = false;
787
+ notify(entry.snapshot.id);
788
+ }),
789
+ ),
790
+ );
791
+ }
792
+ return entry.session.send(text);
793
+ });
794
+
795
+ const disposeAll = Effect.gen(function* () {
796
+ disposed = true;
797
+ const all = [...entries.values()];
798
+ for (const entry of all) clearWatchdog(entry);
799
+ entries.clear();
800
+ yield* Effect.forEach(
801
+ all,
802
+ (entry) =>
803
+ closeEntryScope(entry).pipe(
804
+ Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
805
+ Effect.ignore,
686
806
  ),
687
- );
688
- }
689
- return entry.session.send(text);
807
+ { concurrency: "unbounded" },
808
+ );
809
+ // Pruning cleanups are detached; bound them like everything else so a
810
+ // stuck backend finalizer cannot block runtime shutdown indefinitely.
811
+ yield* Effect.forEach(
812
+ [...cleanups],
813
+ (fiber) =>
814
+ Fiber.await(fiber).pipe(
815
+ Effect.timeout(STOP_TIMEOUT_MS),
816
+ Effect.ignore,
817
+ ),
818
+ { concurrency: "unbounded" },
819
+ ).pipe(Effect.ignore);
820
+ yield* Effect.sync(() => notify());
690
821
  });
691
822
 
692
- const disposeAll = Effect.gen(function* () {
693
- disposed = true;
694
- const all = [...entries.values()];
695
- entries.clear();
696
- yield* Effect.forEach(
697
- all,
698
- (entry) =>
699
- closeEntryScope(entry).pipe(
700
- Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
701
- Effect.ignore,
702
- ),
703
- { concurrency: "unbounded" },
704
- );
705
- // Pruning cleanups are detached; bound them like everything else so a
706
- // stuck backend finalizer cannot block runtime shutdown indefinitely.
707
- yield* Effect.forEach(
708
- [...cleanups],
709
- (fiber) =>
710
- Fiber.await(fiber).pipe(Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore),
711
- { concurrency: "unbounded" },
712
- ).pipe(Effect.ignore);
713
- yield* Effect.sync(() => notify());
823
+ const view: SubagentReadModel = {
824
+ list: () => [...entries.values()].map((entry) => entry.snapshot),
825
+ get: (id) => entries.get(id)?.snapshot,
826
+ getToolRenderer: (id) => entries.get(id)?.session.toolRenderer,
827
+ size: () => entries.size,
828
+ subscribe: (listener) => {
829
+ listeners.add(listener);
830
+ return () => listeners.delete(listener);
831
+ },
832
+ subscribeTo: (id, listener) => {
833
+ let set = idListeners.get(id);
834
+ if (!set) {
835
+ set = new Set();
836
+ idListeners.set(id, set);
837
+ }
838
+ set.add(listener);
839
+ return () => {
840
+ set.delete(listener);
841
+ if (set.size === 0) idListeners.delete(id);
842
+ };
843
+ },
844
+ requestSend: (id, text) => {
845
+ runDetached(send(id, text).pipe(Effect.ignore));
846
+ },
847
+ requestAbort: (id) => {
848
+ const entry = entries.get(id);
849
+ if (!entry) return;
850
+ // UI-initiated aborts are not "consumed": the failed result still
851
+ // flows back to the parent as a follow-up message, matching v1.
852
+ runDetached(abortEntry(entry).pipe(Effect.ignore));
853
+ },
854
+ setOnSettled: (hook) => {
855
+ onSettled = hook;
856
+ },
857
+ };
858
+
859
+ // Safety net: disposing the ManagedRuntime tears everything down even if
860
+ // the extension forgot to call disposeAll explicitly.
861
+ yield* Effect.addFinalizer(() => disposeAll);
862
+
863
+ return SubagentManager.of({
864
+ spawn,
865
+ waitFor,
866
+ cancel,
867
+ send,
868
+ get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
869
+ list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
870
+ disposeAll,
871
+ view,
872
+ });
714
873
  });
715
874
 
716
- const view: SubagentReadModel = {
717
- list: () => [...entries.values()].map((entry) => entry.snapshot),
718
- get: (id) => entries.get(id)?.snapshot,
719
- size: () => entries.size,
720
- subscribe: (listener) => {
721
- listeners.add(listener);
722
- return () => listeners.delete(listener);
723
- },
724
- subscribeTo: (id, listener) => {
725
- let set = idListeners.get(id);
726
- if (!set) {
727
- set = new Set();
728
- idListeners.set(id, set);
729
- }
730
- set.add(listener);
731
- return () => {
732
- set.delete(listener);
733
- if (set.size === 0) idListeners.delete(id);
734
- };
735
- },
736
- requestSend: (id, text) => {
737
- runDetached(send(id, text).pipe(Effect.ignore));
738
- },
739
- requestAbort: (id) => {
740
- const entry = entries.get(id);
741
- if (!entry) return;
742
- // UI-initiated aborts are not "consumed": the failed result still
743
- // flows back to the parent as a follow-up message, matching v1.
744
- runDetached(abortEntry(entry).pipe(Effect.ignore));
745
- },
746
- setOnSettled: (hook) => {
747
- onSettled = hook;
748
- },
749
- };
750
-
751
- // Safety net: disposing the ManagedRuntime tears everything down even if
752
- // the extension forgot to call disposeAll explicitly.
753
- yield* Effect.addFinalizer(() => disposeAll);
754
-
755
- return SubagentManager.of({
756
- spawn,
757
- waitFor,
758
- cancel,
759
- send,
760
- get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
761
- list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
762
- disposeAll,
763
- view,
764
- });
765
- });
875
+ export interface SubagentManagerConfig {
876
+ /** Test-only override for the first-response watchdog timeout. */
877
+ firstResponseTimeoutMs?: number;
878
+ /** Session-branch high-water marks restored by the extension host. */
879
+ initialModelCounter?: number;
880
+ initialBtwCounter?: number;
881
+ }
766
882
 
767
- export const SubagentManagerLive: Layer.Layer<
768
- SubagentManager,
769
- never,
770
- BackendRegistry
771
- > = Layer.effect(SubagentManager, makeManager);
883
+ export const makeSubagentManagerLayer = (config: SubagentManagerConfig = {}) =>
884
+ Layer.effect(SubagentManager, makeManager(config));
885
+
886
+ export const SubagentManagerLive = makeSubagentManagerLayer();