@aragon-agent/cli 0.6.20 → 0.6.22

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 (38) hide show
  1. package/README.md +32 -12
  2. package/dist/agent/controller.js +13 -0
  3. package/dist/commands/builtins.js +2 -1
  4. package/dist/config/cli-commands.d.ts +1 -1
  5. package/dist/config/cli-commands.js +3 -0
  6. package/dist/config/schema.d.ts +28 -12
  7. package/dist/config/schema.js +4 -0
  8. package/dist/team/limits.d.ts +73 -13
  9. package/dist/team/limits.js +82 -15
  10. package/dist/team/normalize.js +7 -2
  11. package/dist/team/overseer.d.ts +154 -16
  12. package/dist/team/overseer.js +342 -46
  13. package/dist/team/report.d.ts +9 -0
  14. package/dist/team/report.js +32 -4
  15. package/dist/team/runtime.d.ts +51 -9
  16. package/dist/team/runtime.js +478 -71
  17. package/dist/team/task-tool.d.ts +7 -0
  18. package/dist/team/task-tool.js +2 -0
  19. package/dist/team/types.d.ts +43 -2
  20. package/dist/ui/App.js +80 -2
  21. package/dist/ui/Composer.d.ts +14 -1
  22. package/dist/ui/Composer.js +6 -3
  23. package/dist/ui/PromptInput.d.ts +10 -1
  24. package/dist/ui/PromptInput.js +11 -1
  25. package/dist/ui/TeamPanel.d.ts +22 -0
  26. package/dist/ui/TeamPanel.js +71 -2
  27. package/dist/ui/editor-reducer.js +10 -5
  28. package/dist/ui/interaction-copy.d.ts +1 -0
  29. package/dist/ui/interaction-copy.js +1 -0
  30. package/dist/ui/interaction-hints.d.ts +2 -0
  31. package/dist/ui/interaction-hints.js +1 -1
  32. package/dist/ui/layout/status-layout.js +1 -0
  33. package/dist/ui/layout/team-panel.js +4 -1
  34. package/dist/ui/overlays/HelpOverlay.js +4 -1
  35. package/dist/ui/overlays/ModelProfilePicker.js +12 -1
  36. package/dist/ui/status-feedback.d.ts +3 -1
  37. package/dist/ui/status-feedback.js +3 -1
  38. package/package.json +2 -2
@@ -28,30 +28,48 @@
28
28
  * pre-feature behaviour.
29
29
  */
30
30
  import { TeamBus } from './bus.js';
31
- import { TEAM_LIMITS } from './limits.js';
32
- import { TeamOverseer, buildReplacementPrompt } from './overseer.js';
31
+ import { TEAM_AGGREGATE_LABEL, TEAM_LIMITS } from './limits.js';
32
+ import { TeamOverseer, buildReplacementPrompt, nextOverseerLadderStepMs, resolveOverseerFirstCheckMs, } from './overseer.js';
33
33
  import { shouldRetryColdStart } from './retry.js';
34
34
  import { createSubagent, defaultSubagentFactory, } from './subagent.js';
35
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.
36
+ * The pseudo-label aggregate supervisor notices travel under (D-5 / R-P1-6):
37
+ * `TEAM_AGGREGATE_LABEL`, RESERVED at normalization so no child slug can be
38
+ * `team`. The reservation is what makes this label a safe namespace - a
39
+ * colliding child would have the aggregate notice attributed to it in the
40
+ * ledger and its own quiet note swallowed by the report's aggregate count.
41
+ */
42
+ const MONITOR_TEAM_LABEL = TEAM_AGGREGATE_LABEL;
43
+ const MONITOR_EXHAUSTED_DECISION = {
44
+ action: 'wait',
45
+ reason: 'dispatch look budget exhausted; supervision quiet for the rest of the dispatch',
46
+ };
47
+ /**
48
+ * The wait a DEGRADED tick announces per child (D-4 / D-10). One shared
49
+ * object, not a fresh literal per event: it is the same fact every time and
50
+ * identity makes that obvious to any consumer that looks.
51
+ */
52
+ const DEGRADED_WAIT_DECISION = {
53
+ action: 'wait',
54
+ reason: 'fast tier unavailable; waiting unassisted',
55
+ };
56
+ /**
57
+ * The per-child SILENCE windows (team-overseer §3.2; subagent-overseer-v2
58
+ * keeps only this half). Timers only — no state, no decisions; the runtime
59
+ * applies what the supervisor decides. The wall-clock half moved to
60
+ * `MonitorLoop`, which owns the cadence ladder for the whole roster.
38
61
  *
39
62
  * Every timer is `unref`'d (the `backoff` argument): a pending supervision
40
63
  * look must not hold the process open on exit.
41
64
  */
