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