@aragon-agent/cli 0.6.17 → 0.6.18

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 (43) hide show
  1. package/CHANGELOG.md +107 -51
  2. package/README.md +48 -2
  3. package/dist/agent/controller.js +13 -0
  4. package/dist/agent/headless.js +7 -0
  5. package/dist/commands/builtins.js +18 -3
  6. package/dist/commands/index-build.d.ts +39 -0
  7. package/dist/commands/index-build.js +61 -0
  8. package/dist/config/cli-commands.d.ts +1 -1
  9. package/dist/config/cli-commands.js +3 -0
  10. package/dist/config/schema.d.ts +24 -4
  11. package/dist/config/schema.js +4 -0
  12. package/dist/exec/events.d.ts +4 -1
  13. package/dist/exec/runner.js +13 -0
  14. package/dist/input/csiu-keys.js +13 -2
  15. package/dist/input/limits.d.ts +15 -0
  16. package/dist/input/limits.js +15 -0
  17. package/dist/input/win32-input-mode.js +7 -1
  18. package/dist/logging/install.d.ts +9 -0
  19. package/dist/logging/install.js +11 -0
  20. package/dist/team/human-queue.d.ts +11 -0
  21. package/dist/team/human-queue.js +18 -1
  22. package/dist/team/limits.d.ts +40 -0
  23. package/dist/team/limits.js +41 -0
  24. package/dist/team/overseer.d.ts +157 -0
  25. package/dist/team/overseer.js +348 -0
  26. package/dist/team/report.js +26 -1
  27. package/dist/team/runtime.d.ts +71 -6
  28. package/dist/team/runtime.js +380 -23
  29. package/dist/team/subagent.d.ts +30 -1
  30. package/dist/team/subagent.js +8 -1
  31. package/dist/team/task-tool.js +4 -1
  32. package/dist/team/types.d.ts +63 -0
  33. package/dist/ui/App.d.ts +1 -1
  34. package/dist/ui/App.js +61 -0
  35. package/dist/ui/Header.js +13 -2
  36. package/dist/ui/PromptInput.js +11 -1
  37. package/dist/ui/overlays/HelpOverlay.js +1 -0
  38. package/package.json +2 -2
  39. package/skills/project-indexer/SKILL.md +163 -0
  40. package/skills/project-indexer/templates/cleancode-overrides.md +46 -0
  41. package/skills/project-indexer/templates/cleancode-template.md +94 -0
  42. package/skills/project-indexer/templates/config-template.md +69 -0
  43. package/skills/project-indexer/templates/index-template.md +129 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
3
- * disposal (team-subagents §3.2 / §3.5 / §3.8).
3
+ * disposal (team-subagents §3.2 / §3.5 / §3.8; team-overseer §3.4).
4
4
  *
5
5
  * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
6
  *
@@ -16,11 +16,104 @@
16
16
  * worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
17
17
  * queue class, no event-loop trickery — every path is observable from the event
18
18
  * stream.
19
+ *
20
+ * THE DISPATCH SUPERVISOR (team-overseer) — a time ceiling is an inspection
21
+ * trigger, not a death sentence. When a supervisor is live for the dispatch,
22
+ * two SOFT timers per child (wall clock and event silence) fire an inspection;
23
+ * the supervisor decides wait / nudge / replace / abandon; THIS module applies.
24
+ * Lifecycle changes are funnelled exclusively through `runOne`, the only place
25
+ * a child's `prompt()` is awaited (I-OV1): the supervisor may steer, flag and
26
+ * abort, but a replacement is built by the worker loop or it is built for
27
+ * nobody. Without a supervisor, every path below is byte for byte the
28
+ * pre-feature behaviour.
19
29
  */
20
30
  import { TeamBus } from './bus.js';
21
31
  import { TEAM_LIMITS } from './limits.js';
32
+ import { TeamOverseer, buildReplacementPrompt } from './overseer.js';
22
33
  import { shouldRetryColdStart } from './retry.js';
23
34
  import { createSubagent, defaultSubagentFactory, } from './subagent.js';
