@aeondave/pi-persona 1.17.1 → 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 (103) hide show
  1. package/AGENTS.md +59 -28
  2. package/CHANGELOG.md +234 -0
  3. package/README.md +16 -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 +183 -55
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +82 -32
  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 +15 -10
  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 +1 -1
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +165 -146
  48. package/src/engine/child.ts +2 -2
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +12 -7
  51. package/src/engine/stream.ts +4 -20
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/engine.ts +43 -16
  54. package/src/extension/hooks.ts +106 -36
  55. package/src/extension/persona-model-settings.ts +31 -13
  56. package/src/extension/shared.ts +15 -7
  57. package/src/extension.ts +505 -241
  58. package/src/monitor/archive.ts +235 -0
  59. package/src/monitor/session.ts +21 -1
  60. package/src/orchestration/flow-journal.ts +26 -5
  61. package/src/orchestration/flow-run.ts +38 -13
  62. package/src/orchestration/flow.ts +9 -2
  63. package/src/orchestration/reducers.ts +9 -5
  64. package/src/orchestration/render.ts +1 -1
  65. package/src/orchestration/sdk.ts +17 -5
  66. package/src/orchestration/strategies/compete.ts +5 -1
  67. package/src/orchestration/strategies/council-rounds.ts +2 -0
  68. package/src/orchestration/strategies/critic-loop.ts +3 -0
  69. package/src/orchestration/strategies/debate.ts +2 -0
  70. package/src/orchestration/strategies/fanout.ts +3 -2
  71. package/src/orchestration/strategies/judge.ts +5 -1
  72. package/src/orchestration/strategies/magi.ts +40 -23
  73. package/src/orchestration/strategies/map.ts +13 -3
  74. package/src/orchestration/strategies/pair.ts +2 -0
  75. package/src/orchestration/strategies/pipeline.ts +2 -0
  76. package/src/orchestration/strategies/synthesize.ts +27 -7
  77. package/src/orchestration/strategy.ts +31 -0
  78. package/src/persona/persona.ts +7 -3
  79. package/src/telemetry/contract.ts +1 -1
  80. package/src/tools/background-run.ts +151 -0
  81. package/src/tools/council.ts +77 -52
  82. package/src/tools/delegate-tool.ts +310 -280
  83. package/src/tools/delegate.ts +22 -210
  84. package/src/tools/exocom-work.ts +18 -53
  85. package/src/tools/exocom.ts +15 -19
  86. package/src/tools/flow.ts +85 -28
  87. package/src/tools/intercom-tool.ts +108 -118
  88. package/src/tools/intercom.ts +1 -1
  89. package/src/tools/models.ts +19 -9
  90. package/src/tools/monitor.ts +39 -6
  91. package/src/tools/timer.ts +11 -13
  92. package/src/ui/agent-overlay.ts +55 -14
  93. package/src/ui/agent-tree.ts +84 -18
  94. package/src/ui/compact-widget.ts +25 -0
  95. package/src/ui/failure-summary.ts +55 -0
  96. package/src/ui/literal-tool-result.ts +24 -0
  97. package/src/ui/model-picker.ts +158 -81
  98. package/src/ui/persona-settings.ts +9 -3
  99. package/src/ui/presentation.ts +24 -0
  100. package/src/ui/report-body.ts +63 -0
  101. package/src/ui/session-cards.ts +189 -0
  102. package/src/ui/wake-rows.ts +262 -0
  103. 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 { 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,15 +41,16 @@ 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;
53
55
  /** Waiting for semaphore admission; queued time is not agent execution time. */
54
56
  queued?: boolean;
@@ -65,6 +67,36 @@ export interface AsyncRun {
65
67
  * run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
66
68
  * performed after launch() returns would re-insert an already-dead run and leak it. */
67
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);
68
100
  }
69
101
 
70
102
  /** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
@@ -88,14 +120,25 @@ export class AsyncRunTracker {
88
120
  private readonly now: () => number;
89
121
 
90
122
  /** `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. */
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. */
93
125
  constructor(opts?: { now?: () => number; maxRetained?: number }) {
94
126
  this.now = opts?.now ?? ((): number => Date.now());
95
- 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;
133
+ }
134
+
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;
96
139
  }
97
140
 
