omp-conductor 0.18.2 → 0.19.1

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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +379 -22
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +511 -101
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +325 -1159
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +326 -47
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/worker.ts CHANGED
@@ -108,10 +108,11 @@ function scheduleWallClock(callback: () => void, delayMs: number): () => void {
108
108
 
109
109
  /**
110
110
  * Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
111
- * turn counter caught a loop, or the wall clock caught a session that was stuck
112
- * without spending turns.
111
+ * turn counter caught a loop, the wall clock caught a session that was stuck
112
+ * without spending turns, or the run reached the spend allowance admission
113
+ * reserved for it (#851).
113
114
  */
114
- export type KilledBy = "turns" | "wallclock";
115
+ export type KilledBy = "turns" | "wallclock" | "spend";
115
116
 
116
117
  export type WorkerPausePhase = "running" | "pausing" | "paused";
117
118
 
@@ -171,6 +172,149 @@ export function renderReviewRevisionPrompt(findings: string, round: number): str
171
172
  return `${REVIEW_REVISION_PROMPT}\n\nReview round ${round} — blocking findings:\n\n${findings}`;
172
173
  }
173
174
 
175
+
176
+ /**
177
+ * What the review-ceiling adjudicator is told (#877).
178
+ *
179
+ * Deliberately NOT a continuation of anything. `REVIEW_REVISION_PROMPT` resumes
180
+ * the implementation session to change code; this opens a FRESH session, under a
181
+ * stronger model role, to make one decision about code it did not write. A
182
+ * relabelled resume of the same session and model is the named silent fake of
183
+ * #873: a fourth nominal opinion from the author is not an adjudication.
184
+ *
185
+ * So the contract is a verdict, not a diff: it may not push, may not edit, and
186
+ * ends by yielding `cleared` or `rejected` with reasons. One literal so a test
187
+ * can pin the exact handoff.
188
+ */
189
+ export const REVIEW_ADJUDICATION_PROMPT =
190
+ "You are the final adjudicator for a pull request that has exhausted its review rounds. You did not write this " +
191
+ "code and you are not fixing it: your entire job is one decision. Read the acceptance criteria, the diff, the " +
192
+ "check state and every prior review finding below, then judge whether THIS head satisfies the issue. " +
193
+ "Do not edit files, do not push, do not open or close a pull request, and do not ask for another review round — " +
194
+ "there are none left. Finish by yielding your verdict: `cleared` if this head should merge as it stands, " +
195
+ "`rejected` if it should not, with the specific reasons that decided it. Judge the code in front of you, not the " +
196
+ "process that produced it.";
197
+
198
+ /**
199
+ * The adjudicator's structured verdict (#877). Permissive like the worker
200
+ * settlement schema: an invalid or absent yield falls back to the text path
201
+ * rather than losing the decision, and the daemon settles the durable row from
202
+ * the parsed object when there is one.
203
+ */
204
+ export const ADJUDICATION_VERDICT_SCHEMA = {
205
+ $schema: "https://json-schema.org/draft/2020-12/schema",
206
+ type: "object",
207
+ description:
208
+ "The adjudicator's terminal decision about one exact pull-request head. There is no third option and no " +
209
+ "further review round: `cleared` sends this head to the ordinary merge path, `rejected` sends it to the " +
210
+ "conductor's autonomous disposition.",
211
+ additionalProperties: false,
212
+ required: ["verdict", "reasons"],
213
+ properties: {
214
+ verdict: {
215
+ type: "string",
216
+ enum: ["cleared", "rejected"],
217
+ description: "cleared: this head should merge as it stands. rejected: it should not.",
218
+ },
219
+ reasons: {
220
+ type: "string",
221
+ description:
222
+ "Why, specifically: the criteria met or unmet, and the evidence in the diff or checks that decided it.",
223
+ },
224
+ headSha: {
225
+ type: "string",
226
+ description: "The 40-character head you judged — echoed back so a moved head is detectable.",
227
+ },
228
+ },
229
+ } as const;
230
+
231
+ /** One prior review round as the adjudicator must see it (#877): what was asked
232
+ * for, and what became of it. A finding without its outcome reads as an
233
+ * outstanding complaint even when the worker fixed it, which is how an
234
+ * adjudicator rejects work that was already corrected. */
235
+ export interface AdjudicationRound {
236
+ round: number;
237
+ findings: string;
238
+ /** The revision's settled outcome (`revised`, `failed`, `skipped`, …), or
239
+ * absent for a round that never settled. */
240
+ outcome?: string;
241
+ }
242
+
243
+ /** Everything the adjudicator is given (#877). Assembled by the caller from
244
+ * durable rows and live tracker reads, never from the worker's own claims. */
245
+ export interface AdjudicationEvidence {
246
+ issue: number;
247
+ issueTitle?: string;
248
+ /** The issue body verbatim: the acceptance criteria are the standard being
249
+ * judged, so they are never summarised. */
250
+ issueBody: string;
251
+ prUrl: string;
252
+ /** The exact head being judged. */
253
+ headSha: string;
254
+ /** The live check verdict at that head, as the tracker reported it. */
255
+ checks: string;
256
+ /** The PR's diff at that head. Bounded — see {@link renderAdjudicationBrief}. */
257
+ diff: string;
258
+ /** Every prior review round, oldest first. */
259
+ rounds: readonly AdjudicationRound[];
260
+ /** The review ceiling that was reached, so the adjudicator knows why it is
261
+ * the last word rather than one more opinion. */
262
+ maxRounds: number;
263
+ }
264
+
265
+ /** How much of the diff one adjudication brief carries. A decision needs the
266
+ * change, not every generated line: a bound keeps one enormous PR from
267
+ * crowding out the findings and criteria that follow it, and the marker says
268
+ * exactly what was withheld so the adjudicator can ask for it rather than
269
+ * assuming it saw everything. */
270
+ export const ADJUDICATION_DIFF_LIMIT = 60_000;
271
+
272
+ /**
273
+ * The full opening prompt for one adjudication: the pinned contract plus the
274
+ * bounded evidence, in the order a decision needs it — what was asked for, what
275
+ * is on the head now, what previous reviews said, and what happened to it.
276
+ *
277
+ * Exported so a test can pin the exact payload without standing up a session,
278
+ * which is #877's requirement: the brief is the deliverable here, and a test
279
+ * that only asserts a stored flag would pass on an empty one.
280
+ */
281
+ export function renderAdjudicationBrief(evidence: AdjudicationEvidence): string {
282
+ const rounds =
283
+ evidence.rounds.length === 0
284
+ ? ["(none recorded — this adjudication was admitted without prior rounds)"]
285
+ : evidence.rounds.map(
286
+ (round) =>
287
+ `Round ${round.round} (${round.outcome ?? "never settled"}):\n${round.findings.trim()}`,
288
+ );
289
+ const diff =
290
+ evidence.diff.length <= ADJUDICATION_DIFF_LIMIT
291
+ ? evidence.diff
292
+ : `${evidence.diff.slice(0, ADJUDICATION_DIFF_LIMIT)}\n` +
293
+ `[diff truncated at ${ADJUDICATION_DIFF_LIMIT} of ${evidence.diff.length} characters — ` +
294
+ "read the remainder from the pull request if the decision turns on it]";
295
+ return [
296
+ REVIEW_ADJUDICATION_PROMPT,
297
+ "",
298
+ `Issue #${evidence.issue}${evidence.issueTitle === undefined ? "" : `: ${evidence.issueTitle}`}`,
299
+ `Pull request: ${evidence.prUrl}`,
300
+ `Head under adjudication: ${evidence.headSha}`,
301
+ `Checks at that head: ${evidence.checks}`,
302
+ `Review rounds exhausted: ${evidence.rounds.length} of ${evidence.maxRounds} allowed`,
303
+ "",
304
+ "## Acceptance criteria (the issue, verbatim)",
305
+ "",
306
+ evidence.issueBody.trim(),
307
+ "",
308
+ "## Prior review findings, and what became of them",
309
+ "",
310
+ rounds.join("\n\n"),
311
+ "",
312
+ "## The diff at this head",
313
+ "",
314
+ diff,
315
+ ].join("\n");
316
+ }
317
+
174
318
  export interface WorkerOpts {
175
319
  brief: string;
176
320
  cwd: string;
@@ -217,6 +361,15 @@ export interface WorkerOpts {
217
361
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
218
362
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
219
363
  onSpawn?: (pid: number) => void;
364
+ /**
365
+ * The worker's Herdr representation (#840): opened from the exact child pid,
366
+ * released when that child is gone.
367
+ *
368
+ * Passed straight through to `createSession`, and deliberately not built here
369
+ * — a worker run knows its issue and attempt, but which workspace a pane
370
+ * belongs in, and whether one should exist at all, is the daemon's to decide.
371
+ */
372
+ pane?: { open(pid: number): void; release(): void };
220
373
  /** Control socket for that child, beside the run's own session directory. */
221
374
  socketPath?: string;
222
375
  /**
@@ -232,11 +385,27 @@ export interface WorkerOpts {
232
385
  * startup cap remains fixed for the run.
233
386
  */
234
387
  maxTurns?: () => number;
388
+ /**
389
+ * Estimated-USD ceiling for this session, from the allowance admission
390
+ * reserved for the run (#851). Checked as each cost-bearing message lands,
391
+ * because that is the only moment a response's cost is known — so the
392
+ * declared overshoot is exactly one completed model response, never zero.
393
+ * Omitted means the fleet has no spend cap and nothing to enforce.
394
+ */
395
+ maxSpendUsd?: number;
235
396
  /** Synchronous cap latch; called before the session abort begins. */
236
397
  onKilled?: (by: KilledBy) => void;
237
398
  onTurn?: (n: number) => void;
238
399
  /** Cumulative USD spend, reported as each cost-bearing message finishes. */
239
400
  onSpend?: (usd: number) => void;
401
+ /**
402
+ * Cumulative output and reasoning tokens for this run (#518).
403
+ *
404
+ * Reported alongside the spend it is derived from, so a reader can tell a run
405
+ * deliberating expensively in wall clock but cheaply in dollars from one that
406
+ * is idle or stuck — the difference that decides whether to intervene.
407
+ */
408
+ onTokens?: (tokens: { output: number; reasoning: number }) => void;
240
409
  /**
241
410
  * The transcript path, handed over the moment the session opens it rather
242
411
  * than at the end with {@link WorkerResult.sessionFile}. Both report the same
@@ -461,6 +630,183 @@ export function structuredSettlement(content: unknown): WorkerSettlement | undef
461
630
  return parsed;
462
631
  }
463
632
 
633
+ /**
634
+ * The adjudicator's parsed verdict (#877), or `undefined` when it yielded
635
+ * nothing usable.
636
+ *
637
+ * Same shape of parse as {@link structuredSettlement}, and separate from it on
638
+ * purpose: an adjudication is a decision, not a settlement, and letting the two
639
+ * share a parser would let a worker-shaped yield read as a verdict.
640
+ */
641
+ export function structuredVerdict(content: unknown): AdjudicationVerdict | undefined {
642
+ if (!Array.isArray(content)) return undefined;
643
+ let parsed: AdjudicationVerdict | undefined;
644
+ for (const block of content) {
645
+ if (field(block, "type") !== "toolCall") continue;
646
+ if (field(block, "name") !== "yield") continue;
647
+ const args = field(block, "arguments");
648
+ if (args === null || typeof args !== "object") continue;
649
+ const result = field(args, "result");
650
+ if (result === null || typeof result !== "object") continue;
651
+ const data = field(result, "data");
652
+ if (data === null || typeof data !== "object" || Array.isArray(data)) continue;
653
+ const record = data as Record<string, unknown>;
654
+ const verdict = record.verdict;
655
+ if (verdict !== "cleared" && verdict !== "rejected") continue;
656
+ const reasons = record.reasons;
657
+ if (typeof reasons !== "string" || reasons.trim() === "") continue;
658
+ const decided: AdjudicationVerdict = { verdict, reasons };
659
+ const headSha = record.headSha;
660
+ if (typeof headSha === "string" && headSha !== "") decided.headSha = headSha;
661
+ parsed = decided;
662
+ }
663
+ return parsed;
664
+ }
665
+
666
+ /** One adjudicator's decision as the daemon receives it (#877). */
667
+ export interface AdjudicationVerdict {
668
+ verdict: "cleared" | "rejected";
669
+ reasons: string;
670
+ /** The head the adjudicator says it judged, when it echoed one back. The
671
+ * daemon compares it against the head it dispatched: a mismatch is a
672
+ * stale-head settle, never a merge. */
673
+ headSha?: string;
674
+ }
675
+
676
+ /** What one adjudication run produced (#877). */
677
+ export interface AdjudicationResult {
678
+ /** The parsed verdict, absent when the session ended without yielding one —
679
+ * which settles the durable row `failed`, never `cleared`. */
680
+ verdict?: AdjudicationVerdict;
681
+ /** The session's last words, kept whether or not a verdict parsed: for a
682
+ * failed adjudication this text IS the evidence. */
683
+ report: string;
684
+ /** What actually ran it, for the durable provenance (#875). */
685
+ model?: string;
686
+ provider?: string;
687
+ turns: number;
688
+ spendUsd: number;
689
+ }
690
+
691
+ export interface AdjudicatorOpts {
692
+ /** The rendered brief from {@link renderAdjudicationBrief}. */
693
+ brief: string;
694
+ /** A directory the session may read: everything it needs is in the brief, so
695
+ * this is deliberately not a run worktree — an adjudicator has no branch. */
696
+ cwd: string;
697
+ /** The model pattern the configured adjudicator role resolved to, in omp's
698
+ * model/role syntax. Omitted leaves the harness to choose, which is what an
699
+ * unconfigured project gets. */
700
+ model?: string;
701
+ sessionDir?: string;
702
+ ompSettingsFile?: string;
703
+ socketPath?: string;
704
+ onSpawn?: (pid: number) => void;
705
+ onChildLog?: (line: string) => void;
706
+ maySpawn?: () => boolean;
707
+ /** Hard turn ceiling for the decision. A verdict is one read and one answer;
708
+ * a session still going after this is not deciding. */
709
+ maxTurns: number;
710
+ }
711
+
712
+ /**
713
+ * Run one bounded adjudication (#877).
714
+ *
715
+ * A FRESH session under the configured role — never a resume of the
716
+ * implementation worker, which is the whole point of escalating at the ceiling:
717
+ * a fourth opinion from the same session and model is not an adjudication.
718
+ *
719
+ * Read-only by construction, and that is enforced by what is NOT passed:
720
+ * no `verbSocketPath`, so the conductor mutation verbs (`conductor_push`,
721
+ * `conductor_pr_create`, everything downstream) are unreachable from this
722
+ * session — it cannot push, open, close or merge anything. No release grants
723
+ * either. Its only output is the verdict it yields.
724
+ */
725
+ export async function runAdjudicator(
726
+ o: AdjudicatorOpts,
727
+ deps: RunWorkerDeps = { createSession },
728
+ ): Promise<AdjudicationResult> {
729
+ let session: AgentSessionLike;
730
+ try {
731
+ session = await deps.createSession({
732
+ cwd: o.cwd,
733
+ ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
734
+ ...(o.model === undefined ? {} : { model: o.model }),
735
+ ...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
736
+ ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
737
+ ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
738
+ ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
739
+ ...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
740
+ // Worker role: structured file tools stay inside `cwd` and no release
741
+ // grant can reach this session. Deliberately no `verbSocketPath` — see
742
+ // the doc comment: an adjudicator decides, it never mutates.
743
+ role: "worker",
744
+ outputSchema: ADJUDICATION_VERDICT_SCHEMA,
745
+ outputSchemaMode: "permissive",
746
+ requireYieldTool: true,
747
+ });
748
+ } catch (err) {
749
+ // The pre-spawn admission gate closed, or the harness could not start. Both
750
+ // are "no verdict", and the caller settles the durable row accordingly —
751
+ // never as a clear.
752
+ return {
753
+ report: err instanceof Error ? err.message : String(err),
754
+ turns: 0,
755
+ spendUsd: 0,
756
+ };
757
+ }
758
+
759
+ let turns = 0;
760
+ let spendUsd = 0;
761
+ let report = "";
762
+ let verdict: AdjudicationVerdict | undefined;
763
+ let model: string | undefined;
764
+ let provider: string | undefined;
765
+ const finished = Promise.withResolvers<void>();
766
+ session.on("turn_start", () => {
767
+ turns += 1;
768
+ if (turns > o.maxTurns) session.abort();
769
+ });
770
+ session.on("message_end", (event) => {
771
+ const message = field(event, "message");
772
+ if (field(message, "role") !== "assistant") return;
773
+ const content = field(message, "content");
774
+ const parsed = structuredVerdict(content);
775
+ if (parsed !== undefined) verdict = parsed;
776
+ const text = reportText(content);
777
+ if (text !== "") report = text;
778
+ const usage = field(message, "usage");
779
+ const cost = usage === null ? null : field(usage, "cost");
780
+ const total = cost === null ? null : field(cost, "total");
781
+ if (typeof total === "number") spendUsd += total;
782
+ const messageModel = field(message, "model");
783
+ if (typeof messageModel === "string" && messageModel !== "") model = messageModel;
784
+ const messageProvider = field(message, "provider");
785
+ if (typeof messageProvider === "string" && messageProvider !== "") provider = messageProvider;
786
+ });
787
+ session.on("agent_end", (event) => {
788
+ // Same rule as the worker path: a non-terminal `agent_end` means the
789
+ // harness will resume the session, so completing there would read a
790
+ // mid-flight snapshot as the verdict.
791
+ if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
792
+ });
793
+ session.on("session_exit", () => finished.resolve());
794
+ try {
795
+ await session.prompt(o.brief);
796
+ await finished.promise;
797
+ } finally {
798
+ await disposeSession(session);
799
+ }
800
+ return {
801
+ ...(verdict === undefined ? {} : { verdict }),
802
+ report,
803
+ ...(model === undefined ? {} : { model }),
804
+ ...(provider === undefined ? {} : { provider }),
805
+ turns,
806
+ spendUsd,
807
+ };
808
+ }
809
+
464
810
  /**
465
811
  * Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
466
812
  *
@@ -560,6 +906,7 @@ export async function runWorker(
560
906
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
561
907
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
562
908
  ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
909
+ ...(o.pane === undefined ? {} : { pane: o.pane }),
563
910
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
564
911
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
565
912
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
@@ -663,6 +1010,11 @@ export async function runWorker(
663
1010
 
664
1011
  let turns = 0;
665
1012
  let spendUsd = 0;
1013
+ // Cumulative output/reasoning tokens (#518), beside the spend they are read
1014
+ // from: the share of one against the other is what makes an expensive-in-time,
1015
+ // cheap-in-dollars run legible instead of looking barely started.
1016
+ let outputTokens = 0;
1017
+ let reasoningTokens = 0;
666
1018
  let provider429Count = 0;
667
1019
  let report = "";
668
1020
  // The newest structured settlement the worker yielded (#540). A terminal
@@ -846,10 +1198,27 @@ export async function runWorker(
846
1198
  // Real cost lives on assistant messages as `usage.cost.total` (live hermes
847
1199
  // transcripts, 2026-08-07). The earlier agent_end.telemetry path never
848
1200
  // fired, so every run recorded $0 and the daily cap was theater (#46).
1201
+ // Tokens ride the same usage block as the cost, so this is one read of a
1202
+ // field already in the transcript (#518) — no new call, no polling.
1203
+ const tokens = outputTokensFromMessage(message);
1204
+ if (tokens !== undefined) {
1205
+ outputTokens += tokens.output;
1206
+ reasoningTokens += tokens.reasoning;
1207
+ o.onTokens?.({ output: outputTokens, reasoning: reasoningTokens });
1208
+ }
1209
+
849
1210
  const cost = costUsdFromMessage(message);
850
1211
  if (cost !== undefined) {
851
1212
  spendUsd += cost;
852
1213
  o.onSpend?.(spendUsd);
1214
+ // The live half of the spend bound (#851). The daily cap alone stopped
1215
+ // the fleet on the NEXT dispatch pass, so one run could cross it by its
1216
+ // whole cost before anything objected — on 2026-08-21 a single run spent
1217
+ // an estimated $25.16 against a $25.00 daily cap unopposed. A run that
1218
+ // reaches the allowance reserved for it is killed here, through the same
1219
+ // latch the turn and wall-clock ceilings use, so it settles through the
1220
+ // ordinary salvage path with its work preserved.
1221
+ if (o.maxSpendUsd !== undefined && spendUsd >= o.maxSpendUsd) kill("spend");
853
1222
  }
854
1223
 
855
1224
  // Count the provider rate limits the harness retried in-session (#573). A
@@ -1059,6 +1428,37 @@ export function costUsdFromMessage(message: unknown): number | undefined {
1059
1428
  return any ? sum : undefined;
1060
1429
  }
1061
1430
 
1431
+ /**
1432
+ * Output and reasoning token counts from one assistant message (#518).
1433
+ *
1434
+ * Both come off the same `usage` block the cost is already read from, so this
1435
+ * adds no provider call and no polling — it reads a field the transcript
1436
+ * already carries. Absent or unparseable counts return `undefined` rather than
1437
+ * zero, because "this provider reported no tokens" and "this turn produced no
1438
+ * output" invite opposite readings, and a zero would make the share arithmetic
1439
+ * quietly wrong.
1440
+ *
1441
+ * `reasoning` is counted as part of `output` by every provider that reports it,
1442
+ * which is what makes the share meaningful: 96% reasoning on run 42f73f52 was
1443
+ * the whole answer to "why has this been working 42 minutes for $0.05", and it
1444
+ * was recorded and unshown.
1445
+ */
1446
+ export function outputTokensFromMessage(
1447
+ message: unknown,
1448
+ ): { output: number; reasoning: number } | undefined {
1449
+ const tokens = field(field(message, "usage"), "tokens");
1450
+ if (tokens === null || typeof tokens !== "object") return undefined;
1451
+ const count = (key: string): number | undefined => {
1452
+ const v = field(tokens, key);
1453
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
1454
+ };
1455
+ const output = count("output");
1456
+ if (output === undefined) return undefined;
1457
+ // A provider that reports no reasoning is reporting zero reasoning, which is
1458
+ // a true share of 0% — distinct from an absent output count above.
1459
+ return { output, reasoning: count("reasoning") ?? 0 };
1460
+ }
1461
+
1062
1462
  /**
1063
1463
  * Is this assistant message a provider HTTP 429 the harness recorded mid-run?
1064
1464
  *