@aragon-agent/cli 0.6.21 → 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.
@@ -1,34 +1,57 @@
1
1
  /**
2
- * The dispatch supervisor (team-overseer).
2
+ * The dispatch supervisor (team-overseer, upgraded by subagent-overseer-v2).
3
3
  *
4
4
  * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope
5
5
  * (`glyphs.test.ts`), so no literal in this tree may hold a non-ASCII byte.
6
6
  *
7
7
  * A TIME CEILING ON A CHILD IS AN INSPECTION TRIGGER, NOT A DEATH SENTENCE.
8
- * When a child stalls (event silence) or simply runs long (the wall clock),
9
- * the supervisor looks at it through ONE bounded fast-tier call and decides:
8
+ * When a child stalls (event silence) or a cadence tick comes due, the
9
+ * supervisor looks at it through ONE bounded fast-tier call and decides:
10
10
  * keep waiting, steer a nudge in to help it recover, rebuild it with an
11
11
  * amended brief, or abandon it with a recorded reason. Only the supervisor
12
12
  * "decides"; the RUNTIME applies (see `runtime.ts`), because every child
13
13
  * lifecycle change must funnel through the worker loop that awaits it
14
14
  * (I-OV1).
15
15
  *
16
+ * THE SUPERVISOR IS A NO-LOOP, BOUNDED, REMEMBERED CALLEE - NOT AN AGENT
17
+ * (subagent-overseer-v2 D-1). The requirement says "a dedicated supervising
18
+ * subagent on the fast model"; it is satisfied by a per-dispatch, remembered,
19
+ * cadence-woken caller on the fast tier rather than a real `Agent.prompt()`
20
+ * loop, because a supervisor that can itself wedge needs a second supervisor.
21
+ *
22
+ * TWO INSPECTION SHAPES (D-3). Event silence triggers a SINGLE-child look
23
+ * (fast reaction to a suspected stall, unchanged from round 1); the cadence
24
+ * ladder triggers a BATCH look that carries every live child in one digest
25
+ * and returns one JSON array of per-child decisions. The batch call carries
26
+ * its own output budget (`overseerBatchOutputTokens`, R-P0-1): a full
27
+ * ten-child reply does not physically fit in the single-inspection 512
28
+ * tokens, and a truncated reply would parse to nothing - all wait, exactly
29
+ * when help is most needed.
30
+ *
16
31
  * THE TRANSPORT IS THE REVIEWER'S, DELIBERATELY. One `complete` call, no
17
- * tools, thinking off, temperature 0, output capped at
18
- * `FAST_LIMITS.reviewOutputTokens` and wall clock at
19
- * `FAST_LIMITS.reviewTimeoutMs` — a supervision call that could itself hang
20
- * would need a second supervisor, which is the regress this design refuses.
32
+ * tools, thinking off, temperature 0, output capped (512 single /
33
+ * 2048 batch) and wall clock at `FAST_LIMITS.reviewTimeoutMs` — a
34
+ * supervision call that could itself hang would need a second supervisor,
35
+ * which is the regress this design refuses.
21
36
  *
22
37
  * REPAIR, NEVER REJECT (the `normalizeSubagentSpecs` rule). An unparseable
23
38
  * or out-of-budget decision becomes `wait` with the default interval: the
24
39
  * failure mode of a supervisor must be "looked and let it run", never
25
- * "looked and killed it" (I-OV2).
40
+ * "looked and killed it" (I-OV2). In a batch, one bad ENTRY degrades only
41
+ * that entry; a wholly unparseable reply degrades the whole batch to wait
42
+ * with zero action side effects (R-P0-1 / AC-13).
26
43
  */
27
44
  import { resolveFastTier } from '../fast/resolve.js';
28
- import { assistantText } from '../fast/review-call.js';
45
+ import { accumulateUsage, assistantText, usageOf } from '../fast/review-call.js';
29
46
  import { FAST_LIMITS } from '../fast/limits.js';
30
47
  import { truncateBytes } from './report.js';
31
48
  import { TEAM_LIMITS } from './limits.js';
49
+ /**
50
+ * Bumped whenever either supervisor protocol prompt changes (D-9), so a
51
+ * behaviour report can be tied to a prompt revision with one grep.
52
+ * `TEAM_BLOCK_VERSION` covers only the LEAD's `<team>` block, not these.
53
+ */
54
+ export const OVERSEER_PROMPT_VERSION = 'v2-2026-10';
32
55
  /** How many trailing history messages the digest carries. */
