@aeondave/pi-persona 1.17.1 → 1.17.4

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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -6,20 +6,21 @@
6
6
  *
7
7
  * NOTE: live *steering* of a running child-process agent (resume/redirect) needs
8
8
  * the cross-process broker (v0.5) or the InProcessEngine (v0.4). This module
9
- * covers launch + peek + completion-notify (the parts that work on `pi -p`).
9
+ * covers launch + peek + completion-notify.
10
10
  */
11
11
 
12
- import type { ProgressSnapshot } from "./stream.ts";
12
+ import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
13
13
  import type { AgentResult } from "../orchestration/types.ts";
14
- import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel } from "../core/display-label.ts";
14
+ import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
15
+ import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
16
+ import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
15
17
  import { compactTokens } from "../core/format.ts";
18
+ import { isThinkingLevel } from "../core/types.ts";
16
19
  import { formatDuration } from "../core/time.ts";
17
20
 
18
- export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
19
-
20
- /** "possibly stuck" — the soft stall signal shared by the supervisor alert and the UI badge. It is
21
- * deliberately patient: a long scan, a big generation, or a blocking command shows no visible
22
- * progress yet is perfectly healthy, so both surfaces flag a leg only after a genuinely long quiet
21
+ /** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
22
+ * badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
23
+ * visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
23
24
  * spell. Purely advisory (no auto-abort); the idle watchdog + token budget are the enforcing backstops. */
24
25
  export const STALL_FLAG_MS = 90_000;
25
26
 