35
+ /**
36
+ * The two soft timers per child (team-overseer §3.2). Timers only — no state,
37
+ * no decisions; the runtime applies what the supervisor decides.
38
+ *
39
+ * Every timer is `unref`'d (the `backoff` argument): a pending supervision
40
+ * look must not hold the process open on exit.
41
+ */
42
+ class ChildSupervisor {
43
+ deps;
44
+ clocks = new Map();
45
+ silences = new Map();
46
+ /** The interval used when a decision did not name one. */
47
+ defaultCheckMs;
48
+ constructor(deps) {
49
+ this.deps = deps;
50
+ this.defaultCheckMs = Math.max(TEAM_LIMITS.overseerNextCheckMinMs, deps.firstCheckMs);
51
+ }
52
+ /** Arm (or re-arm) both windows for a child starting or restarting an attempt. */
53
+ start(label) {
54
+ this.armClock(label, this.deps.firstCheckMs);
55
+ this.armSilence(label);
56
+ }
57
+ /** Reset the silence window. A no-op for a child nobody is supervising. */
58
+ kick(label) {
59
+ if (this.silences.has(label))
60
+ this.armSilence(label);
61
+ }
62
+ rearmSilence(label) {
63
+ this.armSilence(label);
64
+ }
65
+ rearmClock(label, ms) {
66
+ this.armClock(label, ms);
67
+ }
68
+ clear(label) {
69
+ const clock = this.clocks.get(label);
70
+ if (clock !== undefined)
71
+ clearTimeout(clock);
72
+ const silence = this.silences.get(label);
73
+ if (silence !== undefined)
74
+ clearTimeout(silence);
75
+ this.clocks.delete(label);
76
+ this.silences.delete(label);
77
+ }
78
+ clearAll() {
79
+ for (const label of [...this.clocks.keys()])
80
+ this.clear(label);
81
+ for (const label of [...this.silences.keys()])
82
+ this.clear(label);
83
+ }
84
+ armClock(label, ms) {
85
+ const previous = this.clocks.get(label);
86
+ if (previous !== undefined)
87
+ clearTimeout(previous);
88
+ const timer = setTimeout(() => this.fire(label, 'clock'), ms);
89
+ timer.unref?.();
90
+ this.clocks.set(label, timer);
91
+ }
92
+ armSilence(label) {
93
+ const previous = this.silences.get(label);
94
+ if (previous !== undefined)
95
+ clearTimeout(previous);
96
+ const timer = setTimeout(() => {
97
+ // I-OV3: a child legitimately blocked on `team_wait` or the confirm
98
+ // queue re-arms quietly instead of spending an inspection on a fact the
99
+ // digest would only re-derive.
100
+ if (this.deps.isLegitimatelyBlocked(label)) {
101
+ this.armSilence(label);
102
+ return;
103
+ }
104
+ this.fire(label, 'silence');
105
+ }, this.deps.idleMs);
106
+ timer.unref?.();
107
+ this.silences.set(label, timer);
108
+ }
109
+ fire(label, trigger) {
110
+ if (trigger === 'clock')
111
+ this.clocks.delete(label);
112
+ else
113
+ this.silences.delete(label);
114
+ this.deps.onFire(label, trigger);
115
+ }
116
+ }
24
117
  export class TeamRuntime {
25
118
  deps;
26
119
  listeners = new Set();
@@ -43,6 +136,16 @@ export class TeamRuntime {
43
136
  * quietly not for the others.
44
137
  */
45
138
  pendingBackoffs = new Set();
139
+ /**
140
+ * The dispatch supervisor, LIVE for the current dispatch only (team-overseer).
141
+ * Null when the provider is absent/disabled/off, and reset to null at every
142
+ * dispatch end so nothing supervision-shaped leaks across dispatches.
143
+ */
144
+ overseer = null;
145
+ /** The soft timers, same lifetime as `overseer`. */
146
+ supervision = null;
147
+ /** Applied interventions for the in-flight dispatch's outcome. */
148
+ interventions = [];
46
149
  constructor(deps) {
47
150
  this.deps = deps;
48
151
  }
@@ -73,6 +176,30 @@ export class TeamRuntime {
73
176
  const startedAt = Date.now();
74
177
  this.busy = true;
75
178
  this.aborted = false;
179
+ this.interventions = [];
180
+ // THE SUPERVISOR IS CREATED PER DISPATCH and read live: provider present,
181
+ // provider active, policy on. `TeamOverseer.isActive()` re-checks the
182
+ // provider per inspection, so a mid-dispatch `/fast off` stops the next
183
+ // look without unwinding anything already applied.
184
+ this.overseer =
185
+ this.deps.overseer !== undefined &&
186
+ this.deps.overseer.active() &&
187
+ team.overseer
188
+ ? new TeamOverseer(this.deps.overseer, { startedAt })
189
+ : null;
190
+ this.supervision =
191
+ this.overseer !== null
192
+ ? new ChildSupervisor({
193
+ idleMs: config.idleTimeoutMs,
194
+ firstCheckMs: team.subagentTimeoutMs > 0
195
+ ? team.subagentTimeoutMs
196
+ : TEAM_LIMITS.overseerDefaultCheckMs,
197
+ isLegitimatelyBlocked: (label) => this.isBlocked(label),
198
+ onFire: (label, trigger) => {
199
+ void this.inspectAndApply(dispatchId, label, trigger);
200
+ },
201
+ })
202
+ : null;
76
203
  // DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
77
204
  // this binding so it reads the LIVE array: F-4 replaces `handles[index]`
78
205
  // mid-dispatch, and a captured array or a captured handle would answer for
@@ -85,7 +212,7 @@ export class TeamRuntime {
85
212
  * Whether `label` still has a loop that could call `team_send` (F-3 / P0-2).
86
213
  *
87
214
  * NEGATIVE, over TERMINAL PHASES ONLY. "The child's phase is running" is the
88
- * natural way to write this and it is wrong on the SHIPPED DEFAULTS: at
215
+ * natural way to write this and is wrong on the SHIPPED DEFAULTS: at
89
216
  * `maxConcurrent: 3, maxSubagents: 5`, children 4 and 5 sit in `queued` for
90
217
  * the first part of every five-way dispatch, and a wait on one of them would
91
218
  * be refused with "nobody can answer: a4 already finished". `queued` is not
@@ -136,6 +263,10 @@ export class TeamRuntime {
136
263
  ...(this.deps.contextManagerFor
137
264
  ? { contextManagerFor: this.deps.contextManagerFor }
138
265
  : {}),
266
+ // Same conditional-spread rule: a dispatch without a supervisor builds
267
+ // the exact pre-feature `SubagentDeps`, and the child's idle watchdog
268
+ // keeps its un-lengthened ceiling (team-overseer §3.6).
269
+ ...(this.overseer !== null ? { overseerActive: true } : {}),
139
270
  };
140
271
  // Hoisted into a factory so a retry can build a fresh child with IDENTICAL
141
272
  // wiring (F-4). Replacing the child rather than re-prompting it is the only
@@ -145,6 +276,9 @@ export class TeamRuntime {
145
276
  const makeHandle = (spec) => createSubagent(spec, subDeps, {
146
277
  onUpdate: (run) => this.publish(dispatchId, run),
147
278
  onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage }),
279
+ // ONE KICK PER REAL AGENT EVENT, unthrottled (I-OV3): the silence
280
+ // window resets on evidence, not on a coalesced UI frame.
281
+ onActivity: () => this.supervision?.kick(spec.label),
148
282
  });
149
283
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
150
284
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -196,6 +330,9 @@ export class TeamRuntime {
196
330
  signal?.removeEventListener('abort', onAbort);
197
331
  for (const handle of handles)
198
332
  handle.unsubscribe();
333
+ this.supervision?.clearAll();
334
+ this.supervision = null;
335
+ this.overseer = null;
199
336
  // ONE TOTAL, AT THE LEAD'S TABLE. Every child runs the lead's own model
200
337
  // (main-agent parity), so there is exactly one tier to sum and one price to
201
338
  // sum it at; a split would be two answers to one question.
@@ -211,6 +348,7 @@ export class TeamRuntime {
211
348
  endedAt: Date.now(),
212
349
  aborted: this.aborted,
213
350
  leadMail: bus.leadMail(),
351
+ interventions: this.interventions,
214
352
  usage,
215
353
  };
216
354
  this.live = [];
@@ -241,11 +379,19 @@ export class TeamRuntime {
241
379
  }
242
380
  /**
243
381
  * Run one child to a terminal phase, replacing it once if its very first
244
- * request died on a transport error before it did anything at all (F-4).
382
+ * request died on a transport error before it did anything at all (F-4), and
383
+ * replacing it when the SUPERVISOR decides to (team-overseer §3.4).
384
+ *
385
+ * ONE LOOP, ALL LIFECYCLE: the cold-start replacement, the supervisor's
386
+ * replace and the post-mortem replace all swap `handles[index]` and continue
387
+ * THIS loop, because the await below is the only place a child is driven and
388
+ * a handle swapped anywhere else would never be (I-OV1).
245
389
  */
246
390
  async runOne(ctx, index) {
247
391
  const { dispatchId, handles, makeHandle, subagentTimeoutMs } = ctx;
248
- for (let attempt = 0;; attempt += 1) {
392
+ let attempt = 0;
393
+ for (;;) {
394
+ attempt += 1;
249
395
  const handle = handles[index];
250
396
  const { run, agent } = handle;
251
397
  // ABORTS GO THROUGH THE HANDLE (never `agent.abort()` directly): the
@@ -254,12 +400,15 @@ export class TeamRuntime {
254
400
  run.phase = 'starting';
255
401
  run.startedAt = Date.now();
256
402
  this.publish(dispatchId, run);
257
- // `subagentTimeoutMs: 0` MEANS NO CEILING (main-agent parity): no timer
258
- // is armed and `timedOut` can never become true. A wedged child is still
259
- // ended by its OWN idle watchdog, which is the exact ceiling the lead
260
- // runs under.
403
+ // WITH A SUPERVISOR there is no hard wall clock on the child: the
404
+ // supervisor looks and decides, and the child's own (lengthened) idle
405
+ // watchdog is the silence backstop. WITHOUT one, the legacy hard timer —
406
+ // armed exactly as before, `0` meaning no ceiling.
407
+ const supervised = this.overseer !== null;
408
+ if (supervised)
409
+ this.supervision?.start(run.label);
261
410
  let timedOut = false;
262
- const timer = subagentTimeoutMs > 0
411
+ const timer = !supervised && subagentTimeoutMs > 0
263
412
  ? setTimeout(() => {
264
413
  timedOut = true;
265
414
  handle.abort();
@@ -279,21 +428,46 @@ export class TeamRuntime {
279
428
  if (timer)
280
429
  clearTimeout(timer);
281
430
  }
282
- // THE TWO ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
431
+ const verdict = handle.overseerVerdict ?? null;
432
+ // THE SUPERVISOR'S REBUILD. Same loop, fresh body, amended brief: the
433
+ // verdict was set and the child aborted by `inspectAndApply`; here is
434
+ // where the replacement is actually built and driven. `attempt` resets so
435
+ // the fresh body gets its own cold-start budget.
436
+ if (verdict !== null && verdict.action === 'replace' && !this.aborted) {
437
+ this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict);
438
+ attempt = 0;
439
+ continue;
440
+ }
441
+ // THE ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
283
442
  // this system reaches the provider as an aborted fetch, and `core` marks
284
443
  // an `AbortError` retryable - so Esc, `ctx.signal`, the dispatch timeout
285
- // (all three via `this.aborted`) and the per-child timeout (`timedOut`)
286
- // are excluded HERE, before `shouldRetryColdStart` is ever consulted.
287
- const retryable = !this.aborted && !timedOut && shouldRetryColdStart(run, attempt + 1);
444
+ // (all three via `this.aborted`), the per-child timeout (`timedOut`) and
445
+ // now the SUPERVISOR's verdict are excluded HERE, before
446
+ // `shouldRetryColdStart` is ever consulted.
447
+ const retryable = !this.aborted &&
448
+ !timedOut &&
449
+ verdict === null &&
450
+ shouldRetryColdStart(run, attempt);
288
451
  if (!retryable) {
289
452
  run.endedAt = Date.now();
453
+ // The supervisor's abandon is an error like any other, with the
454
+ // supervisor's own reason so the report explains WHO stopped it and why.
455
+ if (verdict !== null && verdict.action === 'abandon') {
456
+ run.error = run.error ?? `overseer abandoned: ${verdict.reason}`;
457
+ }
290
458
  this.settlePhase(run, timedOut, subagentTimeoutMs);
291
459
  this.publish(dispatchId, run);
460
+ // ONE post-mortem look at a child that died silently (team-overseer
461
+ // §3.4): a watchdog death is the "timed out" case the requirement most
462
+ // wants diagnosed, and replace-after-death is exactly re-assignment.
463
+ await this.maybePostMortem(ctx, index, handle, verdict);
464
+ this.supervision?.clear(run.label);
292
465
  return;
293
466
  }
294
- const fresh = this.replaceForRetry(dispatchId, handles, index, makeHandle);
467
+ this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
295
468
  await this.backoff(TEAM_LIMITS.retryBackoffMs);
296
469
  if (this.aborted) {
470
+ const fresh = handles[index];
297
471
  fresh.run.phase = 'aborted';
298
472
  fresh.run.endedAt = Date.now();
299
473
  this.publish(dispatchId, fresh.run);
@@ -302,17 +476,64 @@ export class TeamRuntime {
302
476
  }
303
477
  }
304
478
  /**
305
- * Swap a cold-started child for a fresh one, carrying only what must survive.
479
+ * One post-mortem inspection, and possibly ONE replacement driven by a
480
+ * recursive `runOne` — the worker that called us has already moved on to the
481
+ * next index, so nobody else will drive the fresh child (I-OV1 holds here
482
+ * too, one frame deeper). Recursion depth is bounded by the replacement
483
+ * budget, which is 1.
484
+ */
485
+ async maybePostMortem(ctx, index, handle, verdict) {
486
+ const overseer = this.overseer;
487
+ if (overseer === null || this.aborted || verdict !== null)
488
+ return;
489
+ const { run, agent } = handle;
490
+ if (run.phase !== 'failed' || run.summary !== undefined)
491
+ return;
492
+ if (!overseer.replacementsLeft(run.label))
493
+ return;
494
+ const decision = await overseer.inspect({
495
+ run: { ...run },
496
+ messages: agent.state.messages,
497
+ trigger: 'postmortem',
498
+ childAlive: false,
499
+ });
500
+ if (decision === null || this.aborted)
501
+ return;
502
+ this.recordIntervention(ctx.dispatchId, run.label, 'postmortem', decision);
503
+ this.emit({
504
+ type: 'overseer',
505
+ dispatchId: ctx.dispatchId,
506
+ label: run.label,
507
+ decision,
508
+ trigger: 'postmortem',
509
+ });
510
+ if (decision.action === 'replace' && overseer.replacementsLeft(run.label)) {
511
+ this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
512
+ action: 'replace',
513
+ reason: decision.reason,
514
+ ...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
515
+ });
516
+ await this.runOne(ctx, index);
517
+ }
518
+ }
519
+ /**
520
+ * Swap one child for a fresh one, carrying only what must survive.
306
521
  *
307
522
  * `this.live` IS `handles`, so the single assignment below also makes
308
523
  * `abortAll()` abort the NEW child and makes the outcome carry the new run.
309
524
  * Anyone tempted to "clean up" that aliasing into a defensive copy must update
310
525
  * both places.
526
+ *
527
+ * `verdict === null` is the COLD-START retry (F-4): identical spec, `retries`
528
+ * carried. A verdict is the SUPERVISOR's replace: an amended brief
529
+ * (`buildReplacementPrompt`), `replacements` carried, and the supervisor's
530
+ * budget spent HERE, at the moment the rebuild is real.
311
531
  */
312
- replaceForRetry(dispatchId, handles, index, makeHandle) {
313
- const handle = handles[index];
532
+ replaceChild(dispatchId, handles, index, makeHandle, handle, verdict) {
314
533
  const carried = { ...handle.run.usage };
315
- const retries = (handle.run.retries ?? 0) + 1;
534
+ const retries = (handle.run.retries ?? 0) + (verdict === null ? 1 : 0);
535
+ const replacements = (handle.run.replacements ?? 0) + (verdict !== null ? 1 : 0);
536
+ const interventions = handle.run.interventions;
316
537
  handle.unsubscribe();
317
538
  try {
318
539
  handle.abort();
@@ -320,17 +541,149 @@ export class TeamRuntime {
320
541
  catch {
321
542
  // Already gone; this must not stop the retry.
322
543
  }
323
- const fresh = makeHandle(handle.spec);
324
- // Provably {0,0} under the predicate above (no turn completed, so no
325
- // `turn_end` and no usage event). Carried anyway so that a future relaxation
326
- // of the predicate cannot silently under-report spend - the most misleading
327
- // failure this feature can produce.
544
+ const spec = verdict !== null
545
+ ? {
546
+ ...handle.spec,
547
+ prompt: buildReplacementPrompt(handle.spec, {
548
+ action: 'replace',
549
+ reason: verdict.reason,
550
+ ...(verdict.guidance !== undefined ? { guidance: verdict.guidance } : {}),
551
+ }, handle.run),
552
+ }
553
+ : handle.spec;
554
+ const fresh = makeHandle(spec);
555
+ // Provably {0,0} under the cold-start predicate above (no turn completed,
556
+ // so no `turn_end` and no usage event). Carried anyway so that a future
557
+ // relaxation of the predicate cannot silently under-report spend - the most
558
+ // misleading failure this feature can produce.
328
559
  fresh.run.usage = carried;
329
560
  fresh.run.retries = retries;
561
+ if (replacements > 0)
562
+ fresh.run.replacements = replacements;
563
+ if (interventions !== undefined)
564
+ fresh.run.interventions = interventions;
565
+ if (verdict !== null)
566
+ this.overseer?.noteReplacement(handle.run.label);
330
567
  handles[index] = fresh;
331
568
  this.publish(dispatchId, fresh.run);
332
569
  return fresh;
333
570
  }
571
+ // -------------------------------------------------------------------------
572
+ // Supervision (team-overseer)
573
+ // -------------------------------------------------------------------------
574
+ /**
575
+ * Whether a child's silence is a legitimate wait rather than a stall:
576
+ * `team_wait` (the comm tool already pauses the child's own watchdog for
577
+ * exactly this wait) or a confirmation queued on the one human slot.
578
+ */
579
+ isBlocked(label) {
580
+ const handle = this.live.find((h) => h.run.label === label);
581
+ if (handle !== undefined && handle.run.phase === 'waiting')
582
+ return true;
583
+ return this.deps.humanQueue?.isWaiting(label) === true;
584
+ }
585
+ /**
586
+ * One trigger handled: look, then apply. Fired from a supervision timer and
587
+ * therefore async-and-forgotten; nothing here may throw.
588
+ *
589
+ * QUIET (`decision === null` — provider gone, budget spent) CLEARS the
590
+ * child's timers: supervision that has nothing left to say should stop
591
+ * waking up, and the child's own hard ceilings take over.
592
+ */
593
+ async inspectAndApply(dispatchId, label, trigger) {
594
+ const overseer = this.overseer;
595
+ const supervision = this.supervision;
596
+ if (overseer === null || supervision === null || this.aborted)
597
+ return;
598
+ const handle = this.live.find((h) => h.run.label === label);
599
+ if (handle === undefined)
600
+ return;
601
+ const phase = handle.run.phase;
602
+ if (phase === 'done' || phase === 'failed' || phase === 'aborted')
603
+ return;
604
+ try {
605
+ const decision = await overseer.inspect({
606
+ run: handle.run,
607
+ messages: handle.agent.state.messages,
608
+ trigger,
609
+ childAlive: true,
610
+ });
611
+ if (decision === null || this.aborted) {
612
+ supervision.clear(label);
613
+ return;
614
+ }
615
+ // The child may have settled while the call was in flight; an
616
+ // intervention is still recorded (it happened), but nothing is applied.
617
+ const settled = handle.run.phase === 'done' ||
618
+ handle.run.phase === 'failed' ||
619
+ handle.run.phase === 'aborted';
620
+ this.recordIntervention(dispatchId, label, trigger, decision);
621
+ this.emit({ type: 'overseer', dispatchId, label, decision, trigger });
622
+ if (settled) {
623
+ supervision.clear(label);
624
+ return;
625
+ }
626
+ this.applyDecision(supervision, overseer, handle, label, decision);
627
+ }
628
+ catch {
629
+ // Defence in depth around an async timer callback: an unexpected throw
630
+ // must not take the dispatch down. The child keeps its timers.
631
+ supervision.rearmSilence(label);
632
+ }
633
+ }
634
+ applyDecision(supervision, overseer, handle, label, decision) {
635
+ switch (decision.action) {
636
+ case 'wait':
637
+ supervision.rearmClock(label, decision.nextCheckMs ?? supervision.defaultCheckMs);
638
+ supervision.rearmSilence(label);
639
+ break;
640
+ case 'nudge':
641
+ // I-OV4: never steer into an abort already requested — the same
642
+ // guard-1 fact the child's fast reviewer reads. Queued steering is
643
+ // drained at the safe top-of-loop checkpoint by core.
644
+ if (!handle.isAborted() && decision.guidance !== undefined) {
645
+ handle.agent.steer(decision.guidance);
646
+ }
647
+ supervision.rearmClock(label, supervision.defaultCheckMs);
648
+ supervision.rearmSilence(label);
649
+ break;
650
+ case 'replace': {
651
+ // Budget checked BEFORE the verdict is set, so two near-simultaneous
652
+ // replace decisions cannot both rebuild (the second becomes an
653
+ // abandon with the budget stated in the reason).
654
+ handle.overseerVerdict = overseer.replacementsLeft(label)
655
+ ? {
656
+ action: 'replace',
657
+ reason: decision.reason,
658
+ ...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
659
+ }
660
+ : { action: 'abandon', reason: `replacement budget exhausted; ${decision.reason}` };
661
+ supervision.clear(label);
662
+ handle.abort();
663
+ break;
664
+ }
665
+ case 'abandon':
666
+ handle.overseerVerdict = { action: 'abandon', reason: decision.reason };
667
+ supervision.clear(label);
668
+ handle.abort();
669
+ break;
670
+ }
671
+ }
672
+ /** Record one applied decision on the outcome, the run and the event stream. */
673
+ recordIntervention(dispatchId, label, trigger, decision) {
674
+ this.interventions.push({
675
+ label,
676
+ at: Date.now(),
677
+ action: decision.action,
678
+ reason: decision.reason,
679
+ trigger,
680
+ });
681
+ const handle = this.live.find((h) => h.run.label === label);
682
+ if (handle !== undefined) {
683
+ handle.run.interventions = (handle.run.interventions ?? 0) + 1;
684
+ this.publish(dispatchId, handle.run);
685
+ }
686
+ }
334
687
  /**
335
688
  * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
336
689
  * then error, then no-summary, then done.
@@ -401,6 +754,7 @@ export class TeamRuntime {
401
754
  }
402
755
  }
403
756
  this.bus?.cancelAllWaits();
757
+ this.supervision?.clearAll();
404
758
  // Every in-flight retry backoff, not just the last one registered: a
405
759
  // provider-wide failure puts up to `maxConcurrent` children here at once.
406
760
  for (const release of [...this.pendingBackoffs])
@@ -412,6 +766,9 @@ export class TeamRuntime {
412
766
  if (this.dispatchTimer)
413
767
  clearTimeout(this.dispatchTimer);
414
768
  this.dispatchTimer = null;
769
+ this.supervision?.clearAll();
770
+ this.supervision = null;
771
+ this.overseer = null;
415
772
  this.listeners.clear();
416
773
  }
417
774
  // -------------------------------------------------------------------------
@@ -19,7 +19,7 @@ import type { ToolPermission } from '../exec/permission.js';
19
19
  import type { ChildReviewProvider } from './child-reviewer.js';
20
20
  import { TeamBus } from './bus.js';
21
21
  import type { TeamHumanQueue, WatchdogPausable } from './human-queue.js';
22
- import type { SubagentRun, SubagentSpec } from './types.js';
22
+ import type { OverseerVerdict, SubagentRun, SubagentSpec } from './types.js';
23
23
  /**
24
24
  * The slice of `Agent` a child is used through.
25
25
  *
@@ -98,6 +98,14 @@ export interface SubagentDeps {
98
98
  /** Present only when `--confirm` is on AND a human channel exists. */
99
99
  humanQueue?: TeamHumanQueue;
100
100
  agentFactory: SubagentAgentFactory;
101
+ /**
102
+ * Whether the dispatch supervisor is live for THIS dispatch
103
+ * (team-overseer). Read once per dispatch by the runtime and spread here;
104
+ * when true the child's OWN idle watchdog is lengthened by
105
+ * `TEAM_LIMITS.overseerWatchdogFactor`, so the supervisor's soft silence
106
+ * inspection at `idleTimeoutMs` always precedes the hard abort.
107
+ */
108
+ overseerActive?: boolean;
101
109
  /**
102
110
  * Per-child fast review (main-agent parity): the controller's shared
103
111
  * provider, built on the lead's `FastWiring`. ABSENT means the session
@@ -120,6 +128,13 @@ export interface SubagentHooks {
120
128
  /** Coalesced at the source (§5.2); phase transitions always pass. */
121
129
  onUpdate: (run: SubagentRun) => void;
122
130
  onUsage: (label: string, usage: TokenUsage) => void;
131
+ /**
132
+ * UNTHROTTLED per-event callback (team-overseer I-OV3). `onUpdate` is
133
+ * coalesced, so it would lie about silence; the supervisor's soft stall
134
+ * timer needs one kick per REAL agent event. Called after the phase switch
135
+ * so the phase read at fire time is current.
136
+ */
137
+ onActivity?: () => void;
123
138
  }
124
139
  export interface SubagentHandle {
125
140
  spec: SubagentSpec;
@@ -134,6 +149,20 @@ export interface SubagentHandle {
134
149
  * reviewer steers a `<fast_review>` block into a run that is already dying.
135
150
  */
136
151
  abort: () => void;
152
+ /**
153
+ * Whether `abort()` has been called on this handle (team-overseer
154
+ * I-OV4): the supervisor reads it before steering a nudge, so advice is
155
+ * never queued into a child that is already dying - the same guard-1 fact
156
+ * the child's own fast reviewer reads.
157
+ */
158
+ isAborted: () => boolean;
159
+ /**
160
+ * A terminal supervisor decision awaiting application by the WORKER LOOP
161
+ * (team-overseer I-OV1). Written by `TeamRuntime` immediately before
162
+ * `abort()`; read by `runOne` after `prompt()` resolves. NEVER cleared,
163
+ * so an abort racing the read cannot hide the cause.
164
+ */
165
+ overseerVerdict?: OverseerVerdict;
137
166
  unsubscribe: () => void;
138
167
  }
139
168
  /**
@@ -241,7 +241,9 @@ export function createSubagent(spec, deps, hooks) {
241
241
  // (§3.8). The two places a child legitimately blocks on something other
242
242
  // than its own LLM or tool — `team_wait` and the confirm queue — pause it
243
243
  // explicitly.
244
- idleTimeout: deps.config.idleTimeoutMs,
244
+ idleTimeout: deps.overseerActive === true
245
+ ? deps.config.idleTimeoutMs * TEAM_LIMITS.overseerWatchdogFactor
246
+ : deps.config.idleTimeoutMs,
245
247
  },
246
248
  // SPREAD CONDITIONALLY, never `contextManager: deps.contextManagerFor?.(...)`.
247
249
  // With the feature off the options bag has no such key, so the loop's gate
@@ -437,6 +439,10 @@ export function createSubagent(spec, deps, hooks) {
437
439
  default:
438
440
  break;
439
441
  }
442
+ // AFTER the switch, so `run.phase` is current when the supervisor's
443
+ // silence timer consults it (team-overseer I-OV3): a `team_wait` start
444
+ // must be observable as `waiting` on the very kick that follows it.
445
+ hooks.onActivity?.();
440
446
  emit();
441
447
  });
442
448
  return {
@@ -450,6 +456,7 @@ export function createSubagent(spec, deps, hooks) {
450
456
  abortRequested = true;
451
457
  agent.abort();
452
458
  },
459
+ isAborted: () => abortRequested,
453
460
  unsubscribe: () => {
454
461
  reviewer?.dispose();
455
462
  unsubscribe();
@@ -47,7 +47,10 @@ const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get
47
47
  'a change small enough to just make. Subagents cannot dispatch further subagents and ' +
48
48
  'cannot ask the user anything. Give each a description of at most ' +
49
49
  `${TEAM_LIMITS.descriptionChars} characters and a complete prompt. Extra entries beyond the ` +
50
- 'configured maximum are dropped, so keep within it.';
50
+ 'configured maximum are dropped, so keep within it. A supervisor watches the ' +
51
+ 'fan-out: a child that stalls or runs very long may be nudged with guidance ' +
52
+ 'or replaced by a fresh attempt, and the report\'s Supervisor section ' +
53
+ 'records every intervention.';
51
54
  export function createTaskTool(deps) {
52
55
  return {
53
56
  name: 'task',