33
56
  const DIGEST_TAIL_MESSAGES = 8;
34
57
  /** Per-message text clamp inside the digest. */
@@ -94,6 +117,51 @@ export function buildChildDigest(input) {
94
117
  }
95
118
  return truncateBytes(lines.join('\n'), TEAM_LIMITS.overseerDigestBytes);
96
119
  }
120
+ /**
121
+ * The per-child sections of one batch payload (D-3).
122
+ *
123
+ * SORTED BY LABEL so the same roster produces the byte-identical payload -
124
+ * the batch is the one place where ordering is observable to the model and
125
+ * to every test that asserts on it. Each child's digest is clamped to
126
+ * `overseerPerChildDigestBytes` (facts first, history tail last - R-P2-5).
127
+ * The whole-batch ceiling is applied by the joiner in the provider request.
128
+ */
129
+ export function buildBatchSections(children) {
130
+ return [...children]
131
+ .sort((a, b) => a.run.label.localeCompare(b.run.label, 'en'))
132
+ .map((child) => ({
133
+ label: child.run.label,
134
+ goal: child.run.description,
135
+ digest: truncateBytes(buildChildDigest(child), TEAM_LIMITS.overseerPerChildDigestBytes),
136
+ }));
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Cadence ladder (D-2) - pure math, so tests assert arithmetic, not timers
140
+ // ---------------------------------------------------------------------------
141
+ /**
142
+ * The FIRST cadence check, from the D-8 priority: `overseerIntervalMs` when
143
+ * the user named one; otherwise a positive `subagentTimeoutMs` (compat with
144
+ * users who already treated it as a trigger); otherwise the structural
145
+ * default. Read-only derivation - the user's stored config is never
146
+ * rewritten to "resolve" this.
147
+ */
148
+ export function resolveOverseerFirstCheckMs(team) {
149
+ if (team.overseerIntervalMs > 0)
150
+ return team.overseerIntervalMs;
151
+ if (team.subagentTimeoutMs > 0)
152
+ return team.subagentTimeoutMs;
153
+ return TEAM_LIMITS.overseerDefaultCheckMs;
154
+ }
155
+ /**
156
+ * The next ladder step after one that produced no action: grow by
157
+ * `overseerCadenceRatio`, clamped to the structural range. With the shipped
158
+ * 300 s base the checks land at 300 / 780 / 1548 / 2448 / ... seconds - a
159
+ * healthy slow child is looked at logarithmically in its run time (AC-11).
160
+ */
161
+ export function nextOverseerLadderStepMs(previousMs) {
162
+ const grown = Math.round(previousMs * TEAM_LIMITS.overseerCadenceRatio);
163
+ return Math.min(TEAM_LIMITS.overseerNextCheckMaxMs, Math.max(TEAM_LIMITS.overseerNextCheckMinMs, grown));
164
+ }
97
165
  // ---------------------------------------------------------------------------
98
166
  // Decision normalization
99
167
  // ---------------------------------------------------------------------------
@@ -119,8 +187,39 @@ function waitDecision(reason, nextCheckMs) {
119
187
  * replaced or abandoned, never waited on or nudged, so those normalize to
120
188
  * `abandon` rather than being rejected — a post-mortem answer the protocol
121
189
  * cannot honour must still produce a decision the runtime can apply.
190
+ *
191
+ * The single-inspection char limits (200/1200) are the defaults here; the
192
+ * batch path calls `normalizeWithLimits` directly with the tighter protocol
193
+ * limits (R-P0-1).
122
194
  */
123
195
  export function normalizeOverseerDecision(raw, childAlive) {
196
+ return normalizeWithLimits(raw, childAlive, {
197
+ reasonChars: TEAM_LIMITS.overseerReasonChars,
198
+ guidanceChars: TEAM_LIMITS.overseerGuidanceChars,
199
+ });
200
+ }
201
+ /**
202
+ * Parse the model's answer text as one JSON decision object.
203
+ *
204
+ * Forgiving on PURPOSE: models wrap JSON in prose and fences. The FIRST `{`
205
+ * to the LAST `}` is the only span tried, and anything that does not parse is
206
+ * `wait` — the caller never sees a throw from parsing.
207
+ */
208
+ export function parseOverseerDecisionText(text, childAlive) {
209
+ const first = text.indexOf('{');
210
+ const last = text.lastIndexOf('}');
211
+ if (first >= 0 && last > first) {
212
+ try {
213
+ return normalizeOverseerDecision(JSON.parse(text.slice(first, last + 1)), childAlive);
214
+ }
215
+ catch {
216
+ // Fall through to the safe default.
217
+ }
218
+ }
219
+ return waitDecision('decision unparseable; defaulting to wait');
220
+ }
221
+ /** Normalize with EXPLICIT limits so the two protocols stay independently tunable. */
222
+ function normalizeWithLimits(raw, childAlive, limits) {
124
223
  const obj = (raw && typeof raw === 'object' ? raw : {});
125
224
  const action = typeof obj.action === 'string' && VALID_ACTIONS.has(obj.action)
126
225
  ? obj.action
@@ -129,7 +228,7 @@ export function normalizeOverseerDecision(raw, childAlive) {
129
228
  ? obj.reason.trim()
130
229
  : 'no reason given';
131
230
  const guidance = typeof obj.guidance === 'string' && obj.guidance.trim().length > 0
132
- ? clampChars(obj.guidance.trim(), TEAM_LIMITS.overseerGuidanceChars)
231
+ ? clampChars(obj.guidance.trim(), limits.guidanceChars)
133
232
  : undefined;
134
233
  const nextCheckMs = clampNextCheckMs(obj.nextCheckMs);
135
234
  if (!childAlive && (action === 'wait' || action === 'nudge')) {
@@ -140,30 +239,34 @@ export function normalizeOverseerDecision(raw, childAlive) {
140
239
  }
141
240
  return {
142
241
  action,
143
- reason: clampChars(reason, TEAM_LIMITS.overseerReasonChars),
242
+ reason: clampChars(reason, limits.reasonChars),
144
243
  ...(guidance !== undefined && action === 'nudge' ? { guidance } : {}),
145
244
  ...(action === 'wait' && nextCheckMs !== undefined ? { nextCheckMs } : {}),
146
245
  };
147
246
  }
148
247
  /**
149
- * Parse the model's answer text as one JSON decision object.
248
+ * Parse the model's answer text as a JSON ARRAY of per-child decisions (D-3).
150
249
  *
151
- * Forgiving on PURPOSE: models wrap JSON in prose and fences. The FIRST `{`
152
- * to the LAST `}` is the only span tried, and anything that does not parse is
153
- * `wait` — the caller never sees a throw from parsing.
250
+ * Same forgiving span rule as the single path - first `[` to last `]` - and
251
+ * the same failure semantics: a reply that is not an array (prose, a single
252
+ * object, or TRUNCATED output with no closing `]`) is `[]`, which the caller
253
+ * turns into all-wait with zero action side effects (R-P0-1 / AC-13). Never
254
+ * throws.
154
255
  */
155
- export function parseOverseerDecisionText(text, childAlive) {
156
- const first = text.indexOf('{');
157
- const last = text.lastIndexOf('}');
256
+ export function parseOverseerBatchText(text) {
257
+ const first = text.indexOf('[');
258
+ const last = text.lastIndexOf(']');
158
259
  if (first >= 0 && last > first) {
159
260
  try {
160
- return normalizeOverseerDecision(JSON.parse(text.slice(first, last + 1)), childAlive);
261
+ const parsed = JSON.parse(text.slice(first, last + 1));
262
+ if (Array.isArray(parsed))
263
+ return parsed;
161
264
  }
162
265
  catch {
163
- // Fall through to the safe default.
266
+ // Fall through to the empty batch.
164
267
  }
165
268
  }
166
- return waitDecision('decision unparseable; defaulting to wait');
269
+ return [];
167
270
  }
168
271
  const OVERSEER_SYSTEM_PROMPT = [
169
272
  'You are the supervisor of a team of subagents. You are shown ONE subagent',
@@ -183,6 +286,30 @@ const OVERSEER_SYSTEM_PROMPT = [
183
286
  'A nudge is only delivered after the child finishes its current tool call;',
184
287
  'a long-running tool is not a stall. Prefer the cheapest action that works.',
185
288
  ].join('\n');
289
+ const OVERSEER_BATCH_SYSTEM_PROMPT = [
290
+ 'You are the supervisor of a team of subagents. You are shown EVERY live',
291
+ 'subagent of one dispatch, separated by --- lines, on a periodic check.',
292
+ 'Decide ONCE PER SUBAGENT. Answer with ONE JSON array and nothing else -',
293
+ 'one object per subagent, using exactly the label shown:',
294
+ '[{"label": "<label>", "action": "wait" | "nudge" | "replace" | "abandon",',
295
+ ' "reason": "<= 80 chars", "guidance": "<= 400 chars, nudge only",',
296
+ ' "nextCheckMs": <ms, wait only>}, ...]',
297
+ 'Keep every reason and guidance SHORT: the whole array must fit a small',
298
+ 'output budget and a truncated array helps nobody.',
299
+ 'wait: the child looks healthy or is legitimately blocked (API backoff,',
300
+ ' waiting on a teammate or a human confirmation) - keep waiting, pick the',
301
+ ' next check delay.',
302
+ 'nudge: the child is stuck in a way a hint can fix - write the hint as',
303
+ ' guidance.',
304
+ 'replace: the child cannot recover - a fresh child will be built with your',
305
+ ' reason and guidance prepended to its brief.',
306
+ 'abandon: the task is not worth more attempts - the child is stopped and',
307
+ ' your reason is reported to the lead.',
308
+ 'You may use whole-team judgement: a child far behind healthy peers on the',
309
+ 'same brief is a different signal than one slow child alone.',
310
+ 'A nudge is only delivered after the child finishes its current tool call;',
311
+ 'a long-running tool is not a stall. Prefer the cheapest action that works.',
312
+ ].join('\n');
186
313
  function buildInspectionRequest(params) {
187
314
  return {
188
315
  model: params.modelId,
@@ -196,44 +323,94 @@ function buildInspectionRequest(params) {
196
323
  signal: params.signal,
197
324
  };
198
325
  }
326
+ function buildBatchRequest(params) {
327
+ return {
328
+ model: params.modelId,
329
+ ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
330
+ apiKey: params.apiKey,
331
+ systemPrompt: OVERSEER_BATCH_SYSTEM_PROMPT,
332
+ messages: [{ role: 'user', content: params.digest, timestamp: Date.now() }],
333
+ // NOT FAST_LIMITS.reviewOutputTokens: a ten-child array does not fit 512
334
+ // tokens, and a truncated reply parses to all-wait (R-P0-1 / AC-13).
335
+ maxTokens: TEAM_LIMITS.overseerBatchOutputTokens,
336
+ thinkingLevel: 'off',
337
+ temperature: 0,
338
+ signal: params.signal,
339
+ };
340
+ }
341
+ const NO_USAGE = { inputTokens: 0, outputTokens: 0 };
199
342
  /**
200
343
  * The fast-tier-backed provider, mirroring `createChildReviewer`'s shape so
201
344
  * the controller wires it the same way: shared transport, live predicates,
202
345
  * fail-soft (any failure resolves to `wait` — I-OV2).
346
+ *
347
+ * USAGE IS ACCUMULATED HERE (D-7), off every `AssistantMessage` the shared
348
+ * transport returns, along the same `usageOf` / `accumulateUsage` path the
349
+ * fast reviewer already proved. A reply without a usage block contributes
350
+ * nothing rather than a fabricated zero-cost total.
203
351
  */
204
352
  export function createFastOverseerProvider(deps) {
205
353
  const tier = () => resolveFastTier(deps.getConfig(), deps.hasKey);
354
+ let totalUsage = { ...NO_USAGE };
355
+ const call = async (build) => {
356
+ const resolved = tier();
357
+ if (!deps.available() || !resolved.ok)
358
+ return null;
359
+ const apiKey = deps.getApiKey(resolved.ref.providerId, 'fast');
360
+ if (apiKey === undefined)
361
+ return null;
362
+ const controller = new AbortController();
363
+ const timer = setTimeout(() => controller.abort(), FAST_LIMITS.reviewTimeoutMs);
364
+ try {
365
+ const message = await deps.complete(resolved.ref.providerId, build(resolved.ref, apiKey, controller.signal));
366
+ totalUsage = accumulateUsage(totalUsage, usageOf(message).usage);
367
+ return assistantText(message);
368
+ }
369
+ finally {
370
+ clearTimeout(timer);
371
+ }
372
+ };
206
373
  return {
207
374
  active: () => deps.available() && tier().ok,
208
375
  async inspect(req) {
209
- const resolved = tier();
210
- if (!deps.available() || !resolved.ok) {
211
- return waitDecision('fast tier unavailable; defaulting to wait');
212
- }
213
- const apiKey = deps.getApiKey(resolved.ref.providerId, 'fast');
214
- if (apiKey === undefined) {
215
- return waitDecision('no fast-tier key; defaulting to wait');
216
- }
217
- const controller = new AbortController();
218
- const timer = setTimeout(() => controller.abort(), FAST_LIMITS.reviewTimeoutMs);
219
376
  try {
220
- const message = await deps.complete(resolved.ref.providerId, buildInspectionRequest({
377
+ const text = await call((ref, apiKey, signal) => buildInspectionRequest({
221
378
  digest: req.digest,
222
- modelId: resolved.ref.modelId,
223
- ...(resolved.ref.baseUrl ? { baseUrl: resolved.ref.baseUrl } : {}),
379
+ modelId: ref.modelId,
380
+ ...(ref.baseUrl ? { baseUrl: ref.baseUrl } : {}),
224
381
  apiKey,
225
- signal: controller.signal,
382
+ signal,
226
383
  }));
227
- return parseOverseerDecisionText(assistantText(message), true);
384
+ if (text === null)
385
+ return waitDecision('fast tier unavailable; defaulting to wait');
386
+ return parseOverseerDecisionText(text, true);
228
387
  }
229
388
  catch {
230
389
  // INCLUDING a timeout abort: supervision failing is wait, never kill.
231
390
  return waitDecision('inspection call failed; defaulting to wait');
232
391
  }
233
- finally {
234
- clearTimeout(timer);
392
+ },
393
+ async inspectBatch(req) {
394
+ try {
395
+ // The joiner owns the WHOLE-BATCH ceiling (D-3): sections arrive
396
+ // per-child-clamped and label-sorted; `hardMaxSubagents` means the
397
+ // clamp is a guard, not a squeeze.
398
+ const digest = truncateBytes(req.map((r) => `### ${r.label}\n${r.digest}`).join('\n---\n'), TEAM_LIMITS.overseerBatchDigestBytes);
399
+ const text = await call((ref, apiKey, signal) => buildBatchRequest({
400
+ digest,
401
+ modelId: ref.modelId,
402
+ ...(ref.baseUrl ? { baseUrl: ref.baseUrl } : {}),
403
+ apiKey,
404
+ signal,
405
+ }));
406
+ return text ?? '';
407
+ }
408
+ catch {
409
+ // INCLUDING a timeout abort: supervision failing is wait, never kill.
410
+ return '';
235
411
  }
236
412
  },
413
+ usage: () => ({ ...totalUsage }),
237
414
  };
238
415
  }
239
416
  export const OVERSEER_DEFAULT_NEXT_CHECK_MS = TEAM_LIMITS.overseerDefaultCheckMs;
@@ -243,14 +420,27 @@ export const OVERSEER_DEFAULT_NEXT_CHECK_MS = TEAM_LIMITS.overseerDefaultCheckMs
243
420
  * THE CLASS NEVER TOUCHES A CHILD (I-OV1). It looks and decides; `runtime.ts`
244
421
  * steers, flags, aborts and rebuilds, because only the worker loop can apply
245
422
  * a lifecycle change without orphaning the replacement.
423
+ *
424
+ * LOOKS AND ACTIONS ARE SEPARATE LEDGERS (D-5). A LOOK is cost: every
425
+ * inspection of a child spends one, including a call that failed softly -
426
+ * which is what closes G-6 (a flaky transport used to burn the budget the
427
+ * same as a decision, and the supervisor went quiet exactly when the team
428
+ * was hardest to see). An ACTION is a mutation (nudge / replace / abandon)
429
+ * and is counted only when the runtime APPLIES it, through the `note*`
430
+ * methods below. A label skipped because an inspection is already in flight
431
+ * spends NOTHING (R-P1-4).
246
432
  */
247
433
  export class TeamOverseer {
248
434
  provider;
249
435
  options;
250
436
  memory = new Map();
251
- inspections = new Map();
437
+ looks = new Map();
438
+ nudges = new Map();
252
439
  replacements = new Map();
440
+ abandons = new Map();
253
441
  inFlight = new Set();
442
+ looksTotal = 0;
443
+ callCount = 0;
254
444
  constructor(provider, options) {
255
445
  this.provider = provider;
256
446
  this.options = options;
@@ -264,24 +454,62 @@ export class TeamOverseer {
264
454
  return false;
265
455
  }
266
456
  }
267
- inspectionsExhausted(label) {
268
- return (this.inspections.get(label) ?? 0) >= TEAM_LIMITS.overseerMaxInspectionsPerChild;
457
+ /** Whether ANY inspection of `label` is in flight (single or batch). */
458
+ isInFlight(label) {
459
+ return this.inFlight.has(label);
460
+ }
461
+ /** Per-child look budget spent (D-5). */
462
+ looksExhausted(label) {
463
+ return ((this.looks.get(label) ?? 0) >= TEAM_LIMITS.overseerMaxLooksPerChild ||
464
+ this.dispatchLooksExhausted());
465
+ }
466
+ /** The fool-proof dispatch-wide total (R-P1-6). */
467
+ dispatchLooksExhausted() {
468
+ return this.looksTotal >= TEAM_LIMITS.overseerMaxLooksPerDispatch;
469
+ }
470
+ nudgesLeft(label) {
471
+ return (this.nudges.get(label) ?? 0) < TEAM_LIMITS.overseerMaxNudgesPerChild;
269
472
  }
270
473
  replacementsLeft(label) {
271
474
  return (this.replacements.get(label) ?? 0) < TEAM_LIMITS.overseerMaxReplacementsPerChild;
272
475
  }
476
+ abandonsLeft(label) {
477
+ return (this.abandons.get(label) ?? 0) < TEAM_LIMITS.overseerMaxAbandonsPerChild;
478
+ }
479
+ /** Called by the runtime when it APPLIES a nudge, not when it merely reads one. */
480
+ noteNudge(label) {
481
+ this.nudges.set(label, (this.nudges.get(label) ?? 0) + 1);
482
+ }
273
483
  /** Called by the runtime when it APPLIES a replace, not when it merely reads one. */
274
484
  noteReplacement(label) {
275
485
  this.replacements.set(label, (this.replacements.get(label) ?? 0) + 1);
276
486
  }
487
+ /** Called by the runtime when it APPLIES an abandon, not when it merely reads one. */
488
+ noteAbandon(label) {
489
+ this.abandons.set(label, (this.abandons.get(label) ?? 0) + 1);
490
+ }
491
+ /** The provider's own accumulated inspection spend (D-7), zero when unreadable. */
492
+ usage() {
493
+ try {
494
+ return this.provider.usage();
495
+ }
496
+ catch {
497
+ return { ...NO_USAGE };
498
+ }
499
+ }
500
+ /** Provider invocations, including ones that failed softly (D-7). */
501
+ calls() {
502
+ return this.callCount;
503
+ }
277
504
  /**
278
- * ONE inspection. `null` means QUIET — not active, no budget left, or one
279
- * already in flight for this child — and the caller then does nothing,
505
+ * ONE single-child inspection (the silence-triggered path, round-1
506
+ * behaviour preserved). `null` means QUIET — not active, no budget left, or
507
+ * one already in flight for this child — and the caller then does nothing,
280
508
  * which is exactly what quiet should do.
281
509
  */
282
510
  async inspect(req) {
283
511
  const label = req.run.label;
284
- if (!this.isActive() || this.inspectionsExhausted(label) || this.inFlight.has(label)) {
512
+ if (!this.isActive() || this.looksExhausted(label) || this.inFlight.has(label)) {
285
513
  return null;
286
514
  }
287
515
  this.inFlight.add(label);
@@ -295,6 +523,7 @@ export class TeamOverseer {
295
523
  elapsedSec: Math.max(0, (now - (req.run.startedAt ?? now)) / 1000),
296
524
  });
297
525
  let decision;
526
+ this.callCount += 1;
298
527
  try {
299
528
  decision = await this.provider.inspect({
300
529
  label,
@@ -308,7 +537,75 @@ export class TeamOverseer {
308
537
  finally {
309
538
  this.inFlight.delete(label);
310
539
  }
311
- this.inspections.set(label, (this.inspections.get(label) ?? 0) + 1);
540
+ this.spendLook(label);
541
+ this.remember(label, now, decision);
542
+ return decision;
543
+ }
544
+ /**
545
+ * ONE batch inspection over every requested child (the cadence path, D-3):
546
+ * a single fast-tier call, a JSON array back, one decision per child.
547
+ *
548
+ * SKIPS FIRST, CALLS SECOND (R-P1-4): a label whose single inspection is
549
+ * still in flight, or whose look budget is spent, simply gets NO entry -
550
+ * no decision, no look charged, ladder untouched by the caller. A label
551
+ * that IS carried but whose reply entry is missing or garbage gets `wait`
552
+ * (repair, never reject). A wholly unparseable or truncated reply is ALL
553
+ * wait with zero action side effects (R-P0-1 / AC-13).
554
+ */
555
+ async inspectBatch(reqs) {
556
+ const eligible = reqs.filter((req) => !this.inFlight.has(req.run.label) && !this.looksExhausted(req.run.label));
557
+ if (eligible.length === 0 || !this.isActive())
558
+ return new Map();
559
+ const now = this.options.now?.() ?? Date.now();
560
+ const inputs = eligible.map((req) => ({
561
+ run: req.run,
562
+ childAlive: req.childAlive,
563
+ trigger: 'clock',
564
+ messages: req.messages,
565
+ memory: this.memory.get(req.run.label) ?? [],
566
+ elapsedSec: Math.max(0, (now - (req.run.startedAt ?? now)) / 1000),
567
+ }));
568
+ const sections = buildBatchSections(inputs);
569
+ for (const req of eligible)
570
+ this.inFlight.add(req.run.label);
571
+ this.callCount += 1;
572
+ let raw;
573
+ try {
574
+ raw = await this.provider.inspectBatch(sections);
575
+ }
576
+ catch {
577
+ raw = [];
578
+ }
579
+ finally {
580
+ for (const req of eligible)
581
+ this.inFlight.delete(req.run.label);
582
+ }
583
+ const entries = typeof raw === 'string' ? parseOverseerBatchText(raw) : Array.isArray(raw) ? raw : [];
584
+ const byLabel = new Map();
585
+ for (const entry of entries) {
586
+ const label = entry?.label;
587
+ if (typeof label === 'string' && label.length > 0 && !byLabel.has(label)) {
588
+ byLabel.set(label, entry);
589
+ }
590
+ }
591
+ const decisions = new Map();
592
+ for (const input of inputs) {
593
+ const label = input.run.label;
594
+ const decision = normalizeWithLimits(byLabel.get(label), input.childAlive, {
595
+ reasonChars: TEAM_LIMITS.overseerBatchReasonChars,
596
+ guidanceChars: TEAM_LIMITS.overseerBatchGuidanceChars,
597
+ });
598
+ this.spendLook(label);
599
+ this.remember(label, now, decision);
600
+ decisions.set(label, decision);
601
+ }
602
+ return decisions;
603
+ }
604
+ spendLook(label) {
605
+ this.looks.set(label, (this.looks.get(label) ?? 0) + 1);
606
+ this.looksTotal += 1;
607
+ }
608
+ remember(label, now, decision) {
312
609
  const entries = this.memory.get(label) ?? [];
313
610
  entries.push({
314
611
  atSec: Math.max(0, Math.round((now - this.options.startedAt) / 1000)),
@@ -316,7 +613,6 @@ export class TeamOverseer {
316
613
  reason: decision.reason,
317
614
  });
318
615
  this.memory.set(label, entries);
319
- return decision;
320
616
  }
321
617
  }
322
618
  // ---------------------------------------------------------------------------
@@ -28,6 +28,15 @@ export interface ReportOptions {
28
28
  * THE ONE TABLE, because every child runs the lead's own model
29
29
  * (main-agent parity): one tier, one price, one honest total. */
30
30
  cost?: ModelCost;
31
+ /**
32
+ * The FAST tier's cost table, so the supervisor's own spend line can
33
+ * state real spend (subagent-overseer-v2 D-7 / R-P1-2). Supplied by
34
+ * `TaskToolDeps.fastModelCost()`; ABSENT when the fast model is not
35
+ * priced, in which case the spend line says `pricing unknown` rather
36
+ * than `$0.00` - unknown pricing is not zero pricing (the C-11 / RV-4
37
+ * rule the fast tier already follows everywhere else).
38
+ */
39
+ fastCost?: ModelCost;
31
40
  }
32
41
  /**
33
42
  * Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
@@ -21,7 +21,9 @@
21
21
  * because of one HTTP 429.
22
22
  */
23
23
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
24
- import { TEAM_LIMITS } from './limits.js';
24
+ import { TEAM_AGGREGATE_LABEL, TEAM_LIMITS } from './limits.js';
25
+ /** The pseudo-label aggregate notices use; reserved at normalization. */
26
+ const SUPERVISOR_TEAM_LABEL = TEAM_AGGREGATE_LABEL;
25
27
  function byteLength(text) {
26
28
  return Buffer.byteLength(text, 'utf8');
27
29
  }
@@ -147,16 +149,42 @@ function buildSection(run) {
147
149
  * so the lead can see who was nudged or replaced, when, and why. Budgeted by
148
150
  * the same `reportMaxBytes` ladder as everything else - it renders as a block,
149
151
  * so trimming it means trimming the blocks, which the ladder already does.
152
+ *
153
+ * subagent-overseer-v2 appends three aggregates, each AT MOST ONCE (D-7 /
154
+ * D-10): the fast-tier spend line, a degraded-cadence note and a
155
+ * went-quiet note. Aggregates, never per-tick rows: a degraded dispatch
156
+ * ticks for hours and the report's job is the fact, not the log.
150
157
  */
151
- function buildOverseer(outcome) {
158
+ function buildOverseer(outcome, options) {
152
159
  const list = outcome.interventions ?? [];
153
- if (list.length === 0)
160
+ const calls = outcome.overseerCalls ?? 0;
161
+ const degraded = outcome.overseerDegraded === true;
162
+ // The pseudo-label aggregate (`team`) is a note, not a child's ledger row.
163
+ const quietChildren = list.filter((item) => item.trigger === 'quiet' && item.label !== SUPERVISOR_TEAM_LABEL).length;
164
+ if (list.length === 0 && calls === 0 && !degraded)
154
165
  return [];
155
166
  const lines = ['### Supervisor'];
156
167
  for (const item of list) {
157
168
  const at = Math.max(0, Math.round((item.at - outcome.startedAt) / 1000));
158
169
  lines.push(`[${item.trigger} ${at}s] ${item.label}: ${item.action} - ${item.reason}`);
159
170
  }
171
+ // Honest accounting (D-7): rendered only when calls were actually made,
172
+ // priced at the FAST table, `pricing unknown` when that table is absent.
173
+ if (calls > 0 && outcome.overseerUsage !== undefined) {
174
+ const price = options.fastCost
175
+ ? formatCost(computeCost(outcome.overseerUsage, options.fastCost))
176
+ : 'pricing unknown';
177
+ lines.push(`supervisor: ${calls} call${calls === 1 ? '' : 's'}, ${formatTokens(outcome.overseerUsage.inputTokens)} in / ${formatTokens(outcome.overseerUsage.outputTokens)} out tokens` +
178
+ ` (fast tier), ${price}.`);
179
+ }
180
+ if (degraded) {
181
+ lines.push(`note: the fast tier was unavailable for ${outcome.overseerDegradedTicks ?? 1}` +
182
+ ` cadence check(s); children waited unassisted and none were killed.`);
183
+ }
184
+ if (quietChildren > 0) {
185
+ lines.push(`note: supervision went quiet on ${quietChildren}` +
186
+ ` child${quietChildren === 1 ? '' : 'ren'} after its look budget.`);
187
+ }
160
188
  return lines;
161
189
  }
162
190
  function buildLeadMail(outcome) {
@@ -184,7 +212,7 @@ export function buildDispatchReport(outcome, options = {}) {
184
212
  const header = buildHeader(outcome, options);
185
213
  const sections = outcome.runs.map(buildSection);
186
214
  const mail = buildLeadMail(outcome);
187
- const overseer = buildOverseer(outcome);
215
+ const overseer = buildOverseer(outcome, options);
188
216
  const summaries = sections.map((s) => truncateBytes(s.summary, TEAM_LIMITS.summaryMaxBytes));
189
217
  const trimmed = summaries.map((s, i) => s.length < sections[i].summary.length);
190
218
  const render = (bodies) => {