@@ -32,6 +33,7 @@ export interface AsyncRun {
32
33
  status: "running" | "done" | "failed" | "stopped";
33
34
  progress: ProgressSnapshot;
34
35
  result?: AgentResult;
36
+ resultReceipt?: ResultReceipt;
35
37
  error?: string;
36
38
  /** The codename the launcher gave this run (e.g. "atlas-static") — the SAME name shown on the
37
39
  * agent-tree node for this run. When set, it's the canonical display name (over the bare
@@ -40,15 +42,16 @@ export interface AsyncRun {
40
42
  /** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
41
43
  * so this module needs no import from delegate.ts just to render it. */
42
44
  model?: string;
43
- /** Clock (ms) this run was launched at, and the clock it settled at — the pair the supervisor's
44
- * "that leg took 2m 5s" reading is computed from. Both come from the tracker's INJECTED clock,
45
+ /** Clock (ms) this run started executing (launch, or admission for a queued one), and the clock it
46
+ * settled at — the pair the supervisor's "that leg took 2m 5s" reading is computed from. Both
47
+ * come from the tracker's INJECTED clock,
45
48
  * so a test can settle a two-hour run without waiting. Optional because runs also reach the
46
49
  * renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
47
50
  startedAt?: number;
48
51
  settledAt?: number;
49
- /** Clock (ms) of the last time `progress` actually *advanced* (output grew, or turns/tokens
50
- * rose) — NOT merely the last event. A worker looping without headway keeps emitting but
51
- * never bumps this, so `now - lastAdvanceAt` is what tells "working" from "stuck". */
52
+ /** Clock (ms) of the last time the run made {@link madeHeadway} — NOT merely the last event. A worker
53
+ * looping without headway, or a tool streaming output forever, keeps emitting but never bumps this,
54
+ * so `now - lastAdvanceAt` is what tells "working" from "stuck". */
52
55
  lastAdvanceAt?: number;
53
56
  /** Waiting for semaphore admission; queued time is not agent execution time. */
54
57
  queued?: boolean;
@@ -65,6 +68,36 @@ export interface AsyncRun {
65
68
  * run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
66
69
  * performed after launch() returns would re-insert an already-dead run and leak it. */
67
70
  mutates?: boolean;
71
+ /** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
72
+ * last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
73
+ batch?: string;
74
+ /** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
75
+ * digest instead of a stall verdict, because silence while a human decides is not a wedge. */
76
+ awaiting?: string;
77
+ }
78
+
79
+ /** What {@link madeHeadway} reads of one progress reading: a tracker snapshot, or a leg's `AgentProgress`
80
+ * (which carries no turns). */
81
+ export interface ProgressReading {
82
+ output?: string | undefined;
83
+ tokens?: number | undefined;
84
+ turns?: number | undefined;
85
+ toolEvent?: ToolEvent | undefined;
86
+ }
87
+
88
+ /**
89
+ * The ONE rule for "this run advanced": the assistant produced more (output grew, tokens or turns rose) or a
90
+ * tool call just COMPLETED. Raw tool output is deliberately not headway - a straggler that streams text
91
+ * forever (`npm run dev`, `--watch`, `tail -f`) must still read as stalled, so the operator-only card can
92
+ * flag it. Nothing is killed and the model is not woken; a legitimately noisy tool earns an advisory only.
93
+ * A completion counts once: the transient `toolEvent` rides the one snapshot it was emitted on, so
94
+ * replaying that snapshot credits nothing. Pure.
95
+ */
96
+ export function madeHeadway(prev: ProgressReading | undefined, next: ProgressReading): boolean {
97
+ return (next.output?.length ?? 0) > (prev?.output?.length ?? 0)
98
+ || (next.tokens ?? 0) > (prev?.tokens ?? 0)
99
+ || (next.turns ?? 0) > (prev?.turns ?? 0)
100
+ || (next.toolEvent?.phase === "end" && next.toolEvent !== prev?.toolEvent);
68
101
  }
69
102
 
70
103
  /** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
@@ -83,19 +116,31 @@ export class AsyncRunTracker {
83
116
  /** Ids whose payload was evicted while STILL UNCOLLECTED, oldest first (insertion-ordered Set). */
84
117
  private readonly droppedIds = new Set<string>();
85
118
  private readonly completeListeners: Array<(run: AsyncRun) => void> = [];
119
+ private readonly createListeners: Array<(run: AsyncRun) => void> = [];
86
120
  private seq = 0;
87
121
  private readonly maxRetained: number;
88
122
  private readonly now: () => number;
89
123
 
90
124
  /** `now` is injected so the stall clock is deterministic under test (real time by default).
91
- * `maxRetained` (default 25 — today's hardcoded bound) is the config-driven PI_PERSONA_ASYNC_RETAIN
92
- * knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
125
+ * `maxRetained` (default {@link DEFAULT_ASYNC_RETAIN}; the extension passes `config.asyncRetain`) is the config-driven
126
+ * PI_PERSONA_ASYNC_RETAIN knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
93
127
  constructor(opts?: { now?: () => number; maxRetained?: number }) {
94
128
  this.now = opts?.now ?? ((): number => Date.now());
95
- this.maxRetained = opts?.maxRetained ?? 25;
129
+ this.maxRetained = opts?.maxRetained ?? DEFAULT_ASYNC_RETAIN;
130
+ }
131
+
132
+ /** How many run ids this lineage has issued: what a restored instance must continue from. */
133
+ get issued(): number {
134
+ return this.seq;
96
135
  }
97
136
 
98
- launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean }, run: RunThunk): string {
137
+ /** Continue the id counter from a previous instance of the same session so ids never repeat within
138
+ * one history. Monotonic: anything that would rewind it, or is not a whole number, is ignored. */
139
+ continueFrom(issued: number): void {
140
+ if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
141
+ }
142
+
143
+ launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
99
144
  this.seq += 1;
100
145
  const id = `run-${this.seq.toString(36)}`;
101
146
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -109,12 +154,16 @@ export class AsyncRunTracker {
109
154
  ...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
110
155
  };
111
156
  if (meta.mutates) entry.mutates = true;
157
+ if (meta.batch) entry.batch = meta.batch;
112
158
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
113
159
  // once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
114
160
  // those views; renderers still sanitize defensively when they compose a label.
115
161
  if (meta.label !== undefined) entry.label = sanitizeDisplayLabel(meta.label);
116
162
  if (meta.model !== undefined) entry.model = sanitizeDisplayLabel(meta.model, "model");
117
163
  this.runs.set(id, entry);
164
+ for (const cb of [...this.createListeners]) {
165
+ try { cb(entry); } catch { /* archival observation cannot prevent launch */ }
166
+ }
118
167
 
119
168
  const settle = (): void => this.settleOnce(entry);
120
169
  // `Promise.resolve(run(...))` routes BOTH a returned rejection AND a SYNCHRONOUS throw from
@@ -124,21 +173,17 @@ export class AsyncRunTracker {
124
173
  try {
125
174
  Promise.resolve(
126
175
  run((s) => {
127
- // Newly emitted textual tool output is a progress advance. Use the injected clock,
128
- // never the stream's event timestamp, for stall-age accounting.
129
- const prev = entry.progress;
130
- const toolOutputAdvanced = s.toolOutputAt !== undefined && Number.isFinite(s.toolOutputAt)
131
- && s.toolOutputAt >= 0 && s.toolOutputAt !== prev.toolOutputAt;
132
- if (!entry.queued && (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns || toolOutputAdvanced)) {
133
- entry.lastAdvanceAt = this.now();
134
- }
176
+ // The injected clock stamps the advance, never an event's own timestamp.
177
+ if (!entry.queued && madeHeadway(entry.progress, s)) entry.lastAdvanceAt = this.now();
135
178
  entry.progress = s;
136
179
  }, id),
137
180
  )
138
181
  .then((result) => {
139
182
  if (entry.settled) return; // force-settled by the supervisor — drop the late natural result
140
183
  entry.status = result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed";
141
- entry.result = result;
184
+ // Retain scalar metadata by value: an engine reusing/mutating its result object
185
+ // after settlement must not rewrite this run's requested/observed effort.
186
+ entry.result = { ...result, usage: { ...result.usage }, ...(result.resultReceipt ? { resultReceipt: { ...result.resultReceipt } } : {}) };
142
187
  if (!result.ok && result.error) entry.error = result.error;
143
188
  })
144
189
  .catch((err: unknown) => {
@@ -167,8 +212,8 @@ export class AsyncRunTracker {
167
212
  entry.settledAt = this.now();
168
213
  this.settledOrder.push(entry.id);
169
214
  this.prune();
170
- // Iterate a SNAPSHOT: a waitFor join unsubscribes from inside its own listener, and
171
- // splicing the live array under the cursor would shift the next join's listener past it.
215
+ // Iterate a SNAPSHOT: a listener may unsubscribe from inside its own callback, and
216
+ // splicing the live array under the cursor would shift the next listener past it.
172
217
  // A listener's throw must likewise not skip the ones after it — and on the natural-settle
173
218
  // path this runs inside an unobserved promise chain, where an escaping throw becomes a
174
219
  // process-killing unhandled rejection.
@@ -183,15 +228,36 @@ export class AsyncRunTracker {
183
228
  }
184
229
  }
185
230
 
186
- /** Start the stall window when a queued run actually acquires its concurrency slot. */
231
+ /** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
187
232
  markAdmitted(id: string): boolean {
188
233
  const entry = this.runs.get(id);
189
234
  if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
190
235
  delete entry.queued;
191
- entry.lastAdvanceAt = this.now();
236
+ entry.startedAt = entry.lastAdvanceAt = this.now();
237
+ return true;
238
+ }
239
+
240
+ /** Mark a running run as waiting on the operator (`label`), or release it (`undefined`). The clock
241
+ * restarts on release, so the human's think time is never counted as the run's own silence. */
242
+ setAwaiting(id: string, label: string | undefined): boolean {
243
+ const entry = this.runs.get(id);
244
+ if (!entry || entry.status !== "running" || entry.settled) return false;
245
+ if (label === undefined) {
246
+ delete entry.awaiting;
247
+ this.advance(id);
248
+ } else {
249
+ entry.awaiting = sanitizeStatusLabel(label, "waiting for operator", 120);
250
+ }
192
251
  return true;
193
252
  }
194
253
 
254
+ /** Credit headway to a running run whose own snapshot cannot show it: a council or flow aggregates its
255
+ * legs' progress, so one leg's advance is invisible in the sum. Queued, settled and unknown runs are left alone. */
256
+ advance(id: string): void {
257
+ const entry = this.runs.get(id);
258
+ if (entry && entry.status === "running" && !entry.settled && !entry.queued) entry.lastAdvanceAt = this.now();
259
+ }
260
+
195
261
  /**
196
262
  * Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
197
263
  * for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
@@ -280,6 +346,22 @@ export class AsyncRunTracker {
280
346
  return this.list().filter((r) => r.status === "running");
281
347
  }
282
348
 
349
+ /** True when `run` may be reported: it belongs to no batch, or no member of its batch is still
350
+ * running. Asked of the live tracker, never counted from a delivered set, so a member collected
351
+ * early (`intercom result`), stopped, or failed inside launch() can never keep its batch waiting. */
352
+ batchSettled(run: AsyncRun): boolean {
353
+ const batch = run.batch;
354
+ return batch === undefined || !this.running().some((r) => r.batch === batch);
355
+ }
356
+
357
+ onCreate(cb: (run: AsyncRun) => void): () => void {
358
+ this.createListeners.push(cb);
359
+ return () => {
360
+ const i = this.createListeners.indexOf(cb);
361
+ if (i >= 0) this.createListeners.splice(i, 1);
362
+ };
363
+ }
364
+
283
365
  onComplete(cb: (run: AsyncRun) => void): () => void {
284
366
  this.completeListeners.push(cb);
285
367
  return () => {
@@ -287,54 +369,27 @@ export class AsyncRunTracker {
287
369
  if (i >= 0) this.completeListeners.splice(i, 1);
288
370
  };
289
371
  }
290
-
291
- /**
292
- * Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
293
- * it resolves with the runs' CURRENT states, so the caller reports what settled and
294
- * what is still going. Backs the `intercom wait` action (the supervisor holds its turn
295
- * to collect async results it now needs before its next step).
296
- */
297
- waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
298
- const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
299
- if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
300
- return new Promise((resolve) => {
301
- let off = (): void => {};
302
- let timer: ReturnType<typeof setTimeout> | undefined;
303
- const finish = (): void => {
304
- off();
305
- if (timer) clearTimeout(timer);
306
- signal?.removeEventListener("abort", finish);
307
- resolve(targets);
308
- };
309
- off = this.onComplete(() => {
310
- if (targets.every((r) => r.status !== "running")) finish();
311
- });
312
- timer = setTimeout(finish, timeoutMs);
313
- timer.unref?.();
314
- if (signal) {
315
- if (signal.aborted) finish();
316
- else signal.addEventListener("abort", finish, { once: true });
317
- }
318
- });
319
- }
320
372
  }
321
373
 
322
- /**
323
- * A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
324
- * routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
325
- * window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
326
- * periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
327
- */
374
+ /** First occurrence of each run id, in order. */
328
375
  export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
329
376
  const byId = new Map<string, AsyncRun>();
330
377
  for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
331
378
  return [...byId.values()];
332
379
  }
333
380
 
381
+ /** A terminal engine observation supersedes the launch choice; running/launch rows retain their snapshot. */
382
+ export function observedRunModel(run: Partial<Pick<AsyncRun, "status" | "result">>): string | undefined {
383
+ const model = run.status !== undefined && run.status !== "running" ? run.result?.modelUsed : undefined;
384
+ return model?.trim() ? model.split("/").map((part) => sanitizeDisplayLabel(part, "model", 256)).join("/").slice(0, 256) : undefined;
385
+ }
386
+
334
387
  /** One identity everywhere a tracked run is rendered: the launcher's codename when present,
335
- * otherwise the agent type, with the already-shortened model appended once. */
336
- export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
337
- const model = run.model === undefined ? undefined : sanitizeDisplayLabel(run.model, "model", 24);
388
+ * otherwise the agent type, with the observed terminal model or launch model appended once. */
389
+ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model"> & Partial<Pick<AsyncRun, "status" | "result">>): string {
390
+ const observed = observedRunModel(run);
391
+ const selected = observed ? (observed.split("/").pop() ?? observed).replace(/^claude-/, "") : run.model;
392
+ const model = selected === undefined ? undefined : sanitizeDisplayLabel(selected, "model", 24);
338
393
  if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
339
394
  const suffix = ` · ${model}`;
340
395
  const nameLimit = Math.max(1, MAX_DISPLAY_LABEL_CHARS - suffix.length);
@@ -346,13 +401,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
346
401
  *
347
402
  * Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
348
403
  * came back in seconds or ground for two hours changes what to do next — and no completion surface
349
- * used to say it. Undefined (rather than "?" or "0s") when the pair is missing or inverted, so an
404
+ * used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
350
405
  * unstamped run renders exactly as it did before instead of gaining a fabricated reading.
351
406
  */
352
407
  export function runDurationLabel(run: AsyncRun): string | undefined {
353
408
  const { startedAt, settledAt } = run;
354
- if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
355
- return formatDuration(settledAt - startedAt);
409
+ if (startedAt === undefined || settledAt === undefined) return undefined;
410
+ const span = settledAt - startedAt;
411
+ return Number.isFinite(span) && span >= 0 ? formatDuration(span) : undefined;
356
412
  }
357
413
 
358
414
  /** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
@@ -404,20 +460,6 @@ export function boundCompletionSurface(
404
460
 
405
461
  const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
406
462
 
407
- /** A timeout is a status update, not an invitation to poll. The passive completion notifier is
408
- * the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
409
- * for intervention. */
410
- export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
411
- const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
412
- const omitted = ids.length - visibleIds.length;
413
- const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
414
- return (
415
- `⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
416
- "Continue useful supervisor work; completion will notify you automatically. " +
417
- "Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
418
- );
419
- }
420
-
421
463
  /**
422
464
  * The explicit overflow signal for the one case retention cannot cover: more runs settled than the
423
465
  * tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
@@ -464,6 +506,12 @@ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number):
464
506
  && now - run.lastAdvanceAt >= stallMs;
465
507
  }
466
508
 
509
+ /**
510
+ * A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
511
+ * routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
512
+ * window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
513
+ * periodic stall check never wakes the model with this digest; it files it as an operator-only card.
514
+ */
467
515
  export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
468
516
  if (runs.length === 0) return "No async runs.";
469
517
  const running = runs.filter((r) => r.status === "running").length;
@@ -478,8 +526,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
478
526
  const name = runDisplayName(r);
479
527
  const head = `[${r.id}] ${name} — ${r.status}`;
480
528
  if (r.status === "running") {
481
- let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
529
+ // A tracked council/flow reports tokens only (its legs carry no turn count): "0 turns" would be false.
530
+ let line = `${head} (${r.progress.turns > 0 ? `${r.progress.turns} turns, ` : ""}${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
531
+ // Queued time is not execution time, so a queued run claims no "running for".
532
+ const ran = r.queued ? undefined : runDurationLabel({ ...r, ...(now !== undefined ? { settledAt: now } : {}) });
533
+ if (ran) line += ` · running for ${ran}`;
482
534
  if (r.queued) line += " · queued (waiting for concurrency slot)";
535
+ else if (r.awaiting) line += ` · ${r.awaiting}`;
483
536
  else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
484
537
  else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
485
538
  const stalledFor = now - r.lastAdvanceAt!;
@@ -504,7 +557,7 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
504
557
 
505
558
  /**
506
559
  * Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
507
- * been surfaced as stalled, so the supervisor is woken ONCE when a leg crosses the stall window — never
560
+ * been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
508
561
  * every tick while it stays stalled, and never at all while it is making progress. A leg that advances
509
562
  * again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
510
563
  * forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
@@ -512,20 +565,6 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
512
565
  export class PeekWatcher {
513
566
  /** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
514
567
  private readonly reportedAt = new Map<string, number>();
515
- /** Runs whose one lifetime model stall wake was successfully delivered. */
516
- private readonly modelWoken = new Set<string>();
517
-
518
- firstWakeCandidates(runs: AsyncRun[]): AsyncRun[] {
519
- return runs.filter((run) => !this.modelWoken.has(run.id));
520
- }
521
-
522
- acknowledgeModelWake(runIds: readonly string[]): void {
523
- for (const id of runIds) this.modelWoken.add(id);
524
- }
525
-
526
- retryReports(runs: readonly AsyncRun[]): void {
527
- for (const run of runs) this.reportedAt.delete(run.id);
528
- }
529
568
 
530
569
  /** The running legs that have NEWLY crossed the stall window since we last reported them. */
531
570
  poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
@@ -538,8 +577,8 @@ export class PeekWatcher {
538
577
  this.reportedAt.delete(r.id);
539
578
  continue;
540
579
  }
541
- if (waitingForSupervisor.has(r.id)) {
542
- this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate immediately when it settles
580
+ if (waitingForSupervisor.has(r.id) || r.awaiting) {
581
+ this.reportedAt.delete(r.id); // a live ask or an operator decision is not a stall; re-evaluate when it settles
543
582
  continue;
544
583
  }
545
584
  if (r.lastAdvanceAt === undefined) continue;
@@ -557,35 +596,9 @@ export class PeekWatcher {
557
596
  /** Forget all tracked state (session start / reload hygiene). */
558
597
  reset(): void {
559
598
  this.reportedAt.clear();
560
- this.modelWoken.clear();
561
599
  }
562
600
  }
563
601
 
564
- /**
565
- * The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
566
- * when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
567
- * signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
568
- * patience-first, and intervention means asking the leg — never reaching into its environment to check.
569
- */
570
- export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
571
- if (stuck.length === 0) return "";
572
- const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
573
- const lines = visibleStuck.map((r) => {
574
- const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
575
- return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
576
- });
577
- const omitted = stuck.length - visibleStuck.length;
578
- if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
579
- lines.push(PROGRESS_TOKEN_NOTE);
580
- return [
581
- `${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
582
- ...lines,
583
- "A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
584
- "If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
585
- "wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
586
- ].join("\n");
587
- }
588
-
589
602
  /** The operator-only periodic status card. It carries the progress digest without model instructions. */
590
603
  export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
591
604
  const reason = opts.reason ?? "routine";
@@ -597,20 +610,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
597
610
  * burst into a single message is what keeps completions from piling up as separate queued
598
611
  * follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
599
612
  * supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
600
- * sub-agent text so the supervisor treats it as data, never as instructions.
613
+ * sub-agent text so the supervisor treats it as data, never as instructions. `bounded: false` lifts
614
+ * every cap, for the headless inline return where the caller is parked in the call and nothing is injected.
601
615
  */
602
- export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
616
+ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
603
617
  const done = runs.filter((r) => r.status === "done");
604
618
  const failed = runs.filter((r) => r.status === "failed");
605
619
  const stopped = runs.filter((r) => r.status === "stopped");
606
620
  // Share the notification budget across every leg that can contribute payload. This keeps a
607
621
  // large fan-out fair: the first result cannot consume the whole follow-up while later statuses
608
622
  // are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
623
+ const bounded = opts.bounded !== false;
624
+ const cap = (chars: number): number => (bounded ? chars : Number.POSITIVE_INFINITY);
609
625
  const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
610
- const fairRunChars = Math.min(
626
+ const fairRunChars = cap(Math.min(
611
627
  MAX_COMPLETION_RUN_CHARS,
612
628
  Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
613
- );
629
+ ));
630
+ // Each failed leg is clipped on its own line, from a budget of its own: every id and cause stays
631
+ // listed however wide the fan-out, and the drill-down names a real run.
632
+ const failureChars = cap(Math.min(
633
+ MAX_COMPLETION_RUN_CHARS,
634
+ Math.max(200, Math.floor(MAX_COMPLETION_REPORT_CHARS / (4 * Math.max(1, failed.length)))),
635
+ ));
614
636
  // Failures are always reported — the supervisor must know so it can adjust or tell
615
637
  // the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
616
638
  // (an identical delegation that failed twice is vetoed before it spawns), not by
@@ -619,6 +641,25 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
619
641
  const stoppedCount = stopped.length > 0 ? `, ${stopped.length} stopped` : "";
620
642
  const head = `[pi-persona] ${runs.length} async run${runs.length === 1 ? "" : "s"} settled — ${done.length} done, ${failed.length} failed${stoppedCount}`;
621
643
  const blocks: string[] = [head];
644
+ const models = runs.flatMap((run) => {
645
+ const model = observedRunModel(run);
646
+ return model ? [`${run.id}: Observed model: ${model}`] : [];
647
+ });
648
+ if (models.length > 0) blocks.push(`\n${models.join("\n")}`);
649
+ const receipts = runs.slice(0, MAX_ASYNC_STATUS_ROWS).flatMap((run) => {
650
+ const hint = resultReceiptHint(run.resultReceipt);
651
+ return hint ? [`${run.id}: ${hint}`] : [];
652
+ });
653
+ if (receipts.length > 0) blocks.push(`\nTerminal reports (intercom results lists all retained references):\n${receipts.join("\n")}`);
654
+ // Common result metadata, outside untrusted worker prose, on inline and background paths.
655
+ // A host effort observation is a Pi request setting, not a measure of provider cognition.
656
+ const efforts = runs.flatMap((r) => {
657
+ const requested = r.result?.thinkingRequested;
658
+ const observed = r.result?.thinkingEffective;
659
+ if (!isThinkingLevel(requested) && !isThinkingLevel(observed)) return [];
660
+ return [`${r.id}: requested ${isThinkingLevel(requested) ? requested : "unknown"}; observed Pi effort ${isThinkingLevel(observed) ? observed : "unknown"}`];
661
+ });
662
+ if (efforts.length > 0) blocks.push(`\nThinking (not provider cognition):\n${efforts.join("\n")}`);
622
663
  for (const r of done) {
623
664
  // Only fence REAL output — an empty/whitespace result gets a plain "(no output)", never an
624
665
  // empty <fence></fence> shell (there's nothing untrusted to guard, and the empty block reads
@@ -640,9 +681,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
640
681
  }
641
682
  }
642
683
  if (failed.length > 0) {
643
- const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
684
+ const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${clipRunOutput(r.error ?? "(no detail)", r.id, failureChars, "failure detail")}`).join("\n");
644
685
  blocks.push(
645
- `\n❌ ${failed.length} failed:\n${fence(clipRunOutput(reasons, "failures", fairRunChars))}\n\n` +
686
+ `\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
646
687
  "Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
647
688
  "If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
648
689
  "Do not re-issue the same failing delegation repeatedly.",
@@ -656,17 +697,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
656
697
  }
657
698
  }
658
699
  }
659
- return boundCompletionSurface(blocks.join("\n"));
700
+ const report = blocks.join("\n");
701
+ if (!bounded) return report;
702
+ // The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
703
+ // cut keeps, so a cut leg is still fetchable by id instead of silently missing.
704
+ const named = report.length > MAX_COMPLETION_REPORT_CHARS
705
+ ? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}. Saved reports: intercom { action:"results" }; use result { ref, offset, limit } even if live handles were dropped.`
706
+ : report;
707
+ return boundCompletionSurface(named);
660
708
  }
661
709
 
662
710
  /**
663
711
  * The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
664
- * carries a surrender marker, `scan` returns the note to append (else undefined). BOTH the background
665
- * completion notifier and the `intercom wait` join render through this, so a blocked leg gets the
666
- * same counterweight however it is collected — the sync `tool_result` hook never sees a background
667
- * run. Only DONE legs are scanned: a FAILED leg is already surfaced as a failure by
668
- * {@link buildCompletionReport}, so the guard and the append agree (no done-vs-all mismatch). `scan`
669
- * is injected (PersistenceNudge.scan) to keep this module decoupled from the nudge.
712
+ * carries a surrender marker, `scan` returns the note to append (else undefined). The background
713
+ * completion notifier renders through this, so a blocked leg gets the counterweight on the
714
+ * automatic follow-up — the sync `tool_result` hook never sees a background run. Only DONE legs
715
+ * are scanned: a FAILED leg is already surfaced as a failure by {@link buildCompletionReport},
716
+ * so the guard and the append agree (no done-vs-all mismatch). `scan` is injected
717
+ * (PersistenceNudge.scan) to keep this module decoupled from the nudge.
670
718
  */
671
719
  export function renderCompletion(
672
720
  runs: AsyncRun[],
@@ -691,9 +739,6 @@ export const MAX_EMPTY_RENDER_RETRIES = 3;
691
739
  export interface IdleNotifierDeps<T> {
692
740
  /** Whether the supervisor is idle (not streaming a turn). */
693
741
  isIdle: () => boolean;
694
- /** Legacy host-queue probe. Retained for source compatibility, but deliberately ignored: when
695
- * the supervisor is idle, starting a turn is what drains any orphaned host follow-ups. */
696
- hasPending?: () => boolean;
697
742
  /** Deliver the coalesced message. Async rejection is treated exactly like a synchronous throw:
698
743
  * the batch is restored at the front of the queue and retried. */
699
744
  deliver: (message: string) => void | Promise<void>;
@@ -720,6 +765,10 @@ export interface IdleNotifierDeps<T> {
720
765
  * drain keeps pace with small items instead of being pinned to the worst case. Either form is
721
766
  * clamped to at least one item: a queue must always drain. */
722
767
  maxBatchItems?: number | ((pending: readonly T[]) => number);
768
+ /** Whether a buffered item may be delivered yet. A held item stays buffered (peekable, discardable)
769
+ * and is not polled: it ships on the first flush after the `notify()`/`kick()` that made it ready.
770
+ * Unset ⇒ everything is ready. */
771
+ isReady?: (item: T) => boolean;
723
772
  /** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
724
773
  now?: () => number;
725
774
  }
@@ -728,7 +777,8 @@ export interface IdleNotifierDeps<T> {
728
777
  * Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
729
778
  * A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
730
779
  * Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
731
- * Used for async-run completions and child intercom asks.
780
+ * Used for async-run completions (a batch's members held until the last one settles) and child
781
+ * intercom asks.
732
782
  */
733
783
  export class IdleCoalescingNotifier<T> {
734
784
  private readonly deps: IdleNotifierDeps<T>;
@@ -784,15 +834,15 @@ export class IdleCoalescingNotifier<T> {
784
834
  }
785
835
 
786
836
  /** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
787
- * follow-up hasn't flushed yet). Lets `intercom wait`/`peek` surface results in the
788
- * settle→deliver gap instead of reporting nothing; the caller `discard`s what it collects so
789
- * the pending flush won't re-deliver it. */
837
+ * follow-up hasn't flushed yet). Lets `intercom peek` surface results in the
838
+ * settle→deliver gap instead of reporting nothing. Peeking does not collect: only an explicit
839
+ * `result` `discard`s the item so the pending flush won't re-deliver it. */
790
840
  peekPending(): T[] {
791
841
  return [...this.pending];
792
842
  }
793
843
 
794
844
  /** Drop buffered items matching `pred` — e.g. results the supervisor already collected
795
- * synchronously (intercom `wait`), which must not be re-delivered as a follow-up. */
845
+ * explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
796
846
  discard(pred: (item: T) => boolean): void {
797
847
  for (let i = this.pending.length - 1; i >= 0; i--) {
798
848
  if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
@@ -816,7 +866,8 @@ export class IdleCoalescingNotifier<T> {
816
866
  flushIfIdle(): Promise<void> {
817
867
  if (this.flushing) return this.flushing;
818
868
  this.clearArmedTimer();
819
- if (this.pending.length === 0) return Promise.resolve();
869
+ const ready = this.readyItems();
870
+ if (ready.length === 0) return Promise.resolve();
820
871
 
821
872
  const generation = this.generation;
822
873
  let batch: T[] | undefined;
@@ -841,8 +892,9 @@ export class IdleCoalescingNotifier<T> {
841
892
  }
842
893
  }
843
894
 
844
- const take = this.maxBatchItems === undefined ? this.pending.length : Math.max(1, Math.floor(this.maxBatchItems(this.pending)));
845
- batch = this.pending.splice(0, take);
895
+ const take = this.maxBatchItems === undefined ? ready.length : Math.max(1, Math.floor(this.maxBatchItems(ready)));
896
+ batch = ready.slice(0, take);
897
+ for (const item of batch) this.pending.splice(this.pending.indexOf(item), 1);
846
898
  const message = this.deps.render(batch);
847
899
  if (!message) {
848
900
  // Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
@@ -917,8 +969,14 @@ export class IdleCoalescingNotifier<T> {
917
969
  this.handle = undefined;
918
970
  }
919
971
 
972
+ /** The buffered items that may be delivered now, in order. */
973
+ private readyItems(): T[] {
974
+ const { isReady } = this.deps;
975
+ return isReady ? this.pending.filter(isReady) : [...this.pending];
976
+ }
977
+
920
978
  private arm(ms: number): void {
921
- if (this.handle !== undefined || this.pending.length === 0) return;
979
+ if (this.handle !== undefined || this.readyItems().length === 0) return;
922
980
  let scheduled!: unknown;
923
981
  scheduled = this.deps.setTimer(() => {
924
982
  if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback