omp-conductor 0.18.1 → 0.19.0

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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
package/src/worker.ts CHANGED
@@ -12,7 +12,6 @@
12
12
 
13
13
  import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import type { WorkerIdentity } from "./host.ts";
16
15
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
17
16
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
18
17
  import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
@@ -109,10 +108,11 @@ function scheduleWallClock(callback: () => void, delayMs: number): () => void {
109
108
 
110
109
  /**
111
110
  * Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
112
- * turn counter caught a loop, or the wall clock caught a session that was stuck
113
- * 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).
114
114
  */
115
- export type KilledBy = "turns" | "wallclock";
115
+ export type KilledBy = "turns" | "wallclock" | "spend";
116
116
 
117
117
  export type WorkerPausePhase = "running" | "pausing" | "paused";
118
118
 
@@ -172,6 +172,149 @@ export function renderReviewRevisionPrompt(findings: string, round: number): str
172
172
  return `${REVIEW_REVISION_PROMPT}\n\nReview round ${round} — blocking findings:\n\n${findings}`;
173
173
  }
174
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
+
175
318
  export interface WorkerOpts {
176
319
  brief: string;
177
320
  cwd: string;
@@ -219,16 +362,14 @@ export interface WorkerOpts {
219
362
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
220
363
  onSpawn?: (pid: number) => void;
221
364
  /**
222
- * The worker identity this session runs under (#798): the dedicated
223
- * least-privilege account the daemon resolved for worker launch. Forwarded
224
- * to `createSession`, which launches the child through the identity
225
- * transition (setpriv), re-points its environment at the worker's own home,
226
- * grants it the control socket, and refuses the session unless its kernel
227
- * uid/gid match. The daemon supplies it for every worker launch and refuses
228
- * to dispatch a worker without it; absent, the session runs as the
229
- * caller's own identity (orchestrator, tests).
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.
230
371
  */
231
- workerIdentity?: WorkerIdentity;
372
+ pane?: { open(pid: number): void; release(): void };
232
373
  /** Control socket for that child, beside the run's own session directory. */
233
374
  socketPath?: string;
234
375
  /**
@@ -244,11 +385,27 @@ export interface WorkerOpts {
244
385
  * startup cap remains fixed for the run.
245
386
  */
246
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;
247
396
  /** Synchronous cap latch; called before the session abort begins. */
248
397
  onKilled?: (by: KilledBy) => void;
249
398
  onTurn?: (n: number) => void;
250
399
  /** Cumulative USD spend, reported as each cost-bearing message finishes. */
251
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;
252
409
  /**
253
410
  * The transcript path, handed over the moment the session opens it rather
254
411
  * than at the end with {@link WorkerResult.sessionFile}. Both report the same
@@ -473,6 +630,183 @@ export function structuredSettlement(content: unknown): WorkerSettlement | undef
473
630
  return parsed;
474
631
  }
475
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
+
476
810
  /**
477
811
  * Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
478
812
  *
@@ -571,8 +905,8 @@ export async function runWorker(
571
905
  role: "worker",
572
906
  ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
573
907
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
574
- ...(o.workerIdentity === undefined ? {} : { identity: o.workerIdentity }),
575
908
  ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
909
+ ...(o.pane === undefined ? {} : { pane: o.pane }),
576
910
  ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
577
911
  ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
578
912
  ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
@@ -676,6 +1010,11 @@ export async function runWorker(
676
1010
 
677
1011
  let turns = 0;
678
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;
679
1018
  let provider429Count = 0;
680
1019
  let report = "";
681
1020
  // The newest structured settlement the worker yielded (#540). A terminal
@@ -859,10 +1198,27 @@ export async function runWorker(
859
1198
  // Real cost lives on assistant messages as `usage.cost.total` (live hermes
860
1199
  // transcripts, 2026-08-07). The earlier agent_end.telemetry path never
861
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
+
862
1210
  const cost = costUsdFromMessage(message);
863
1211
  if (cost !== undefined) {
864
1212
  spendUsd += cost;
865
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");
866
1222
  }
867
1223
 
868
1224
  // Count the provider rate limits the harness retried in-session (#573). A
@@ -1072,6 +1428,37 @@ export function costUsdFromMessage(message: unknown): number | undefined {
1072
1428
  return any ? sum : undefined;
1073
1429
  }
1074
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
+
1075
1462
  /**
1076
1463
  * Is this assistant message a provider HTTP 429 the harness recorded mid-run?
1077
1464
  *