42
65
  class ChildSupervisor {
43
66
  deps;
44
- clocks = new Map();
45
67
  silences = new Map();
46
- /** The interval used when a decision did not name one. */
47
- defaultCheckMs;
48
68
  constructor(deps) {
49
69
  this.deps = deps;
50
- this.defaultCheckMs = Math.max(TEAM_LIMITS.overseerNextCheckMinMs, deps.firstCheckMs);
51
70
  }
52
- /** Arm (or re-arm) both windows for a child starting or restarting an attempt. */
71
+ /** Arm (or re-arm) the silence window for a child starting an attempt. */
53
72
  start(label) {
54
- this.armClock(label, this.deps.firstCheckMs);
55
73
  this.armSilence(label);
56
74
  }
57
75
  /** Reset the silence window. A no-op for a child nobody is supervising. */
@@ -62,33 +80,16 @@ class ChildSupervisor {
62
80
  rearmSilence(label) {
63
81
  this.armSilence(label);
64
82
  }
65
- rearmClock(label, ms) {
66
- this.armClock(label, ms);
67
- }
68
83
  clear(label) {
69
- const clock = this.clocks.get(label);
70
- if (clock !== undefined)
71
- clearTimeout(clock);
72
84
  const silence = this.silences.get(label);
73
85
  if (silence !== undefined)
74
86
  clearTimeout(silence);
75
- this.clocks.delete(label);
76
87
  this.silences.delete(label);
77
88
  }
78
89
  clearAll() {
79
- for (const label of [...this.clocks.keys()])
80
- this.clear(label);
81
90
  for (const label of [...this.silences.keys()])
82
91
  this.clear(label);
83
92
  }
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
93
  armSilence(label) {
93
94
  const previous = this.silences.get(label);
94
95
  if (previous !== undefined)
@@ -101,17 +102,191 @@ class ChildSupervisor {
101
102
  this.armSilence(label);
102
103
  return;
103
104
  }
104
- this.fire(label, 'silence');
105
+ this.silences.delete(label);
106
+ this.deps.onFire(label, 'silence');
105
107
  }, this.deps.idleMs);
106
108
  timer.unref?.();
107
109
  this.silences.set(label, timer);
108
110
  }
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);
111
+ }
112
+ /**
113
+ * The cadence ladder (subagent-overseer-v2 D-2): one timer for the whole
114
+ * roster, per-child `nextAt` / `ladderStep` entries armed at each child's
115
+ * ACTUAL start (R-P1-5), and a strict one-batch-in-flight rule.
116
+ *
117
+ * A SKIPPED LABEL RE-SCHEULES WITHOUT TOUCHING ITS LADDER STEP: the
118
+ * single-flight guard (R-P1-4) can leave a due `nextAt` in the past while a
119
+ * silence-triggered inspection of that child is still settling, and a timer
120
+ * that re-fired at `max(0, due - now)` would busy-loop until it settles. The
121
+ * recheck delay is invisible to the protocol - no look is spent, the ladder
122
+ * value does not move - it only keeps the event loop idle.
123
+ */
124
+ const MONITOR_RECHECK_MS = 500;
125
+ class MonitorLoop {
126
+ deps;
127
+ nextAt = new Map();
128
+ ladderStep = new Map();
129
+ timer = null;
130
+ ticking = false;
131
+ stopped = false;
132
+ constructor(deps) {
133
+ this.deps = deps;
134
+ }
135
+ /** Arm the FIRST rung for a child that just started (R-P1-5). */
136
+ arm(label) {
137
+ const now = this.deps.now();
138
+ this.nextAt.set(label, now + this.deps.firstCheckMs);
139
+ this.ladderStep.set(label, this.deps.firstCheckMs);
140
+ this.schedule();
141
+ }
142
+ /** A wait decision's `nextCheckMs` OVERRIDES the rung and becomes it (D-2). */
143
+ override(label, ms) {
144
+ const now = this.deps.now();
145
+ this.nextAt.set(label, now + ms);
146
+ this.ladderStep.set(label, ms);
147
+ this.schedule();
148
+ }
149
+ /** Any ACTION resets that child's ladder to the base rung (D-2). */
150
+ reset(label) {
151
+ this.arm(label);
152
+ }
153
+ /** Grow the rung and schedule the next visit (D-2). */
154
+ advance(label) {
155
+ const now = this.deps.now();
156
+ const step = nextOverseerLadderStepMs(this.ladderStep.get(label) ?? this.deps.firstCheckMs);
157
+ this.ladderStep.set(label, step);
158
+ this.nextAt.set(label, now + step);
159
+ this.schedule();
160
+ }
161
+ /** Stop visiting one child (settled, quieted, or its verdict applied). */
162
+ clear(label) {
163
+ this.nextAt.delete(label);
164
+ this.ladderStep.delete(label);
165
+ this.schedule();
166
+ }
167
+ /** Stop the whole loop; the dispatch is over or the total budget is spent. */
168
+ stop() {
169
+ this.stopped = true;
170
+ if (this.timer !== null)
171
+ clearTimeout(this.timer);
172
+ this.timer = null;
173
+ this.nextAt.clear();
174
+ this.ladderStep.clear();
175
+ }
176
+ dispose() {
177
+ this.stop();
178
+ }
179
+ /** The earliest due time among live labels, or null when nothing is armed. */
180
+ earliestDue() {
181
+ let earliest = null;
182
+ for (const [label, at] of this.nextAt) {
183
+ if (!this.deps.isLive(label))
184
+ continue;
185
+ earliest = earliest === null ? at : Math.min(earliest, at);
186
+ }
187
+ return earliest;
188
+ }
189
+ schedule() {
190
+ if (this.timer !== null) {
191
+ clearTimeout(this.timer);
192
+ this.timer = null;
193
+ }
194
+ if (this.stopped || this.ticking)
195
+ return;
196
+ const earliest = this.earliestDue();
197
+ if (earliest === null)
198
+ return;
199
+ const delay = Math.max(0, earliest - this.deps.now());
200
+ const timer = setTimeout(() => {
201
+ this.timer = null;
202
+ void this.tick();
203
+ }, delay);
204
+ timer.unref?.();
205
+ this.timer = timer;
206
+ }
207
+ async tick() {
208
+ if (this.ticking || this.stopped)
209
+ return;
210
+ const now = this.deps.now();
211
+ const due = [];
212
+ for (const [label, at] of [...this.nextAt]) {
213
+ if (!this.deps.isLive(label)) {
214
+ // Settled while armed; R-P1-5 also means a re-queued replacement body
215
+ // re-arms itself at its own start.
216
+ this.nextAt.delete(label);
217
+ this.ladderStep.delete(label);
218
+ continue;
219
+ }
220
+ if (now >= at)
221
+ due.push(label);
222
+ }
223
+ if (due.length === 0) {
224
+ this.schedule();
225
+ return;
226
+ }
227
+ // DEGRADED TICK (D-4 / D-10): no fast tier right now. The children wait
228
+ // unassisted - announced per child, never killed - and the cadence keeps
229
+ // running so a `/fast on` restores assistance at the next rung.
230
+ if (!this.deps.isActive()) {
231
+ this.deps.onDegraded(due);
232
+ for (const label of due)
233
+ this.advance(label);
234
+ this.schedule();
235
+ return;
236
+ }
237
+ // PER-CHILD QUIET (D-5): budget spent says so once, then stop visiting.
238
+ const inspectable = [];
239
+ for (const label of due) {
240
+ if (this.deps.isExhausted(label)) {
241
+ this.clear(label);
242
+ this.deps.onQuiet(label);
243
+ continue;
244
+ }
245
+ inspectable.push(label);
246
+ }
247
+ if (inspectable.length === 0) {
248
+ this.schedule();
249
+ return;
250
+ }
251
+ this.ticking = true;
252
+ let decisions;
253
+ try {
254
+ decisions = await this.deps.inspectBatch(inspectable);
255
+ }
256
+ catch {
257
+ // Defence in depth: `inspectBatch` is fail-soft by contract. Treat the
258
+ // tick as if every child had answered wait.
259
+ decisions = new Map();
260
+ }
261
+ finally {
262
+ this.ticking = false;
263
+ }
264
+ const after = this.deps.now();
265
+ for (const label of inspectable) {
266
+ const decision = decisions.get(label);
267
+ if (decision === undefined) {
268
+ // Skipped for single-flight (R-P1-4): no decision, no look, and the
269
+ // ladder does not move - only the timer must not busy-loop.
270
+ this.nextAt.set(label, after + MONITOR_RECHECK_MS);
271
+ continue;
272
+ }
273
+ if (decision.action === 'wait' && decision.nextCheckMs !== undefined) {
274
+ this.override(label, decision.nextCheckMs);
275
+ }
276
+ else if (decision.action === 'wait') {
277
+ this.advance(label);
278
+ }
279
+ else {
280
+ this.reset(label);
281
+ }
282
+ }
283
+ if (this.deps.dispatchExhausted()) {
284
+ // The fool-proof total (R-P1-6). Stop the whole loop, once, loudly.
285
+ this.stop();
286
+ this.deps.onExhausted();
287
+ return;
288
+ }
289
+ this.schedule();
115
290
  }
116
291
  }
