@aeondave/pi-persona 1.17.0 → 1.17.3

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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  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 +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -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 { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
15
+ import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
15
16
  import { compactTokens } from "../core/format.ts";
16
17
  import { formatDuration } from "../core/time.ts";
17
18
 
18
19
  export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
19
20
 
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
 
@@ -40,16 +41,19 @@ export interface AsyncRun {
40
41
  /** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
41
42
  * so this module needs no import from delegate.ts just to render it. */
42
43
  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,
44
+ /** Clock (ms) this run started executing (launch, or admission for a queued one), and the clock it
45
+ * settled at — the pair the supervisor's "that leg took 2m 5s" reading is computed from. Both
46
+ * come from the tracker's INJECTED clock,
45
47
  * so a test can settle a two-hour run without waiting. Optional because runs also reach the
46
48
  * renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
47
49
  startedAt?: number;
48
50
  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". */
51
+ /** Clock (ms) of the last time the run made {@link madeHeadway} — NOT merely the last event. A worker
52
+ * looping without headway, or a tool streaming output forever, keeps emitting but never bumps this,
53
+ * so `now - lastAdvanceAt` is what tells "working" from "stuck". */
52
54
  lastAdvanceAt?: number;
55
+ /** Waiting for semaphore admission; queued time is not agent execution time. */
56
+ queued?: boolean;
53
57
  /** Internal bookkeeping: true once the completion listeners have fired for this entry. Guards a
54
58
  * force-settle racing the run thunk's natural resolution so listeners fire exactly once. Lives on
55
59
  * the entry (not a side Set) so it survives pruning and the `launch` closure still sees it. */
@@ -63,6 +67,36 @@ export interface AsyncRun {
63
67
  * run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
64
68
  * performed after launch() returns would re-insert an already-dead run and leak it. */
65
69
  mutates?: boolean;
70
+ /** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
71
+ * last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
72
+ batch?: string;
73
+ /** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
74
+ * digest instead of a stall verdict, because silence while a human decides is not a wedge. */
75
+ awaiting?: string;
76
+ }
77
+
78
+ /** What {@link madeHeadway} reads of one progress reading: a tracker snapshot, or a leg's `AgentProgress`
79
+ * (which carries no turns). */
80
+ export interface ProgressReading {
81
+ output?: string | undefined;
82
+ tokens?: number | undefined;
83
+ turns?: number | undefined;
84
+ toolEvent?: ToolEvent | undefined;
85
+ }
86
+
87
+ /**
88
+ * The ONE rule for "this run advanced": the assistant produced more (output grew, tokens or turns rose) or a
89
+ * tool call just COMPLETED. Raw tool output is deliberately not headway - a straggler that streams text
90
+ * forever (`npm run dev`, `--watch`, `tail -f`) must still read as stalled, so the operator-only card can
91
+ * flag it. Nothing is killed and the model is not woken; a legitimately noisy tool earns an advisory only.
92
+ * A completion counts once: the transient `toolEvent` rides the one snapshot it was emitted on, so
93
+ * replaying that snapshot credits nothing. Pure.
94
+ */
95
+ export function madeHeadway(prev: ProgressReading | undefined, next: ProgressReading): boolean {
96
+ return (next.output?.length ?? 0) > (prev?.output?.length ?? 0)
97
+ || (next.tokens ?? 0) > (prev?.tokens ?? 0)
98
+ || (next.turns ?? 0) > (prev?.turns ?? 0)
99
+ || (next.toolEvent?.phase === "end" && next.toolEvent !== prev?.toolEvent);
66
100
  }
67
101
 
68
102
  /** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
@@ -86,14 +120,25 @@ export class AsyncRunTracker {
86
120
  private readonly now: () => number;
87
121
 
88
122
  /** `now` is injected so the stall clock is deterministic under test (real time by default).
89
- * `maxRetained` (default 25 — today's hardcoded bound) is the config-driven PI_PERSONA_ASYNC_RETAIN
90
- * knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
123
+ * `maxRetained` (default {@link DEFAULT_ASYNC_RETAIN}; the extension passes `config.asyncRetain`) is the config-driven
124
+ * PI_PERSONA_ASYNC_RETAIN knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
91
125
  constructor(opts?: { now?: () => number; maxRetained?: number }) {
92
126
  this.now = opts?.now ?? ((): number => Date.now());
93
- this.maxRetained = opts?.maxRetained ?? 25;
127
+ this.maxRetained = opts?.maxRetained ?? DEFAULT_ASYNC_RETAIN;
128
+ }
129
+
130
+ /** How many run ids this lineage has issued: what a restored instance must continue from. */
131
+ get issued(): number {
132
+ return this.seq;
94
133
  }
95
134
 
96
- launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean }, run: RunThunk): string {
135
+ /** Continue the id counter from a previous instance of the same session so ids never repeat within
136
+ * one history. Monotonic: anything that would rewind it, or is not a whole number, is ignored. */
137
+ continueFrom(issued: number): void {
138
+ if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
139
+ }
140
+
141
+ launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
97
142
  this.seq += 1;
98
143
  const id = `run-${this.seq.toString(36)}`;
99
144
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -104,9 +149,10 @@ export class AsyncRunTracker {
104
149
  status: "running",
105
150
  progress: { output: "", turns: 0, tokens: 0 },
106
151
  startedAt,
107
- lastAdvanceAt: startedAt,
152
+ ...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
108
153
  };
109
154
  if (meta.mutates) entry.mutates = true;
155
+ if (meta.batch) entry.batch = meta.batch;
110
156
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
111
157
  // once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
112
158
  // those views; renderers still sanitize defensively when they compose a label.
@@ -122,12 +168,8 @@ export class AsyncRunTracker {
122
168
  try {
123
169
  Promise.resolve(
124
170
  run((s) => {
125
- // Only a REAL advance (more output, or a higher turn/token count) resets the stall
126
- // clock — a repeated identical snapshot from a spinning worker must not mask a stall.
127
- const prev = entry.progress;
128
- if (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns) {
129
- entry.lastAdvanceAt = this.now();
130
- }
171
+ // The injected clock stamps the advance, never an event's own timestamp.
172
+ if (!entry.queued && madeHeadway(entry.progress, s)) entry.lastAdvanceAt = this.now();
131
173
  entry.progress = s;
132
174
  }, id),
133
175
  )
@@ -163,8 +205,8 @@ export class AsyncRunTracker {
163
205
  entry.settledAt = this.now();
164
206
  this.settledOrder.push(entry.id);
165
207
  this.prune();
166
- // Iterate a SNAPSHOT: a waitFor join unsubscribes from inside its own listener, and
167
- // splicing the live array under the cursor would shift the next join's listener past it.
208
+ // Iterate a SNAPSHOT: a listener may unsubscribe from inside its own callback, and
209
+ // splicing the live array under the cursor would shift the next listener past it.
168
210
  // A listener's throw must likewise not skip the ones after it — and on the natural-settle
169
211
  // path this runs inside an unobserved promise chain, where an escaping throw becomes a
170
212
  // process-killing unhandled rejection.
@@ -179,6 +221,36 @@ export class AsyncRunTracker {
179
221
  }
180
222
  }
181
223
 
224
+ /** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
225
+ markAdmitted(id: string): boolean {
226
+ const entry = this.runs.get(id);
227
+ if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
228
+ delete entry.queued;
229
+ entry.startedAt = entry.lastAdvanceAt = this.now();
230
+ return true;
231
+ }
232
+
233
+ /** Mark a running run as waiting on the operator (`label`), or release it (`undefined`). The clock
234
+ * restarts on release, so the human's think time is never counted as the run's own silence. */
235
+ setAwaiting(id: string, label: string | undefined): boolean {
236
+ const entry = this.runs.get(id);
237
+ if (!entry || entry.status !== "running" || entry.settled) return false;
238
+ if (label === undefined) {
239
+ delete entry.awaiting;
240
+ this.advance(id);
241
+ } else {
242
+ entry.awaiting = sanitizeStatusLabel(label, "waiting for operator", 120);
243
+ }
244
+ return true;
245
+ }
246
+
247
+ /** Credit headway to a running run whose own snapshot cannot show it: a council or flow aggregates its
248
+ * legs' progress, so one leg's advance is invisible in the sum. Queued, settled and unknown runs are left alone. */
249
+ advance(id: string): void {
250
+ const entry = this.runs.get(id);
251
+ if (entry && entry.status === "running" && !entry.settled && !entry.queued) entry.lastAdvanceAt = this.now();
252
+ }
253
+
182
254
  /**
183
255
  * Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
184
256
  * for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
@@ -267,6 +339,14 @@ export class AsyncRunTracker {
267
339
  return this.list().filter((r) => r.status === "running");
268
340
  }
269
341
 
342
+ /** True when `run` may be reported: it belongs to no batch, or no member of its batch is still
343
+ * running. Asked of the live tracker, never counted from a delivered set, so a member collected
344
+ * early (`intercom result`), stopped, or failed inside launch() can never keep its batch waiting. */
345
+ batchSettled(run: AsyncRun): boolean {
346
+ const batch = run.batch;
347
+ return batch === undefined || !this.running().some((r) => r.batch === batch);
348
+ }
349
+
270
350
  onComplete(cb: (run: AsyncRun) => void): () => void {
271
351
  this.completeListeners.push(cb);
272
352
  return () => {
@@ -274,44 +354,9 @@ export class AsyncRunTracker {
274
354
  if (i >= 0) this.completeListeners.splice(i, 1);
275
355
  };
276
356
  }
277
-
278
- /**
279
- * Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
280
- * it resolves with the runs' CURRENT states, so the caller reports what settled and
281
- * what is still going. Backs the `intercom wait` action (the supervisor holds its turn
282
- * to collect async results it now needs before its next step).
283
- */
284
- waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
285
- const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
286
- if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
287
- return new Promise((resolve) => {
288
- let off = (): void => {};
289
- let timer: ReturnType<typeof setTimeout> | undefined;
290
- const finish = (): void => {
291
- off();
292
- if (timer) clearTimeout(timer);
293
- signal?.removeEventListener("abort", finish);
294
- resolve(targets);
295
- };
296
- off = this.onComplete(() => {
297
- if (targets.every((r) => r.status !== "running")) finish();
298
- });
299
- timer = setTimeout(finish, timeoutMs);
300
- timer.unref?.();
301
- if (signal) {
302
- if (signal.aborted) finish();
303
- else signal.addEventListener("abort", finish, { once: true });
304
- }
305
- });
306
- }
307
357
  }
308
358
 
309
- /**
310
- * A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
311
- * routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
312
- * window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
313
- * periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
314
- */
359
+ /** First occurrence of each run id, in order. */
315
360
  export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
316
361
  const byId = new Map<string, AsyncRun>();
317
362
  for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
@@ -333,13 +378,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
333
378
  *
334
379
  * Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
335
380
  * came back in seconds or ground for two hours changes what to do next — and no completion surface
336
- * used to say it. Undefined (rather than "?" or "0s") when the pair is missing or inverted, so an
381
+ * used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
337
382
  * unstamped run renders exactly as it did before instead of gaining a fabricated reading.
338
383
  */
339
384
  export function runDurationLabel(run: AsyncRun): string | undefined {
340
385
  const { startedAt, settledAt } = run;
341
- if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
342
- return formatDuration(settledAt - startedAt);
386
+ if (startedAt === undefined || settledAt === undefined) return undefined;
387
+ const span = settledAt - startedAt;
388
+ return Number.isFinite(span) && span >= 0 ? formatDuration(span) : undefined;
343
389
  }
344
390
 
345
391
  /** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
@@ -391,20 +437,6 @@ export function boundCompletionSurface(
391
437
 
392
438
  const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
393
439
 
394
- /** A timeout is a status update, not an invitation to poll. The passive completion notifier is
395
- * the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
396
- * for intervention. */
397
- export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
398
- const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
399
- const omitted = ids.length - visibleIds.length;
400
- const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
401
- return (
402
- `⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
403
- "Continue useful supervisor work; completion will notify you automatically. " +
404
- "Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
405
- );
406
- }
407
-
408
440
  /**
409
441
  * The explicit overflow signal for the one case retention cannot cover: more runs settled than the
410
442
  * tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
@@ -445,6 +477,18 @@ export function clipRunOutput(text: string, id: string, maxChars: number, label
445
477
  return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
446
478
  }
447
479
 
480
+ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number): boolean {
481
+ return run.status === "running" && run.queued !== true && stallMs > 0
482
+ && run.lastAdvanceAt !== undefined && Number.isFinite(run.lastAdvanceAt) && run.lastAdvanceAt >= 0
483
+ && now - run.lastAdvanceAt >= stallMs;
484
+ }
485
+
486
+ /**
487
+ * A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
488
+ * routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
489
+ * window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
490
+ * periodic stall check never wakes the model with this digest; it files it as an operator-only card.
491
+ */
448
492
  export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
449
493
  if (runs.length === 0) return "No async runs.";
450
494
  const running = runs.filter((r) => r.status === "running").length;
@@ -459,11 +503,17 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
459
503
  const name = runDisplayName(r);
460
504
  const head = `[${r.id}] ${name} — ${r.status}`;
461
505
  if (r.status === "running") {
462
- let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
463
- if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
464
- else if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
465
- const stalledFor = now - r.lastAdvanceAt;
466
- if (stalledFor >= stallMs) line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
506
+ // A tracked council/flow reports tokens only (its legs carry no turn count): "0 turns" would be false.
507
+ let line = `${head} (${r.progress.turns > 0 ? `${r.progress.turns} turns, ` : ""}${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
508
+ // Queued time is not execution time, so a queued run claims no "running for".
509
+ const ran = r.queued ? undefined : runDurationLabel({ ...r, ...(now !== undefined ? { settledAt: now } : {}) });
510
+ if (ran) line += ` · running for ${ran}`;
511
+ if (r.queued) line += " · queued (waiting for concurrency slot)";
512
+ else if (r.awaiting) line += ` · ${r.awaiting}`;
513
+ else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
514
+ else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
515
+ const stalledFor = now - r.lastAdvanceAt!;
516
+ line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
467
517
  }
468
518
  return line;
469
519
  }
@@ -484,13 +534,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
484
534
 
485
535
  /**
486
536
  * Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
487
- * been surfaced as stalled, so the supervisor is woken ONCE when a leg crosses the stall window — never
537
+ * been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
488
538
  * every tick while it stays stalled, and never at all while it is making progress. A leg that advances
489
539
  * again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
490
540
  * forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
491
541
  */
492
542
  export class PeekWatcher {
493
- /** runId → the `lastAdvanceAt` value at which we reported it stalled (a later advance re-arms). */
543
+ /** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
494
544
  private readonly reportedAt = new Map<string, number>();
495
545
 
496
546
  /** The running legs that have NEWLY crossed the stall window since we last reported them. */
@@ -500,14 +550,18 @@ export class PeekWatcher {
500
550
  for (const r of runs) {
501
551
  if (r.status !== "running") continue;
502
552
  live.add(r.id);
503
- if (waitingForSupervisor.has(r.id)) {
504
- this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate immediately when it settles
553
+ if (r.queued) {
554
+ this.reportedAt.delete(r.id);
555
+ continue;
556
+ }
557
+ if (waitingForSupervisor.has(r.id) || r.awaiting) {
558
+ this.reportedAt.delete(r.id); // a live ask or an operator decision is not a stall; re-evaluate when it settles
505
559
  continue;
506
560
  }
507
561
  if (r.lastAdvanceAt === undefined) continue;
508
562
  const prev = this.reportedAt.get(r.id);
509
563
  if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
510
- if (stallMs > 0 && now - r.lastAdvanceAt >= stallMs && !this.reportedAt.has(r.id)) {
564
+ if (isAsyncRunStalled(r, now, stallMs) && !this.reportedAt.has(r.id)) {
511
565
  this.reportedAt.set(r.id, r.lastAdvanceAt);
512
566
  newlyStuck.push(r);
513
567
  }
@@ -522,34 +576,10 @@ export class PeekWatcher {
522
576
  }
523
577
  }
524
578
 
525
- /**
526
- * The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
527
- * when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
528
- * signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
529
- * patience-first, and intervention means asking the leg — never reaching into its environment to check.
530
- */
531
- export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
532
- if (stuck.length === 0) return "";
533
- const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
534
- const lines = visibleStuck.map((r) => {
535
- const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
536
- return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
537
- });
538
- const omitted = stuck.length - visibleStuck.length;
539
- if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
540
- lines.push(PROGRESS_TOKEN_NOTE);
541
- return [
542
- `${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
543
- ...lines,
544
- "A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
545
- "If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
546
- "wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
547
- ].join("\n");
548
- }
549
-
550
579
  /** The operator-only periodic status card. It carries the progress digest without model instructions. */
551
- export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string> }): string {
552
- return `Async activity · routine check-in\n\n${buildPeekDigest(runs, opts)}`;
580
+ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
581
+ const reason = opts.reason ?? "routine";
582
+ return `Async activity · ${reason === "stall" ? "possible stall" : "routine check-in"}\n\n${buildPeekDigest(runs, opts)}`;
553
583
  }
554
584
 
555
585
  /**
@@ -557,20 +587,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
557
587
  * burst into a single message is what keeps completions from piling up as separate queued
558
588
  * follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
559
589
  * supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
560
- * sub-agent text so the supervisor treats it as data, never as instructions.
590
+ * sub-agent text so the supervisor treats it as data, never as instructions. `bounded: false` lifts
591
+ * every cap, for the headless inline return where the caller is parked in the call and nothing is injected.
561
592
  */
562
- export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
593
+ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
563
594
  const done = runs.filter((r) => r.status === "done");
564
595
  const failed = runs.filter((r) => r.status === "failed");
565
596
  const stopped = runs.filter((r) => r.status === "stopped");
566
597
  // Share the notification budget across every leg that can contribute payload. This keeps a
567
598
  // large fan-out fair: the first result cannot consume the whole follow-up while later statuses
568
599
  // are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
600
+ const bounded = opts.bounded !== false;
601
+ const cap = (chars: number): number => (bounded ? chars : Number.POSITIVE_INFINITY);
569
602
  const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
570
- const fairRunChars = Math.min(
603
+ const fairRunChars = cap(Math.min(
571
604
  MAX_COMPLETION_RUN_CHARS,
572
605
  Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
573
- );
606
+ ));
607
+ // Each failed leg is clipped on its own line, from a budget of its own: every id and cause stays
608
+ // listed however wide the fan-out, and the drill-down names a real run.
609
+ const failureChars = cap(Math.min(
610
+ MAX_COMPLETION_RUN_CHARS,
611
+ Math.max(200, Math.floor(MAX_COMPLETION_REPORT_CHARS / (4 * Math.max(1, failed.length)))),
612
+ ));
574
613
  // Failures are always reported — the supervisor must know so it can adjust or tell
575
614
  // the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
576
615
  // (an identical delegation that failed twice is vetoed before it spawns), not by
@@ -600,9 +639,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
600
639
  }
601
640
  }
602
641
  if (failed.length > 0) {
603
- const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
642
+ const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${clipRunOutput(r.error ?? "(no detail)", r.id, failureChars, "failure detail")}`).join("\n");
604
643
  blocks.push(
605
- `\n❌ ${failed.length} failed:\n${fence(clipRunOutput(reasons, "failures", fairRunChars))}\n\n` +
644
+ `\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
606
645
  "Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
607
646
  "If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
608
647
  "Do not re-issue the same failing delegation repeatedly.",
@@ -616,17 +655,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
616
655
  }
617
656
  }
618
657
  }
619
- return boundCompletionSurface(blocks.join("\n"));
658
+ const report = blocks.join("\n");
659
+ if (!bounded) return report;
660
+ // The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
661
+ // cut keeps, so a cut leg is still fetchable by id instead of silently missing.
662
+ const named = report.length > MAX_COMPLETION_REPORT_CHARS
663
+ ? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}`
664
+ : report;
665
+ return boundCompletionSurface(named);
620
666
  }
621
667
 
622
668
  /**
623
669
  * The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
624
- * carries a surrender marker, `scan` returns the note to append (else undefined). BOTH the background
625
- * completion notifier and the `intercom wait` join render through this, so a blocked leg gets the
626
- * same counterweight however it is collected — the sync `tool_result` hook never sees a background
627
- * run. Only DONE legs are scanned: a FAILED leg is already surfaced as a failure by
628
- * {@link buildCompletionReport}, so the guard and the append agree (no done-vs-all mismatch). `scan`
629
- * is injected (PersistenceNudge.scan) to keep this module decoupled from the nudge.
670
+ * carries a surrender marker, `scan` returns the note to append (else undefined). The background
671
+ * completion notifier renders through this, so a blocked leg gets the counterweight on the
672
+ * automatic follow-up — the sync `tool_result` hook never sees a background run. Only DONE legs
673
+ * are scanned: a FAILED leg is already surfaced as a failure by {@link buildCompletionReport},
674
+ * so the guard and the append agree (no done-vs-all mismatch). `scan` is injected
675
+ * (PersistenceNudge.scan) to keep this module decoupled from the nudge.
630
676
  */
631
677
  export function renderCompletion(
632
678
  runs: AsyncRun[],
@@ -680,6 +726,10 @@ export interface IdleNotifierDeps<T> {
680
726
  * drain keeps pace with small items instead of being pinned to the worst case. Either form is
681
727
  * clamped to at least one item: a queue must always drain. */
682
728
  maxBatchItems?: number | ((pending: readonly T[]) => number);
729
+ /** Whether a buffered item may be delivered yet. A held item stays buffered (peekable, discardable)
730
+ * and is not polled: it ships on the first flush after the `notify()`/`kick()` that made it ready.
731
+ * Unset ⇒ everything is ready. */
732
+ isReady?: (item: T) => boolean;
683
733
  /** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
684
734
  now?: () => number;
685
735
  }
@@ -688,7 +738,8 @@ export interface IdleNotifierDeps<T> {
688
738
  * Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
689
739
  * A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
690
740
  * Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
691
- * Used for async-run completions and child intercom asks.
741
+ * Used for async-run completions (a batch's members held until the last one settles) and child
742
+ * intercom asks.
692
743
  */
693
744
  export class IdleCoalescingNotifier<T> {
694
745
  private readonly deps: IdleNotifierDeps<T>;
@@ -744,15 +795,15 @@ export class IdleCoalescingNotifier<T> {
744
795
  }
745
796
 
746
797
  /** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
747
- * follow-up hasn't flushed yet). Lets `intercom wait`/`peek` surface results in the
748
- * settle→deliver gap instead of reporting nothing; the caller `discard`s what it collects so
749
- * the pending flush won't re-deliver it. */
798
+ * follow-up hasn't flushed yet). Lets `intercom peek` surface results in the
799
+ * settle→deliver gap instead of reporting nothing. Peeking does not collect: only an explicit
800
+ * `result` `discard`s the item so the pending flush won't re-deliver it. */
750
801
  peekPending(): T[] {
751
802
  return [...this.pending];
752
803
  }
753
804
 
754
805
  /** Drop buffered items matching `pred` — e.g. results the supervisor already collected
755
- * synchronously (intercom `wait`), which must not be re-delivered as a follow-up. */
806
+ * explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
756
807
  discard(pred: (item: T) => boolean): void {
757
808
  for (let i = this.pending.length - 1; i >= 0; i--) {
758
809
  if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
@@ -776,7 +827,8 @@ export class IdleCoalescingNotifier<T> {
776
827
  flushIfIdle(): Promise<void> {
777
828
  if (this.flushing) return this.flushing;
778
829
  this.clearArmedTimer();
779
- if (this.pending.length === 0) return Promise.resolve();
830
+ const ready = this.readyItems();
831
+ if (ready.length === 0) return Promise.resolve();
780
832
 
781
833
  const generation = this.generation;
782
834
  let batch: T[] | undefined;
@@ -801,8 +853,9 @@ export class IdleCoalescingNotifier<T> {
801
853
  }
802
854
  }
803
855
 
804
- const take = this.maxBatchItems === undefined ? this.pending.length : Math.max(1, Math.floor(this.maxBatchItems(this.pending)));
805
- batch = this.pending.splice(0, take);
856
+ const take = this.maxBatchItems === undefined ? ready.length : Math.max(1, Math.floor(this.maxBatchItems(ready)));
857
+ batch = ready.slice(0, take);
858
+ for (const item of batch) this.pending.splice(this.pending.indexOf(item), 1);
806
859
  const message = this.deps.render(batch);
807
860
  if (!message) {
808
861
  // Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
@@ -877,8 +930,14 @@ export class IdleCoalescingNotifier<T> {
877
930
  this.handle = undefined;
878
931
  }
879
932
 
933
+ /** The buffered items that may be delivered now, in order. */
934
+ private readyItems(): T[] {
935
+ const { isReady } = this.deps;
936
+ return isReady ? this.pending.filter(isReady) : [...this.pending];
937
+ }
938
+
880
939
  private arm(ms: number): void {
881
- if (this.handle !== undefined || this.pending.length === 0) return;
940
+ if (this.handle !== undefined || this.readyItems().length === 0) return;
882
941
  let scheduled!: unknown;
883
942
  scheduled = this.deps.setTimer(() => {
884
943
  if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
@@ -509,7 +509,7 @@ export async function runChildAgent(
509
509
  // start and was waiting on a slow first response.
510
510
  result.errorMessage = `agent produced no progress within the ${opts.startupTimeoutMs}ms startup window — either it never started (a stalled init, e.g. an MCP adapter hanging) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${streamErr ? ` (last error: ${streamErr})` : ""}`;
511
511
  } else if (timedOut) {
512
- result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms${streamErr ? ` (last error: ${streamErr})` : ""}`;
512
+ result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${streamErr ? ` (last error: ${streamErr})` : ""}`;
513
513
  } else if (aborted) {
514
514
  result.errorMessage = `agent aborted${streamErr ? ` (last error: ${streamErr})` : ""}`;
515
515
  } else if (oversizedLine) {
@@ -0,0 +1,12 @@
1
+ /** A missing hashed chunk in Pi's own bundle is a local host problem, not a provider outage.
2
+ * Pure and deliberately narrow: ordinary missing packages or a worker's own imports get no hint. */
3
+ export function piHostModuleHint(error: string): string | undefined {
4
+ const normalized = error.replaceAll("\\", "/");
5
+ const match = /Cannot find module (["'])([^"'\r\n]+\/@earendil-works\/pi-coding-agent\/dist\/bundle\/chunks\/[A-Za-z0-9_-]+-[A-Z0-9]{8}\.js)\1 imported from ([^\r\n]+)/.exec(normalized);
6
+ const missing = match?.[2];
7
+ const importer = match?.[3]?.trim();
8
+ if (!missing || !importer) return undefined;
9
+ const directory = missing.slice(0, missing.lastIndexOf("/") + 1);
10
+ if (!importer.startsWith(directory) || !/^[^/]+\.js$/.test(importer.slice(directory.length))) return undefined;
11
+ return "A local Pi host bundle module could not load; the host may have been updated in place while this session was running. Retry in a fresh Pi process with the same provider/model; only if the fresh process also fails, repair the Pi installation. Switching provider/model does not repair a missing local module.";
12
+ }