98
- launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean }, run: RunThunk): string {
141
+ launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
99
142
  this.seq += 1;
100
143
  const id = `run-${this.seq.toString(36)}`;
101
144
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -109,6 +152,7 @@ export class AsyncRunTracker {
109
152
  ...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
110
153
  };
111
154
  if (meta.mutates) entry.mutates = true;
155
+ if (meta.batch) entry.batch = meta.batch;
112
156
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
113
157
  // once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
114
158
  // those views; renderers still sanitize defensively when they compose a label.
@@ -124,14 +168,8 @@ export class AsyncRunTracker {
124
168
  try {
125
169
  Promise.resolve(
126
170
  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
- }
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();
135
173
  entry.progress = s;
136
174
  }, id),
137
175
  )
@@ -167,8 +205,8 @@ export class AsyncRunTracker {
167
205
  entry.settledAt = this.now();
168
206
  this.settledOrder.push(entry.id);
169
207
  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.
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.
172
210
  // A listener's throw must likewise not skip the ones after it — and on the natural-settle
173
211
  // path this runs inside an unobserved promise chain, where an escaping throw becomes a
174
212
  // process-killing unhandled rejection.
@@ -183,15 +221,36 @@ export class AsyncRunTracker {
183
221
  }
184
222
  }
185
223
 
186
- /** Start the stall window when a queued run actually acquires its concurrency slot. */
224
+ /** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
187
225
  markAdmitted(id: string): boolean {
188
226
  const entry = this.runs.get(id);
189
227
  if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
190
228
  delete entry.queued;
191
- entry.lastAdvanceAt = this.now();
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
+ }
192
244
  return true;
193
245
  }
194
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
+
195
254
  /**
196
255
  * Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
197
256
  * for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
@@ -280,6 +339,14 @@ export class AsyncRunTracker {
280
339
  return this.list().filter((r) => r.status === "running");
281
340
  }
282
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
+
283
350
  onComplete(cb: (run: AsyncRun) => void): () => void {
284
351
  this.completeListeners.push(cb);
285
352
  return () => {
@@ -287,44 +354,9 @@ export class AsyncRunTracker {
287
354
  if (i >= 0) this.completeListeners.splice(i, 1);
288
355
  };
289
356
  }
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
357
  }
321
358
 
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
- */
359
+ /** First occurrence of each run id, in order. */
328
360
  export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
329
361
  const byId = new Map<string, AsyncRun>();
330
362
  for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
@@ -346,13 +378,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
346
378
  *
347
379
  * Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
348
380
  * 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
381
+ * used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
350
382
  * unstamped run renders exactly as it did before instead of gaining a fabricated reading.
351
383
  */
352
384
  export function runDurationLabel(run: AsyncRun): string | undefined {
353
385
  const { startedAt, settledAt } = run;
354
- if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
355
- 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;
356
389
  }
357
390
 
358
391
  /** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
@@ -404,20 +437,6 @@ export function boundCompletionSurface(
404
437
 
405
438
  const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
406
439
 
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
440
  /**
422
441
  * The explicit overflow signal for the one case retention cannot cover: more runs settled than the
423
442
  * tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
@@ -464,6 +483,12 @@ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number):
464
483
  && now - run.lastAdvanceAt >= stallMs;
465
484
  }
466
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
+ */
467
492
  export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
468
493
  if (runs.length === 0) return "No async runs.";
469
494
  const running = runs.filter((r) => r.status === "running").length;
@@ -478,8 +503,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
478
503
  const name = runDisplayName(r);
479
504
  const head = `[${r.id}] ${name} — ${r.status}`;
480
505
  if (r.status === "running") {
481
- let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
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}`;
482
511
  if (r.queued) line += " · queued (waiting for concurrency slot)";
512
+ else if (r.awaiting) line += ` · ${r.awaiting}`;
483
513
  else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
484
514
  else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
485
515
  const stalledFor = now - r.lastAdvanceAt!;
@@ -504,7 +534,7 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
504
534
 
505
535
  /**
506
536
  * 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
537
+ * been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
508
538
  * every tick while it stays stalled, and never at all while it is making progress. A leg that advances
509
539
  * again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
510
540
  * forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
@@ -512,20 +542,6 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
512
542
  export class PeekWatcher {
513
543
  /** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
514
544
  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
545
 
530
546
  /** The running legs that have NEWLY crossed the stall window since we last reported them. */
531
547
  poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
@@ -538,8 +554,8 @@ export class PeekWatcher {
538
554
  this.reportedAt.delete(r.id);
539
555
  continue;
540
556
  }
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
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
543
559
  continue;
544
560
  }
545
561
  if (r.lastAdvanceAt === undefined) continue;
@@ -557,35 +573,9 @@ export class PeekWatcher {
557
573
  /** Forget all tracked state (session start / reload hygiene). */
558
574
  reset(): void {
559
575
  this.reportedAt.clear();
560
- this.modelWoken.clear();
561
576
  }
562
577
  }
563
578
 
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
579
  /** The operator-only periodic status card. It carries the progress digest without model instructions. */
590
580
  export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
591
581
  const reason = opts.reason ?? "routine";
@@ -597,20 +587,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
597
587
  * burst into a single message is what keeps completions from piling up as separate queued
598
588
  * follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
599
589
  * 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.
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.
601
592
  */
602
- export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
593
+ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
603
594
  const done = runs.filter((r) => r.status === "done");
604
595
  const failed = runs.filter((r) => r.status === "failed");
605
596
  const stopped = runs.filter((r) => r.status === "stopped");
606
597
  // Share the notification budget across every leg that can contribute payload. This keeps a
607
598
  // large fan-out fair: the first result cannot consume the whole follow-up while later statuses
608
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);
609
602
  const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
610
- const fairRunChars = Math.min(
603
+ const fairRunChars = cap(Math.min(
611
604
  MAX_COMPLETION_RUN_CHARS,
612
605
  Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
613
- );
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
+ ));
614
613
  // Failures are always reported — the supervisor must know so it can adjust or tell
615
614
  // the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
616
615
  // (an identical delegation that failed twice is vetoed before it spawns), not by
@@ -640,9 +639,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
640
639
  }
641
640
  }
642
641
  if (failed.length > 0) {
643
- 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");
644
643
  blocks.push(
645
- `\n❌ ${failed.length} failed:\n${fence(clipRunOutput(reasons, "failures", fairRunChars))}\n\n` +
644
+ `\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
646
645
  "Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
647
646
  "If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
648
647
  "Do not re-issue the same failing delegation repeatedly.",
@@ -656,17 +655,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
656
655
  }
657
656
  }
658
657
  }
659
- 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);
660
666
  }
661
667
 
662
668
  /**
663
669
  * 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.
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.
670
676
  */