117
292
  export class TeamRuntime {
@@ -137,15 +312,30 @@ export class TeamRuntime {
137
312
  */
138
313
  pendingBackoffs = new Set();
139
314
  /**
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.
315
+ * The dispatch supervisor, LIVE for the current dispatch only
316
+ * (team-overseer / subagent-overseer-v2). Null when the provider is not
317
+ * wired or the policy is off, and reset to null at every dispatch end so
318
+ * nothing supervision-shaped leaks across dispatches.
319
+ *
320
+ * THE GATE IS "PROVIDER PRESENT AND POLICY ON" (R-P0-2), deliberately NOT
321
+ * the provider's live `active()`: a dispatch that STARTS without a fast
322
+ * tier must still get a MonitorLoop, because the promise this feature
323
+ * makes is "timeout is never a death sentence" - an unavailable tier
324
+ * degrades the tick to an announced unassisted wait rather than falling
325
+ * back to the legacy hard abort. `active()` is re-read every tick.
143
326
  */
144
327
  overseer = null;
145
- /** The soft timers, same lifetime as `overseer`. */
328
+ /** The per-child silence windows, same lifetime as `overseer`. */
146
329
  supervision = null;
330
+ /** The cadence ladder (subagent-overseer-v2 D-2), same lifetime. */
331
+ monitor = null;
147
332
  /** Applied interventions for the in-flight dispatch's outcome. */
148
333
  interventions = [];
334
+ /** Labels already told the supervisor went quiet on them (D-5, once). */
335
+ quietNotified = new Set();
336
+ /** Edge-latched "a tick ran unassisted" (D-10), per dispatch. */
337
+ overseerDegraded = false;
338
+ overseerDegradedTicks = 0;
149
339
  constructor(deps) {
150
340
  this.deps = deps;
151
341
  }
@@ -177,29 +367,48 @@ export class TeamRuntime {
177
367
  this.busy = true;
178
368
  this.aborted = false;
179
369
  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.
370
+ this.quietNotified.clear();
371
+ this.overseerDegraded = false;
372
+ this.overseerDegradedTicks = 0;
373
+ // THE SUPERVISOR IS CREATED PER DISPATCH (R-P0-2): provider wired AND
374
+ // policy on. `TeamOverseer.isActive()` re-checks the provider per
375
+ // inspection and per tick, so a mid-dispatch `/fast off` degrades the
376
+ // NEXT look to an unassisted wait instead of unwinding anything already
377
+ // applied - and a dispatch that started without the tier is still
378
+ // supervised, in that degraded, never-kill sense.
379
+ const supervised = this.deps.overseer !== undefined && team.overseer;
184
380
  this.overseer =
185
- this.deps.overseer !== undefined &&
186
- this.deps.overseer.active() &&
187
- team.overseer
381
+ supervised && this.deps.overseer !== undefined
188
382
  ? new TeamOverseer(this.deps.overseer, { startedAt })
189
383
  : null;
190
384
  this.supervision =
191
385
  this.overseer !== null
192
386
  ? new ChildSupervisor({
193
387
  idleMs: config.idleTimeoutMs,
194
- firstCheckMs: team.subagentTimeoutMs > 0
195
- ? team.subagentTimeoutMs
196
- : TEAM_LIMITS.overseerDefaultCheckMs,
197
388
  isLegitimatelyBlocked: (label) => this.isBlocked(label),
198
389
  onFire: (label, trigger) => {
199
390
  void this.inspectAndApply(dispatchId, label, trigger);
200
391
  },
201
392
  })
202
393
  : null;
394
+ this.monitor =
395
+ this.overseer !== null
396
+ ? new MonitorLoop({
397
+ // D-8: `overseerIntervalMs` wins, then a positive
398
+ // `subagentTimeoutMs` (compat), then the structural default.
399
+ firstCheckMs: resolveOverseerFirstCheckMs(team),
400
+ now: () => Date.now(),
401
+ // R-P1-5: only STARTED, UNSETTLED children tick.
402
+ isLive: (label) => this.isMonitorLive(label),
403
+ isActive: () => this.overseer?.isActive() === true,
404
+ isExhausted: (label) => this.overseer?.looksExhausted(label) === true,
405
+ dispatchExhausted: () => this.overseer?.dispatchLooksExhausted() === true,
406
+ inspectBatch: (labels) => this.monitorInspectBatch(dispatchId, labels),
407
+ onDegraded: (labels) => this.noteDegradedTick(dispatchId, labels),
408
+ onQuiet: (label) => this.noteQuiet(dispatchId, label),
409
+ onExhausted: () => this.noteDispatchExhausted(dispatchId),
410
+ })
411
+ : null;
203
412
  // DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
204
413
  // this binding so it reads the LIVE array: F-4 replaces `handles[index]`
205
414
  // mid-dispatch, and a captured array or a captured handle would answer for
@@ -319,7 +528,11 @@ export class TeamRuntime {
319
528
  dispatchId,
320
529
  handles,
321
530
  makeHandle,
322
- subagentTimeoutMs: team.subagentTimeoutMs,
531
+ // D-4: with the supervisor gate open, `subagentTimeoutMs` NEVER arms a
532
+ // hard abort - 0 here arms no timer, so the key's only remaining role
533
+ // is the ladder's compat first-check (D-8). The legacy hard abort
534
+ // exists ONLY under `team.overseer: false`.
535
+ subagentTimeoutMs: team.overseer ? 0 : team.subagentTimeoutMs,
323
536
  };
324
537
  const cursor = { next: 0 };
325
538
  const workers = Array.from({ length: Math.max(1, Math.min(team.maxConcurrent, handles.length)) }, () => this.worker(runCtx, cursor));
@@ -330,8 +543,17 @@ export class TeamRuntime {
330
543
  signal?.removeEventListener('abort', onAbort);
331
544
  for (const handle of handles)
332
545
  handle.unsubscribe();
546
+ this.monitor?.dispose();
547
+ this.monitor = null;
333
548
  this.supervision?.clearAll();
334
549
  this.supervision = null;
550
+ // CAPTURED BEFORE THE FIELD IS NULLED: the outcome carries what the
551
+ // supervisor spent (D-7) and whether any tick ran unassisted (D-10),
552
+ // both read off the per-dispatch state being torn down here.
553
+ const overseerUsage = this.overseer?.usage();
554
+ const overseerCalls = this.overseer?.calls();
555
+ const overseerDegraded = this.overseerDegraded;
556
+ const overseerDegradedTicks = this.overseerDegradedTicks;
335
557
  this.overseer = null;
336
558
  // ONE TOTAL, AT THE LEAD'S TABLE. Every child runs the lead's own model
337
559
  // (main-agent parity), so there is exactly one tier to sum and one price to
@@ -350,6 +572,13 @@ export class TeamRuntime {
350
572
  leadMail: bus.leadMail(),
351
573
  interventions: this.interventions,
352
574
  usage,
575
+ // Honest supervision accounting (D-7 / D-10). `overseerCalls > 0`
576
+ // with zero usage is a provider that never reported usage - the
577
+ // report then omits the spend line rather than pricing nothing.
578
+ ...(overseerUsage !== undefined ? { overseerUsage } : {}),
579
+ ...(overseerCalls !== undefined ? { overseerCalls } : {}),
580
+ ...(overseerDegraded ? { overseerDegraded: true } : {}),
581
+ ...(overseerDegradedTicks > 0 ? { overseerDegradedTicks } : {}),
353
582
  };
354
583
  this.live = [];
355
584
  this.bus = null;
@@ -400,13 +629,20 @@ export class TeamRuntime {
400
629
  run.phase = 'starting';
401
630
  run.startedAt = Date.now();
402
631
  this.publish(dispatchId, run);
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.
632
+ // WITH THE SUPERVISOR GATE OPEN there is no hard wall clock on the
633
+ // child at all (D-4): the cadence ladder looks and decides, and the
634
+ // child's own (lengthened) idle watchdog is the silence backstop. The
635
+ // rung and the silence window are both armed HERE, at the attempt's
636
+ // actual start (R-P1-5) - a `queued` child has neither. WITHOUT the
637
+ // gate - `team.overseer: false`, the legacy regime - the hard timer is
638
+ // armed exactly as before, `0` meaning no ceiling. A provider that is
639
+ // wired but INACTIVE arms no timer either: the tick degrades to an
640
+ // announced unassisted wait, never a kill (R-P0-2).
407
641
  const supervised = this.overseer !== null;
408
- if (supervised)
642
+ if (supervised) {
409
643
  this.supervision?.start(run.label);
644
+ this.monitor?.arm(run.label);
645
+ }
410
646
  let timedOut = false;
411
647
  const timer = !supervised && subagentTimeoutMs > 0
412
648
  ? setTimeout(() => {
@@ -462,6 +698,7 @@ export class TeamRuntime {
462
698
  // wants diagnosed, and replace-after-death is exactly re-assignment.
463
699
  await this.maybePostMortem(ctx, index, handle, verdict);
464
700
  this.supervision?.clear(run.label);
701
+ this.monitor?.clear(run.label);
465
702
  return;
466
703
  }
467
704
  this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
@@ -534,6 +771,7 @@ export class TeamRuntime {
534
771
  const retries = (handle.run.retries ?? 0) + (verdict === null ? 1 : 0);
535
772
  const replacements = (handle.run.replacements ?? 0) + (verdict !== null ? 1 : 0);
536
773
  const interventions = handle.run.interventions;
774
+ const lastIntervention = handle.run.lastIntervention;
537
775
  handle.unsubscribe();
538
776
  try {
539
777
  handle.abort();
@@ -562,6 +800,10 @@ export class TeamRuntime {
562
800
  fresh.run.replacements = replacements;
563
801
  if (interventions !== undefined)
564
802
  fresh.run.interventions = interventions;
803
+ // The badge survives a rebuild (`replaced 5s`) - the panel row does not
804
+ // reset just because the body under it did.
805
+ if (lastIntervention !== undefined)
806
+ fresh.run.lastIntervention = lastIntervention;
565
807
  if (verdict !== null)
566
808
  this.overseer?.noteReplacement(handle.run.label);
567
809
  handles[index] = fresh;
@@ -583,12 +825,14 @@ export class TeamRuntime {
583
825
  return this.deps.humanQueue?.isWaiting(label) === true;
584
826
  }
585
827
  /**
586
- * One trigger handled: look, then apply. Fired from a supervision timer and
587
- * therefore async-and-forgotten; nothing here may throw.
828
+ * One SILENCE trigger handled: look, then apply. Fired from a supervision
829
+ * timer and therefore async-and-forgotten; nothing here may throw.
588
830
  *
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.
831
+ * A NULL DECISION HAS THREE DISTINCT QUIETS: the look budget is spent ->
832
+ * say so ONCE and stop supervising this child (D-5); an inspection of this
833
+ * child is already in flight -> re-arm and let it settle (R-P1-4); the
834
+ * tier is gone -> clear quietly, because the cadence tick - not the
835
+ * silence path - owns the degraded announcements (D-4).
592
836
  */
593
837
  async inspectAndApply(dispatchId, label, trigger) {
594
838
  const overseer = this.overseer;
@@ -609,7 +853,17 @@ export class TeamRuntime {
609
853
  childAlive: true,
610
854
  });
611
855
  if (decision === null || this.aborted) {
612
- supervision.clear(label);
856
+ if (!this.aborted && overseer.looksExhausted(label)) {
857
+ this.noteQuiet(dispatchId, label);
858
+ supervision.clear(label);
859
+ this.monitor?.clear(label);
860
+ }
861
+ else if (!this.aborted && overseer.isInFlight(label)) {
862
+ supervision.rearmSilence(label);
863
+ }
864
+ else {
865
+ supervision.clear(label);
866
+ }
613
867
  return;
614
868
  }
615
869
  // The child may have settled while the call was in flight; an
@@ -621,9 +875,10 @@ export class TeamRuntime {
621
875
  this.emit({ type: 'overseer', dispatchId, label, decision, trigger });
622
876
  if (settled) {
623
877
  supervision.clear(label);
878
+ this.monitor?.clear(label);
624
879
  return;
625
880
  }
626
- this.applyDecision(supervision, overseer, handle, label, decision);
881
+ this.applyDecision(handle, label, decision);
627
882
  }
628
883
  catch {
629
884
  // Defence in depth around an async timer callback: an unexpected throw
@@ -631,49 +886,88 @@ export class TeamRuntime {
631
886
  supervision.rearmSilence(label);
632
887
  }
633
888
  }
634
- applyDecision(supervision, overseer, handle, label, decision) {
889
+ applyDecision(handle, label, decision) {
890
+ const supervision = this.supervision;
891
+ const overseer = this.overseer;
892
+ if (supervision === null || overseer === null)
893
+ return;
635
894
  switch (decision.action) {
636
895
  case 'wait':
637
- supervision.rearmClock(label, decision.nextCheckMs ?? supervision.defaultCheckMs);
896
+ // A model-chosen interval OVERRIDES the rung and becomes it (D-2);
897
+ // without one the rung is untouched - the silence window is the only
898
+ // clock this path re-arms.
899
+ if (decision.nextCheckMs !== undefined) {
900
+ this.monitor?.override(label, decision.nextCheckMs);
901
+ }
638
902
  supervision.rearmSilence(label);
639
903
  break;
640
- case 'nudge':
904
+ case 'nudge': {
905
+ // THE NUDGE BUDGET IS CHECKED BEFORE STEERING (D-5): a fourth nudge
906
+ // is not advice, it is the supervisor refusing to decide - it waits.
907
+ if (!overseer.nudgesLeft(label)) {
908
+ supervision.rearmSilence(label);
909
+ break;
910
+ }
641
911
  // I-OV4: never steer into an abort already requested — the same
642
912
  // guard-1 fact the child's fast reviewer reads. Queued steering is
643
913
  // drained at the safe top-of-loop checkpoint by core.
644
914
  if (!handle.isAborted() && decision.guidance !== undefined) {
645
915
  handle.agent.steer(decision.guidance);
916
+ overseer.noteNudge(label);
646
917
  }
647
- supervision.rearmClock(label, supervision.defaultCheckMs);
918
+ this.monitor?.reset(label);
648
919
  supervision.rearmSilence(label);
649
920
  break;
921
+ }
650
922
  case 'replace': {
651
- // Budget checked BEFORE the verdict is set, so two near-simultaneous
923
+ // Budgets checked BEFORE the verdict is set, so two near-simultaneous
652
924
  // replace decisions cannot both rebuild (the second becomes an
653
925
  // abandon with the budget stated in the reason).
654
- handle.overseerVerdict = overseer.replacementsLeft(label)
655
- ? {
926
+ if (overseer.replacementsLeft(label)) {
927
+ handle.overseerVerdict = {
656
928
  action: 'replace',
657
929
  reason: decision.reason,
658
930
  ...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
659
- }
660
- : { action: 'abandon', reason: `replacement budget exhausted; ${decision.reason}` };
931
+ };
932
+ }
933
+ else if (overseer.abandonsLeft(label)) {
934
+ handle.overseerVerdict = {
935
+ action: 'abandon',
936
+ reason: `replacement budget exhausted; ${decision.reason}`,
937
+ };
938
+ overseer.noteAbandon(label);
939
+ }
940
+ else {
941
+ // Both mutation budgets spent: the honest reading is wait (I-OV2).
942
+ supervision.rearmSilence(label);
943
+ this.monitor?.advance(label);
944
+ break;
945
+ }
661
946
  supervision.clear(label);
947
+ this.monitor?.clear(label);
662
948
  handle.abort();
663
949
  break;
664
950
  }
665
951
  case 'abandon':
952
+ if (!overseer.abandonsLeft(label)) {
953
+ supervision.rearmSilence(label);
954
+ this.monitor?.advance(label);
955
+ break;
956
+ }
666
957
  handle.overseerVerdict = { action: 'abandon', reason: decision.reason };
958
+ overseer.noteAbandon(label);
667
959
  supervision.clear(label);
960
+ this.monitor?.clear(label);
668
961
  handle.abort();
669
962
  break;
670
963
  }
671
964
  }
672
965
  /** Record one applied decision on the outcome, the run and the event stream. */
673
966
  recordIntervention(dispatchId, label, trigger, decision) {
967
+ const at = Date.now();
674
968
  this.interventions.push({
675
969
  label,
676
- at: Date.now(),
970
+ at,
677
971
  action: decision.action,
678
972
  reason: decision.reason,
679
973
  trigger,
@@ -681,9 +975,116 @@ export class TeamRuntime {
681
975
  const handle = this.live.find((h) => h.run.label === label);
682
976
  if (handle !== undefined) {
683
977
  handle.run.interventions = (handle.run.interventions ?? 0) + 1;
978
+ // The panel's badge (AC-5): ABSOLUTE `at`, same units as `startedAt`
979
+ // (R-P2-3), so rendering is one subtraction.
980
+ handle.run.lastIntervention = {
981
+ action: decision.action,
982
+ at,
983
+ reasonHead: decision.reason.slice(0, 60),
984
+ };
684
985
  this.publish(dispatchId, handle.run);
685
986
  }
686
987
  }
988
+ // -------------------------------------------------------------------------
989
+ // The cadence loop's callbacks (subagent-overseer-v2 D-2 / D-3 / D-4)
990
+ // -------------------------------------------------------------------------
991
+ /** R-P1-5: a label ticks only once STARTED and not yet settled. */
992
+ isMonitorLive(label) {
993
+ const handle = this.live.find((h) => h.run.label === label);
994
+ if (handle === undefined || handle.run.startedAt === undefined)
995
+ return false;
996
+ const phase = handle.run.phase;
997
+ return phase !== 'done' && phase !== 'failed' && phase !== 'aborted';
998
+ }
999
+ /**
1000
+ * One ASSISTED cadence tick: build the batch over the due labels, apply
1001
+ * every returned decision through the same `applyDecision` the silence
1002
+ * path uses (D-3's single application path), and hand the map back so the
1003
+ * loop can move the ladders. Labels the batch skipped (single-flight,
1004
+ * R-P1-4) are absent from the map.
1005
+ */
1006
+ async monitorInspectBatch(dispatchId, labels) {
1007
+ const overseer = this.overseer;
1008
+ if (overseer === null || this.aborted)
1009
+ return new Map();
1010
+ const reqs = [];
1011
+ for (const label of labels) {
1012
+ const handle = this.live.find((h) => h.run.label === label);
1013
+ if (handle === undefined)
1014
+ continue;
1015
+ reqs.push({
1016
+ run: handle.run,
1017
+ messages: handle.agent.state.messages,
1018
+ trigger: 'clock',
1019
+ childAlive: true,
1020
+ });
1021
+ }
1022
+ const decisions = await overseer.inspectBatch(reqs);
1023
+ for (const [label, decision] of decisions) {
1024
+ if (this.aborted)
1025
+ break;
1026
+ const handle = this.live.find((h) => h.run.label === label);
1027
+ const settled = handle === undefined ||
1028
+ handle.run.phase === 'done' ||
1029
+ handle.run.phase === 'failed' ||
1030
+ handle.run.phase === 'aborted';
1031
+ this.recordIntervention(dispatchId, label, 'clock', decision);
1032
+ this.emit({ type: 'overseer', dispatchId, label, decision, trigger: 'clock' });
1033
+ if (settled) {
1034
+ this.monitor?.clear(label);
1035
+ continue;
1036
+ }
1037
+ this.applyDecision(handle, label, decision);
1038
+ }
1039
+ return decisions;
1040
+ }
1041
+ /**
1042
+ * One DEGRADED cadence tick (D-4 / D-10): the fast tier is missing right
1043
+ * now. The children wait unassisted - announced per child through the same
1044
+ * event shape, never killed - and the fact is edge-latched here, NOT in
1045
+ * the interventions ledger.
1046
+ */
1047
+ noteDegradedTick(dispatchId, labels) {
1048
+ this.overseerDegraded = true;
1049
+ this.overseerDegradedTicks += 1;
1050
+ for (const label of labels) {
1051
+ this.emit({
1052
+ type: 'overseer',
1053
+ dispatchId,
1054
+ label,
1055
+ decision: DEGRADED_WAIT_DECISION,
1056
+ trigger: 'clock',
1057
+ });
1058
+ }
1059
+ }
1060
+ /** The supervisor goes quiet on one child, ONCE (D-5 / D-10). */
1061
+ noteQuiet(dispatchId, label) {
1062
+ if (this.quietNotified.has(label))
1063
+ return;
1064
+ this.quietNotified.add(label);
1065
+ const decision = {
1066
+ action: 'wait',
1067
+ reason: 'look budget exhausted; supervision quiet for this child',
1068
+ };
1069
+ this.recordIntervention(dispatchId, label, 'quiet', decision);
1070
+ this.emit({ type: 'overseer', dispatchId, label, decision, trigger: 'quiet' });
1071
+ }
1072
+ /**
1073
+ * The dispatch-wide fool-proof total is spent (R-P1-6): stop the whole
1074
+ * loop and emit ONE aggregate notice under a pseudo-label that is never a
1075
+ * child slug.
1076
+ */
1077
+ noteDispatchExhausted(dispatchId) {
1078
+ this.monitor?.stop();
1079
+ this.recordIntervention(dispatchId, MONITOR_TEAM_LABEL, 'quiet', MONITOR_EXHAUSTED_DECISION);
1080
+ this.emit({
1081
+ type: 'overseer',
1082
+ dispatchId,
1083
+ label: MONITOR_TEAM_LABEL,
1084
+ decision: MONITOR_EXHAUSTED_DECISION,
1085
+ trigger: 'quiet',
1086
+ });
1087
+ }
687
1088
  /**
688
1089
  * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
689
1090
  * then error, then no-summary, then done.
@@ -755,6 +1156,10 @@ export class TeamRuntime {
755
1156
  }
756
1157
  this.bus?.cancelAllWaits();
757
1158
  this.supervision?.clearAll();
1159
+ // The cadence loop too: every child is being aborted, so no tick has
1160
+ // anything left to look at (an in-flight batch's decisions are dropped
1161
+ // by the aborted guard in `monitorInspectBatch`).
1162
+ this.monitor?.stop();
758
1163
  // Every in-flight retry backoff, not just the last one registered: a
759
1164
  // provider-wide failure puts up to `maxConcurrent` children here at once.
760
1165
  for (const release of [...this.pendingBackoffs])
@@ -766,6 +1171,8 @@ export class TeamRuntime {
766
1171
  if (this.dispatchTimer)
767
1172
  clearTimeout(this.dispatchTimer);
768
1173
  this.dispatchTimer = null;
1174
+ this.monitor?.dispose();
1175
+ this.monitor = null;
769
1176
  this.supervision?.clearAll();
770
1177
  this.supervision = null;
771
1178
  this.overseer = null;