671
677
  export function renderCompletion(
672
678
  runs: AsyncRun[],
@@ -720,6 +726,10 @@ export interface IdleNotifierDeps<T> {
720
726
  * drain keeps pace with small items instead of being pinned to the worst case. Either form is
721
727
  * clamped to at least one item: a queue must always drain. */
722
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;
723
733
  /** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
724
734
  now?: () => number;
725
735
  }
@@ -728,7 +738,8 @@ export interface IdleNotifierDeps<T> {
728
738
  * Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
729
739
  * A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
730
740
  * 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.
741
+ * Used for async-run completions (a batch's members held until the last one settles) and child
742
+ * intercom asks.
732
743
  */
733
744
  export class IdleCoalescingNotifier<T> {
734
745
  private readonly deps: IdleNotifierDeps<T>;
@@ -784,15 +795,15 @@ export class IdleCoalescingNotifier<T> {
784
795
  }
785
796
 
786
797
  /** 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. */
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. */
790
801
  peekPending(): T[] {
791
802
  return [...this.pending];
792
803
  }
793
804
 
794
805
  /** 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. */
806
+ * explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
796
807
  discard(pred: (item: T) => boolean): void {
797
808
  for (let i = this.pending.length - 1; i >= 0; i--) {
798
809
  if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
@@ -816,7 +827,8 @@ export class IdleCoalescingNotifier<T> {
816
827
  flushIfIdle(): Promise<void> {
817
828
  if (this.flushing) return this.flushing;
818
829
  this.clearArmedTimer();
819
- if (this.pending.length === 0) return Promise.resolve();
830
+ const ready = this.readyItems();
831
+ if (ready.length === 0) return Promise.resolve();
820
832
 
821
833
  const generation = this.generation;
822
834
  let batch: T[] | undefined;
@@ -841,8 +853,9 @@ export class IdleCoalescingNotifier<T> {
841
853
  }
842
854
  }
843
855
 
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);
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);
846
859
  const message = this.deps.render(batch);
847
860
  if (!message) {
848
861
  // Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
@@ -917,8 +930,14 @@ export class IdleCoalescingNotifier<T> {
917
930
  this.handle = undefined;
918
931
  }
919
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
+
920
939
  private arm(ms: number): void {
921
- if (this.handle !== undefined || this.pending.length === 0) return;
940
+ if (this.handle !== undefined || this.readyItems().length === 0) return;
922
941
  let scheduled!: unknown;
923
942
  scheduled = this.deps.setTimer(() => {
924
943
  if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
@@ -242,7 +242,7 @@ export async function runChildAgent(
242
242
  const onLine = (line: string) => {
243
243
  if (!line.trim()) return;
244
244
  try {
245
- const toolEvent = applyEvent(state, JSON.parse(line), Date.now());
245
+ const toolEvent = applyEvent(state, JSON.parse(line));
246
246
  // Ordinary stream progress stays coalesced to one snapshot per stdout chunk below.
247
247
  // Tool lifecycle transitions are the exception: several can share one chunk and each
248
248
  // authoritative transition must cross the progress seam exactly once.
@@ -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
+ }
@@ -33,6 +33,7 @@ import { roleHint } from "../orchestration/roster.ts";
33
33
  import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
34
34
  import type { AgentResult } from "../orchestration/types.ts";
35
35
  import { looksLikeProviderError } from "./errors.ts";
36
+ import { piHostModuleHint } from "./host-module.ts";
36
37
  import { nextChildHandle } from "./handles.ts";
37
38
  import { combineSignals } from "./signals.ts";
38
39
  import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
@@ -666,7 +667,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
666
667
  // One progress tick, routed exactly as the subscription routes them: the per-call sink
667
668
  // takes the AgentProgress shape (no `turns`), else the construction-time sink.
668
669
  const emitProgress = (snap: ProgressSnapshot): void => {
669
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolOutputAt !== undefined ? { toolOutputAt: snap.toolOutputAt } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
670
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
670
671
  else if (deps.onProgress) deps.onProgress(snap);
671
672
  };
672
673
  let contractDef: ContractDef | undefined = requestedDef;
@@ -674,7 +675,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
674
675
  try {
675
676
  unsub = session.subscribe((ev) => {
676
677
  armIdle(); // any event = activity → reset the idle clock
677
- const toolEvent = applyEvent(state, ev, Date.now());
678
+ const toolEvent = applyEvent(state, ev);
678
679
  // Track the open calls so an abandoned one can be closed once the run settles.
679
680
  if (toolEvent) {
680
681
  if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
@@ -735,7 +736,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
735
736
  // launch path (extension.ts) only has the latter.
736
737
  const snap = snapshot(state);
737
738
  const activity = `✉ from ${env.from}`;
738
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.toolOutputAt !== undefined ? { toolOutputAt: snap.toolOutputAt } : {}), activity });
739
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), activity });
739
740
  else if (deps.onProgress) deps.onProgress({ ...snap, activity });
740
741
  }
741
742
  };
@@ -825,7 +826,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
825
826
 
826
827
  const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
827
828
  // A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
828
- if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
829
+ if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
829
830
  // The deadline cannot tell the two causes apart, so the message names both: claiming
830
831
  // "it never started" would misdirect the operator of a leg that did start and was
831
832
  // simply waiting on a slow first response (mirrors the child engine's wording).
@@ -836,6 +837,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
836
837
  else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
837
838
  else if (thrownError) result.error = `${tag} ${thrownError}`;
838
839
  else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
840
+ const hostModuleHint = !ok ? piHostModuleHint(state.errorMessage ?? thrownError ?? "") : undefined;
841
+ if (hostModuleHint && result.error) result.error += ` — ${hostModuleHint}`;
839
842
  // Classify the failure so the fallback decorator can reroute ONLY provider errors,
840
843
  // never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
841
844
  // own report of an API failure → provider. A THROWN error is not automatically a
@@ -846,9 +849,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
846
849
  ? "timeout"
847
850
  : aborted
848
851
  ? "abort"
849
- : state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
850
- ? "provider"
851
- : "agent";
852
+ : hostModuleHint
853
+ ? "agent"
854
+ : state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
855
+ ? "provider"
856
+ : "agent";
852
857
 
853
858
  if (contractDef) {
854
859
  const v = parseAndValidate(state.output, contractDef);