@coreplane/switchboard 1.242.0 → 1.244.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 (153) hide show
  1. package/dist/assets/config/config.example.yaml +20 -1
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +234 -29
  3. package/dist/assets/deploy/cloudflare-resident/gc.ts +8 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +483 -75
  5. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +13 -2
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/project.json +3 -3
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +31 -35
  11. package/dist/assets/src/config/profile.ts +68 -3
  12. package/dist/assets/src/core/authz/policy.ts +4 -0
  13. package/dist/assets/src/core/authz/types.ts +10 -0
  14. package/dist/assets/src/core/budgets.ts +349 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +32 -0
  16. package/dist/assets/src/core/coordinator/driver.ts +75 -22
  17. package/dist/assets/src/core/costs.ts +9 -76
  18. package/dist/assets/src/core/delivery.ts +1 -1
  19. package/dist/assets/src/core/modelPricing.ts +212 -0
  20. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  21. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  22. package/dist/assets/src/core/runEvents.ts +72 -5
  23. package/dist/assets/src/core/runFriction.ts +3 -1
  24. package/dist/assets/src/core/runRecord.ts +78 -0
  25. package/dist/assets/src/core/runUsage.ts +158 -47
  26. package/dist/assets/src/core/schedules.ts +3 -0
  27. package/dist/assets/src/core/ship/contract.ts +2 -1
  28. package/dist/assets/src/core/ship/coordinator.ts +312 -97
  29. package/dist/assets/src/core/ship/handoff.ts +9 -0
  30. package/dist/assets/src/core/ship/renewal.ts +155 -0
  31. package/dist/assets/src/core/trace/attrs.ts +11 -0
  32. package/dist/assets/src/core/trace/types.ts +4 -3
  33. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  34. package/dist/assets/src/execution/residentRefresh.ts +52 -7
  35. package/dist/assets/src/execution/residentSteps.ts +1 -1
  36. package/dist/assets/src/execution/residentText.ts +8 -3
  37. package/dist/assets/src/mcp/registry.ts +3 -0
  38. package/dist/assets/web/dist/.vite/manifest.json +432 -429
  39. package/dist/assets/web/dist/assets/{AppShell-IDMGG6yi.js → AppShell-Ck4hSZDi.js} +1 -1
  40. package/dist/assets/web/dist/assets/CostsPage-DvhjDMka.js +2 -0
  41. package/dist/assets/web/dist/assets/DeliveryPage-DBlI_Yq2.js +1 -0
  42. package/dist/assets/web/dist/assets/HomePage-XFWPxDiJ.js +2 -0
  43. package/dist/assets/web/dist/assets/{NotFoundPage-C97EhJcQ.js → NotFoundPage-CiGrVOyv.js} +1 -1
  44. package/dist/assets/web/dist/assets/PendingTurnRow-BHCSpsPf.js +1 -0
  45. package/dist/assets/web/dist/assets/{ResidentDetailPage-DTMBgnIW.js → ResidentDetailPage-C5USV_8P.js} +1 -1
  46. package/dist/assets/web/dist/assets/ResidentsIndexPage-CSKisWnX.js +1 -0
  47. package/dist/assets/web/dist/assets/RunFoldRow-BPMQxTcl.js +1 -0
  48. package/dist/assets/web/dist/assets/RunRoutePage-CZe9Yodw.js +9 -0
  49. package/dist/assets/web/dist/assets/RunsIndexPage-BfA333Qy.js +1 -0
  50. package/dist/assets/web/dist/assets/{RunsTabs-BUfdk0lH.js → RunsTabs-BWRHzhtT.js} +1 -1
  51. package/dist/assets/web/dist/assets/ScheduledPage-DBaChcJZ.js +1 -0
  52. package/dist/assets/web/dist/assets/SettingsPage-CqVi9-Ac.js +1 -0
  53. package/dist/assets/web/dist/assets/{StatusDot-ELoXHlFt.js → StatusDot-_Q_bmReh.js} +1 -1
  54. package/dist/assets/web/dist/assets/{Tooltip-BoeFwYP2.js → Tooltip-Cafsgd4m.js} +1 -1
  55. package/dist/assets/web/dist/assets/UnitRoutePage-CdoBZwtf.js +1 -0
  56. package/dist/assets/web/dist/assets/{angular-html-oBNfPJR0.js → angular-html-Dail07rr.js} +1 -1
  57. package/dist/assets/web/dist/assets/{angular-ts-BvNwsyWA.js → angular-ts-BReExio8.js} +1 -1
  58. package/dist/assets/web/dist/assets/{apl-CNUdRlYf.js → apl-DhNO6Yyx.js} +1 -1
  59. package/dist/assets/web/dist/assets/{astro-Zb0NriSe.js → astro-BYotTY4C.js} +1 -1
  60. package/dist/assets/web/dist/assets/{blade-qPRVheqq.js → blade-mUxTtD1X.js} +1 -1
  61. package/dist/assets/web/dist/assets/{c-D8Awx4YO.js → c-DhuiwcSQ.js} +1 -1
  62. package/dist/assets/web/dist/assets/{chapel-Bt72Mhsx.js → chapel-GD2vFtRi.js} +1 -1
  63. package/dist/assets/web/dist/assets/{cobol-BOBacexg.js → cobol-BhFDwkes.js} +1 -1
  64. package/dist/assets/web/dist/assets/{coffee-E4u0liHW.js → coffee-DWUgbE0r.js} +1 -1
  65. package/dist/assets/web/dist/assets/{cpp-q2sLNlul.js → cpp-CO9XFjGW.js} +1 -1
  66. package/dist/assets/web/dist/assets/{crystal-DyWqUnlb.js → crystal-B9pVurpn.js} +1 -1
  67. package/dist/assets/web/dist/assets/{css-CQY0hFsD.js → css-CFVVV6lI.js} +1 -1
  68. package/dist/assets/web/dist/assets/{dist-BU5UivXC.js → dist-BtPs2AkQ.js} +2 -2
  69. package/dist/assets/web/dist/assets/{edge-C1MwhJkX.js → edge-BDhIDQIb.js} +1 -1
  70. package/dist/assets/web/dist/assets/{elixir-Bb3YbHfn.js → elixir-DG3J8WoD.js} +1 -1
  71. package/dist/assets/web/dist/assets/{elm-DAN9IGQw.js → elm-FiOdiaOE.js} +1 -1
  72. package/dist/assets/web/dist/assets/{erb-BEB8Xlsj.js → erb-vxbtW503.js} +1 -1
  73. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  74. package/dist/assets/web/dist/assets/{git-rebase-tqpjRfxO.js → git-rebase-DINz8M3H.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-js-Ccbo65zR.js → glimmer-js-yann6r9i.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glimmer-ts-B6WBVMpE.js → glimmer-ts-CXTbrZn_.js} +1 -1
  77. package/dist/assets/web/dist/assets/{glsl-tRec3Fcu.js → glsl-DNYC-hq0.js} +1 -1
  78. package/dist/assets/web/dist/assets/{graphql-P8kbxT4F.js → graphql-CwzhHzi9.js} +1 -1
  79. package/dist/assets/web/dist/assets/{hack-H9Zhkagy.js → hack-BBXF1cwR.js} +1 -1
  80. package/dist/assets/web/dist/assets/{haml-DtEnpn7Z.js → haml-oEqBcsTy.js} +1 -1
  81. package/dist/assets/web/dist/assets/{handlebars-DkgPfoAz.js → handlebars-YE5thkT1.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-D30RXpIs.js → html-Dp4jRQ1j.js} +1 -1
  83. package/dist/assets/web/dist/assets/{html-derivative-DwozLrEx.js → html-derivative-DCT1GYtQ.js} +1 -1
  84. package/dist/assets/web/dist/assets/{http-DXuzBAPm.js → http-BPcGbmzJ.js} +1 -1
  85. package/dist/assets/web/dist/assets/{hurl-d1UUJIt_.js → hurl-CQnNHwyd.js} +1 -1
  86. package/dist/assets/web/dist/assets/indexRow-BmK74Vp1.js +1 -0
  87. package/dist/assets/web/dist/assets/{java-DL0gWf34.js → java-Dm8Ovj6W.js} +1 -1
  88. package/dist/assets/web/dist/assets/{javascript-Cl7vavnS.js → javascript-Btm9oJK9.js} +1 -1
  89. package/dist/assets/web/dist/assets/{jinja-CfQOWMX9.js → jinja-VZYjWF_U.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jison-KdYirlqm.js → jison-Dcmjxa9x.js} +1 -1
  91. package/dist/assets/web/dist/assets/{json-C9cDQ-Qj.js → json-DoWiOGZ8.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jsx-CRx5NItd.js → jsx-Dvmo55ma.js} +1 -1
  93. package/dist/assets/web/dist/assets/{julia-CmsQsZQl.js → julia-d8AtfV26.js} +1 -1
  94. package/dist/assets/web/dist/assets/{just-CsM3Q8TE.js → just-Cc5EStA-.js} +1 -1
  95. package/dist/assets/web/dist/assets/{latex-BXCh5YRX.js → latex-B5fKKIH7.js} +1 -1
  96. package/dist/assets/web/dist/assets/{liquid-BF2vwK8p.js → liquid-DUYI9JND.js} +1 -1
  97. package/dist/assets/web/dist/assets/{lua-DcMBATrl.js → lua-BhMwKWpw.js} +1 -1
  98. package/dist/assets/web/dist/assets/main-C4366PZF.css +1 -0
  99. package/dist/assets/web/dist/assets/main-DlTNAC6c.js +28 -0
  100. package/dist/assets/web/dist/assets/{marko-88MndKvG.js → marko-Cnf2cPAi.js} +1 -1
  101. package/dist/assets/web/dist/assets/{mdc-DJ4kVAd8.js → mdc-FDDXoir9.js} +1 -1
  102. package/dist/assets/web/dist/assets/{nginx-CP6mRgtV.js → nginx-q-8U_1jR.js} +1 -1
  103. package/dist/assets/web/dist/assets/{nim-QQfj3fpF.js → nim-DIA0TUWs.js} +1 -1
  104. package/dist/assets/web/dist/assets/{org-Bke3eBzc.js → org-CR88hy7S.js} +1 -1
  105. package/dist/assets/web/dist/assets/{perl-Bkh0N7Kz.js → perl-Bkd8N7ja.js} +1 -1
  106. package/dist/assets/web/dist/assets/{php-Dnv1Piya.js → php-BOs84mvy.js} +1 -1
  107. package/dist/assets/web/dist/assets/{pug-D6peFFI7.js → pug-BbpZBb3w.js} +1 -1
  108. package/dist/assets/web/dist/assets/{qml-D8PEs-C-.js → qml-D2jQpjFE.js} +1 -1
  109. package/dist/assets/web/dist/assets/{r-B1EL9b_j.js → r-DKzcAQ22.js} +1 -1
  110. package/dist/assets/web/dist/assets/{razor-BsMTIh2b.js → razor-SOQMWcC9.js} +1 -1
  111. package/dist/assets/web/dist/assets/{regexp-HhvC8spD.js → regexp-B6KK4W8d.js} +1 -1
  112. package/dist/assets/web/dist/assets/{rst-D5paAxpg.js → rst-kPSg-NPf.js} +1 -1
  113. package/dist/assets/web/dist/assets/{ruby-BrQwhLrl.js → ruby-cKS-JYLH.js} +1 -1
  114. package/dist/assets/web/dist/assets/{sas-afot2B1o.js → sas-DNxzE5gs.js} +1 -1
  115. package/dist/assets/web/dist/assets/{scss-Efm-mwuG.js → scss-DGcHQ5OO.js} +1 -1
  116. package/dist/assets/web/dist/assets/{shellscript-BK0Vv5fT.js → shellscript-CRZeZBa5.js} +1 -1
  117. package/dist/assets/web/dist/assets/{shellsession-BGqlMC7N.js → shellsession-DSNEL0HX.js} +1 -1
  118. package/dist/assets/web/dist/assets/{soy-M0b4UwGM.js → soy-zisNbkDO.js} +1 -1
  119. package/dist/assets/web/dist/assets/{sql-sxe6IE9j.js → sql-MG06KMMb.js} +1 -1
  120. package/dist/assets/web/dist/assets/{sseReplay-g7ml86LM.js → sseReplay-DaRtc7nI.js} +5 -5
  121. package/dist/assets/web/dist/assets/{stata-nPF_ddLP.js → stata-CB_wKwv9.js} +1 -1
  122. package/dist/assets/web/dist/assets/{surrealql-D_GrC6u7.js → surrealql-DYdSt7re.js} +1 -1
  123. package/dist/assets/web/dist/assets/{svelte-DwL1AtNP.js → svelte-DHO-F7c7.js} +1 -1
  124. package/dist/assets/web/dist/assets/{templ-7s7LTDkc.js → templ-CUL5L4Lr.js} +1 -1
  125. package/dist/assets/web/dist/assets/{tex-Bx-5fMxe.js → tex-_JNEKjVW.js} +1 -1
  126. package/dist/assets/web/dist/assets/{ts-tags-DUMJnke_.js → ts-tags--9EpEV_8.js} +1 -1
  127. package/dist/assets/web/dist/assets/{tsx-BN8biPDe.js → tsx-BcNq2x2u.js} +1 -1
  128. package/dist/assets/web/dist/assets/{twig-8nIu84TN.js → twig-EsY5Dk_D.js} +1 -1
  129. package/dist/assets/web/dist/assets/{typescript-BooSPq_S.js → typescript-CyA_HiUH.js} +1 -1
  130. package/dist/assets/web/dist/assets/{typst-CTBiBsem.js → typst-SzaHlDt4.js} +1 -1
  131. package/dist/assets/web/dist/assets/{vue-C6Ft4Lea.js → vue-D3cn5acV.js} +1 -1
  132. package/dist/assets/web/dist/assets/{vue-html-Ba36dD5D.js → vue-html-6MxhUxOa.js} +1 -1
  133. package/dist/assets/web/dist/assets/{vue-vine-NkFexVo2.js → vue-vine-qJnqhgo9.js} +1 -1
  134. package/dist/assets/web/dist/assets/{xml-C_THnHXZ.js → xml-DyRpC7xv.js} +1 -1
  135. package/dist/assets/web/dist/assets/{xsl-D8G5xqjY.js → xsl-BXMRPAsg.js} +1 -1
  136. package/dist/assets/web/dist/assets/{yaml-jAMIJzge.js → yaml-BNmPCtAN.js} +1 -1
  137. package/dist/cli.js +5843 -2246
  138. package/package.json +1 -1
  139. package/dist/assets/web/dist/assets/CostsPage-CtmKhhOF.js +0 -2
  140. package/dist/assets/web/dist/assets/DeliveryPage-n1tz5I_v.js +0 -1
  141. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +0 -2
  142. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +0 -1
  143. package/dist/assets/web/dist/assets/ResidentsIndexPage-CaDvXhzJ.js +0 -1
  144. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +0 -1
  145. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +0 -6
  146. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +0 -1
  147. package/dist/assets/web/dist/assets/ScheduledPage-DXD2gLJk.js +0 -1
  148. package/dist/assets/web/dist/assets/SettingsPage-BIGio8Y0.js +0 -1
  149. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +0 -1
  150. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  151. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  152. package/dist/assets/web/dist/assets/main-B2fX10aW.css +0 -1
  153. package/dist/assets/web/dist/assets/main-D4EA1g6n.js +0 -28
@@ -11,7 +11,11 @@ providers:
11
11
  anthropic:
12
12
  type: anthropic
13
13
  apiKeyEnv: ANTHROPIC_API_KEY
14
- # Any OpenAI-compatible endpoint works with the same adapter:
14
+ # OpenAI itself runs on the same compatible adapter: any preset's model may
15
+ # be openai/<model-id> anywhere a model is accepted. The adapter speaks Chat
16
+ # Completions, not the Responses API, sends no reasoning control (our effort
17
+ # tiers do not reach these models), and meters tokens without pricing them.
18
+ # A first-class OpenAI block is a plan's business, not this file's.
15
19
  openai:
16
20
  type: openai-compatible
17
21
  baseUrl: https://api.openai.com/v1
@@ -65,10 +69,19 @@ defaults:
65
69
  # clipped (the card and the run record say so); a preset whose identity or
66
70
  # class is above a cap is refused by name before any executor exists. Absent →
67
71
  # nothing is capped and every preset runs exactly its declared profile.
72
+ # A fourth field, `confirm`, is the front door's and caps no run: the first
73
+ # blast-radius class a command the router bound from prose is handed back at
74
+ # (`To run this: …`) instead of run — `write` (every write is handed back; a
75
+ # read or a repository's own test or build runs) or `destructive` (a write
76
+ # runs at once; only a destructive one is handed back). It intersects toward
77
+ # caution like the run axes — a channel or a user can only ask MORE than what
78
+ # is set here — and unset everywhere it is the built-in `write`. `exec` and
79
+ # `never` are refused by name.
68
80
  # boundary:
69
81
  # maxMinutes: 120
70
82
  # maxIdentity: write
71
83
  # machines: [none, blank, repo-cold, repo-resident]
84
+ # confirm: write
72
85
 
73
86
  # PR reviews and the reading diff (docs/reference/specs/reading-diff.md). Every
74
87
  # review records its full `git diff`; the ABRIDGED version (meat.dev, run on
@@ -436,6 +449,12 @@ workspaceDir: ./workspaces
436
449
  # r2Buckets: # optional: bucket name → label for buckets not named <worker>-cache
437
450
  # some-other-bucket: uploads
438
451
  # anthropicWorkspaceId: wrkspc_... # this app's Anthropic workspace
452
+ # prices: # optional: USD per million tokens by <provider>/<model>, the ref a run's
453
+ # openai/gpt-5: # spans name — for a model the Anthropic list does not know, or to
454
+ # input: 1.25 # override the list; all four kinds named (a provider with no
455
+ # output: 10 # cache-write charge says 0). A model neither knows is "unpriced",
456
+ # cacheRead: 0.125 # never $0, on the costs page and on a run's own page
457
+ # cacheWrite: 0
439
458
 
440
459
  # Delivery indicators: GET /delivery and `delivery report` (docs/reference/specs/delivery.md).
441
460
  # Optional. Needs a GitHub credential (the App triple or GH_TOKEN); read-only on GitHub.
@@ -25,6 +25,7 @@ import {
25
25
  isRunSession,
26
26
  isRunVisibilityFilter,
27
27
  normalizeStored,
28
+ pullRequestNumberOf,
28
29
  RETENTION_BOUNDS,
29
30
  RUN_EVENTS_DEFAULT_PAGE,
30
31
  RUN_EVENTS_MAX_PAGE,
@@ -59,11 +60,11 @@ import {
59
60
  } from "../../src/core/runLedger/sessionLog.ts";
60
61
  import type { RunEvent } from "../../src/core/runEvents.ts";
61
62
  import {
62
- aggregateUsageByUser,
63
63
  isRunUsage,
64
64
  usageOfEvents,
65
65
  type RunUsage,
66
- type RunUsageReport,
66
+ type RunUsageRows,
67
+ type UsageIdentity,
67
68
  type UsageRun,
68
69
  } from "../../src/core/runUsage.ts";
69
70
  import {
@@ -626,6 +627,27 @@ export class ScheduleDO extends DurableObject<Env> {
626
627
  // for optimistic concurrency: a `put` whose `expectedVersion` is stale is a 409,
627
628
  // never a silent clobber (the bot and the CLI both write this document).
628
629
 
630
+ /** What the bot posts to mint a confirmation: the facts this object judges on
631
+ * and the bot's own row as an opaque body (routing-and-config item 25). */
632
+ interface ConfirmationInput {
633
+ id: string;
634
+ threadKey: string;
635
+ requester: string;
636
+ body: Record<string, unknown>;
637
+ }
638
+ /** A stored confirmation as a consume returns it: the input plus the expiry this object stamped. */
639
+ interface ConfirmationRow extends ConfirmationInput {
640
+ expiresAt: number;
641
+ }
642
+ /** Why a consume or a cancel refused: the row is gone (`used`), past its expiry (`expired`), or someone else's (`foreign`). */
643
+ type ConfirmationRefusal = "used" | "expired" | "foreign";
644
+ type ConfirmationOutcome = { row: ConfirmationRow } | { refused: ConfirmationRefusal };
645
+ type ConfirmationCancelOutcome = { ok: true } | { refused: Exclude<ConfirmationRefusal, "expired"> };
646
+
647
+ function isJsonObject(v: unknown): v is Record<string, unknown> {
648
+ return typeof v === "object" && v !== null && !Array.isArray(v);
649
+ }
650
+
629
651
  export class ConfigDO extends DurableObject<Env> {
630
652
  private readonly sql: SqlStorage;
631
653
 
@@ -649,9 +671,91 @@ export class ConfigDO extends DurableObject<Env> {
649
671
  expires_at INTEGER NOT NULL,
650
672
  ticket TEXT NOT NULL
651
673
  );
674
+ CREATE TABLE IF NOT EXISTS confirmations (
675
+ id TEXT PRIMARY KEY,
676
+ thread_key TEXT NOT NULL,
677
+ requester TEXT NOT NULL,
678
+ expires_at INTEGER NOT NULL,
679
+ body TEXT NOT NULL
680
+ );
652
681
  `);
653
682
  }
654
683
 
684
+ // ---- Confirmations (docs/reference/specs/routing-and-config.md item 25): the
685
+ // row a routed write is offered as, minted by the bot and consumed once at
686
+ // the click. The body is the bot's row, opaque here; this object owns the
687
+ // three facts the consume decides on — the thread (one pending row per
688
+ // thread), the requester (the click must be theirs) and the expiry, stamped
689
+ // and judged on this object's clock so two bot processes alive during a roll
690
+ // read one answer. Nothing sweeps: expiry is checked on touch.
691
+
692
+ /** Insert the row and drop the thread's older one in one transaction; the
693
+ * expiry is `now + ttlMs`, stamped here and answered to the caller. */
694
+ async putConfirmation(row: ConfirmationInput, ttlMs: number, now: number): Promise<number> {
695
+ const expiresAt = now + ttlMs;
696
+ this.ctx.storage.transactionSync(() => {
697
+ this.sql.exec(`DELETE FROM confirmations WHERE thread_key = ?`, row.threadKey);
698
+ this.sql.exec(
699
+ `INSERT OR REPLACE INTO confirmations (id, thread_key, requester, expires_at, body) VALUES (?, ?, ?, ?, ?)`,
700
+ row.id,
701
+ row.threadKey,
702
+ row.requester,
703
+ expiresAt,
704
+ JSON.stringify(row.body),
705
+ );
706
+ });
707
+ return expiresAt;
708
+ }
709
+
710
+ /** Read, judge and delete in one transaction on the single-threaded object,
711
+ * so a confirmation runs at most once whatever the click timing: a missing
712
+ * row is `used`, a row past its expiry is deleted and `expired`, a requester
713
+ * none of `actorIds` names is `foreign` (the row stays for its requester),
714
+ * and otherwise the row is deleted and returned. */
715
+ async consumeConfirmation(id: string, actorIds: readonly string[], now: number): Promise<ConfirmationOutcome> {
716
+ return this.ctx.storage.transactionSync(() => {
717
+ const stored = this.readConfirmation(id);
718
+ if (!stored) return { refused: "used" };
719
+ if (stored.expiresAt <= now) {
720
+ this.sql.exec(`DELETE FROM confirmations WHERE id = ?`, id);
721
+ return { refused: "expired" };
722
+ }
723
+ if (!actorIds.includes(stored.requester)) return { refused: "foreign" };
724
+ this.sql.exec(`DELETE FROM confirmations WHERE id = ?`, id);
725
+ return { row: stored };
726
+ });
727
+ }
728
+
729
+ /** Delete the row under the same requester check as a consume; a missing row
730
+ * is `used`. Expiry plays no part: cancelling an expired offer still leaves
731
+ * nothing pending, which is what the click asked for. */
732
+ async cancelConfirmation(id: string, actorIds: readonly string[]): Promise<ConfirmationCancelOutcome> {
733
+ return this.ctx.storage.transactionSync(() => {
734
+ const stored = this.readConfirmation(id);
735
+ if (!stored) return { refused: "used" };
736
+ if (!actorIds.includes(stored.requester)) return { refused: "foreign" };
737
+ this.sql.exec(`DELETE FROM confirmations WHERE id = ?`, id);
738
+ return { ok: true };
739
+ });
740
+ }
741
+
742
+ private readConfirmation(id: string): ConfirmationRow | undefined {
743
+ const row = this.sql
744
+ .exec<{ thread_key: string; requester: string; expires_at: number; body: string }>(
745
+ `SELECT thread_key, requester, expires_at, body FROM confirmations WHERE id = ?`,
746
+ id,
747
+ )
748
+ .toArray()[0];
749
+ if (!row) return undefined;
750
+ return {
751
+ id,
752
+ threadKey: row.thread_key,
753
+ requester: row.requester,
754
+ expiresAt: row.expires_at,
755
+ body: parseStored(row.body, isJsonObject) ?? {},
756
+ };
757
+ }
758
+
655
759
  // ---- MCP sealed credentials + connect tickets (docs/reference/specs/mcp-tools.md items 15–16).
656
760
  // Ciphertext the bot sealed — opaque here — and one-time tickets: the two
657
761
  // things a config document must never carry, kept beside it on this object.
@@ -1051,8 +1155,21 @@ const CONFIG_ROUTES = new Set([
1051
1155
  "/config/tickets/put",
1052
1156
  "/config/tickets/get",
1053
1157
  "/config/tickets/transition",
1158
+ "/config/confirmations/put",
1159
+ "/config/confirmations/consume",
1160
+ "/config/confirmations/cancel",
1054
1161
  ]);
1055
1162
  const TICKET_STATES: ReadonlySet<string> = new Set<McpTicketState>(MCP_TICKET_STATES);
1163
+ /** A confirmation id as the bot mints it (a UUID) — one token, no whitespace, bounded. */
1164
+ const CONFIRMATION_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
1165
+
1166
+ /** The `{ id, actorIds }` a consume or a cancel carries, or the 400 that refuses it. */
1167
+ function confirmationClickOf(b: Record<string, unknown>): { id: string; actorIds: string[] } | Response {
1168
+ if (typeof b.id !== "string" || !CONFIRMATION_ID_RE.test(b.id)) return json({ error: "id malformed" }, 400);
1169
+ if (!Array.isArray(b.actorIds) || !b.actorIds.every((a): a is string => typeof a === "string"))
1170
+ return json({ error: "actorIds must be a list of actor ids" }, 400);
1171
+ return { id: b.id, actorIds: b.actorIds };
1172
+ }
1056
1173
 
1057
1174
  async function handleConfig(pathname: string, body: unknown, env: Env): Promise<Response> {
1058
1175
  const b = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
@@ -1094,6 +1211,38 @@ async function handleConfig(pathname: string, body: unknown, env: Env): Promise<
1094
1211
  );
1095
1212
  return json({ ok: true, applied });
1096
1213
  }
1214
+ // Confirmations (routing-and-config item 25): the body is the bot's row,
1215
+ // stored verbatim; the expiry is this object's clock plus the ttl the bot
1216
+ // passed, never a timestamp the bot chose.
1217
+ case "/config/confirmations/put": {
1218
+ if (typeof b.id !== "string" || !CONFIRMATION_ID_RE.test(b.id)) return json({ error: "id malformed" }, 400);
1219
+ if (typeof b.threadKey !== "string" || !b.threadKey) return json({ error: "threadKey required" }, 400);
1220
+ if (typeof b.requester !== "string" || !b.requester) return json({ error: "requester required" }, 400);
1221
+ if (!isJsonObject(b.body)) return json({ error: "body must be a JSON object" }, 400);
1222
+ if (typeof b.ttlMs !== "number" || !Number.isInteger(b.ttlMs) || b.ttlMs < 0)
1223
+ return json({ error: "ttlMs must be a non-negative integer" }, 400);
1224
+ const expiresAt = await dO.putConfirmation(
1225
+ { id: b.id, threadKey: b.threadKey, requester: b.requester, body: b.body },
1226
+ b.ttlMs,
1227
+ systemClock(),
1228
+ );
1229
+ console.log(`[config/confirmations/put] ${b.threadKey} ${b.id} expires_at=${expiresAt}`);
1230
+ return json({ ok: true, expiresAt });
1231
+ }
1232
+ case "/config/confirmations/consume": {
1233
+ const click = confirmationClickOf(b);
1234
+ if (click instanceof Response) return click;
1235
+ const outcome = await dO.consumeConfirmation(click.id, click.actorIds, systemClock());
1236
+ console.log(`[config/confirmations/consume] ${click.id} ${"row" in outcome ? "consumed" : outcome.refused}`);
1237
+ return json(outcome);
1238
+ }
1239
+ case "/config/confirmations/cancel": {
1240
+ const click = confirmationClickOf(b);
1241
+ if (click instanceof Response) return click;
1242
+ const outcome = await dO.cancelConfirmation(click.id, click.actorIds);
1243
+ console.log(`[config/confirmations/cancel] ${click.id} ${"ok" in outcome ? "cancelled" : outcome.refused}`);
1244
+ return json(outcome);
1245
+ }
1097
1246
  default:
1098
1247
  break;
1099
1248
  }
@@ -1265,13 +1414,15 @@ export class RunHistoryDO extends DurableObject<Env> {
1265
1414
  this.migrateRunsTable();
1266
1415
  // The indexes the visibility predicate's leaves walk (`channel_id IN`,
1267
1416
  // `channel_visibility IN`, `user_id =`), each ordered like the page; the
1268
- // session the sweep asks about; the parent a children listing filters on.
1417
+ // session the sweep asks about; the parent a children listing filters on;
1418
+ // the pull request a findings listing filters on.
1269
1419
  this.sql.exec(`
1270
1420
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1271
1421
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
1272
1422
  CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
1273
1423
  CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
1274
1424
  CREATE INDEX IF NOT EXISTS runs_parent ON runs(parent_run_id, finished_at DESC, run_id DESC);
1425
+ CREATE INDEX IF NOT EXISTS runs_pr ON runs(repo, pr_number, finished_at DESC, run_id DESC);
1275
1426
  `);
1276
1427
  // The sessions registry (session-log item 7): every session log a run of
1277
1428
  // this store claimed, with its thread — the sweep cannot enumerate the
@@ -1438,7 +1589,11 @@ export class RunHistoryDO extends DurableObject<Env> {
1438
1589
  * aggregate fills it from the run's stored events. The parent a child names
1439
1590
  * (run-history item 46), the column a children listing filters on: the
1440
1591
  * record already carries it in `summary_json`, so existing rows are filled
1441
- * from there once, and every later `put` writes it beside the row. */
1592
+ * from there once, and every later `put` writes it beside the row. The pull
1593
+ * request a run names (run-history item 58; `pullRequestNumberOf`), the
1594
+ * column a findings listing filters on: the same one-time fill from
1595
+ * `summary_json` — the coding post-step's `pr`, else a posted review's
1596
+ * target — and every later `put` writes it beside the row. */
1442
1597
  migrateRunsTable(): void {
1443
1598
  const columns = new Set(
1444
1599
  this.sql
@@ -1457,6 +1612,19 @@ export class RunHistoryDO extends DurableObject<Env> {
1457
1612
  WHERE json_type(summary_json, '$.parentRunId') = 'text'`,
1458
1613
  );
1459
1614
  }
1615
+ if (!columns.has("pr_number")) {
1616
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN pr_number INTEGER`);
1617
+ // `pullRequestNumberOf` in SQL: the coding post-step's pull request, else
1618
+ // the one a posted review targeted (a skipped post has no target).
1619
+ this.sql.exec(
1620
+ `UPDATE runs SET pr_number = COALESCE(
1621
+ CASE WHEN json_type(summary_json, '$.pr.number') = 'integer' THEN json_extract(summary_json, '$.pr.number') END,
1622
+ CASE WHEN json_extract(summary_json, '$.reviewPost.posted') = 1
1623
+ AND json_type(summary_json, '$.reviewPost.target.number') = 'integer'
1624
+ THEN json_extract(summary_json, '$.reviewPost.target.number') END
1625
+ )`,
1626
+ );
1627
+ }
1460
1628
  }
1461
1629
 
1462
1630
  private liveByThread(threadKey: string): LiveRunRow | undefined {
@@ -1970,8 +2138,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1970
2138
  );
1971
2139
  this.sql.exec(
1972
2140
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1973
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id)
1974
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2141
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id, pr_number)
2142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1975
2143
  ON CONFLICT(run_id) DO UPDATE SET
1976
2144
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1977
2145
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
@@ -1981,7 +2149,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1981
2149
  bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1982
2150
  session_key = excluded.session_key,
1983
2151
  usage_json = COALESCE(excluded.usage_json, runs.usage_json),
1984
- parent_run_id = excluded.parent_run_id`,
2152
+ parent_run_id = excluded.parent_run_id,
2153
+ pr_number = excluded.pr_number`,
1985
2154
  stored.id,
1986
2155
  stored.label ?? null,
1987
2156
  stored.agent ?? null,
@@ -2004,6 +2173,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2004
2173
  stored.session?.key ?? null,
2005
2174
  stored.usage ? JSON.stringify(stored.usage) : null,
2006
2175
  stored.parentRunId ?? null,
2176
+ pullRequestNumberOf(stored) ?? null,
2007
2177
  );
2008
2178
  // The session's registry row learns its newest finish (session-log item
2009
2179
  // 7); a record that reaches the store without a claim (the plain put
@@ -2208,28 +2378,32 @@ export class RunHistoryDO extends DurableObject<Env> {
2208
2378
  }
2209
2379
 
2210
2380
  /**
2211
- * Who spent what, per UTC day (costs.md, cost by user), over the runs that
2212
- * finished in [sinceMs, untilMs) and are inside the retention window. A row
2213
- * written before `usage_json` existed is filled in here from its stored
2214
- * `model.turn` events — up to USAGE_BACKFILL_PER_CALL a call, the rest
2215
- * reported as `pending` — so the history heals as it is read, with no
2381
+ * What the runs that finished in [sinceMs, untilMs) and are inside the
2382
+ * retention window cost (costs.md items 10–10a; run-history item 56): one row
2383
+ * per run — its requester, thread, channel, agent and usage — plus the
2384
+ * identity of every parent a child names that is outside the batch, so the
2385
+ * bot bills the child without a second read. The arithmetic is the bot's; the
2386
+ * Worker only reads its rows. A row written before `usage_json` existed is
2387
+ * filled in here from its stored `model.turn` events — up to
2388
+ * USAGE_BACKFILL_PER_CALL a call, the rest answered without `usage` and
2389
+ * counted in `pending` — so the history heals as it is read, with no
2216
2390
  * operator step; a run without turns is written back as the zero usage so it
2217
- * is not re-read. A child is billed to its parent's requester; a parent
2218
- * outside the range is looked up by id.
2391
+ * is not re-read.
2219
2392
  */
2220
- usageByUser(sinceMs: number, untilMs: number): RunUsageReport {
2393
+ usage(sinceMs: number, untilMs: number): RunUsageRows {
2221
2394
  const now = systemClock();
2222
2395
  const { policy } = this.policyState();
2223
2396
  const cutoff = now - policy.retentionDays * 86_400_000;
2224
2397
  const rows = this.sql
2225
2398
  .exec<UsageRow>(
2226
- `SELECT run_id, user_id, started_at, finished_at, usage_json, summary_json FROM runs
2399
+ `SELECT run_id, user_id, agent, channel_id, thread_key, started_at, finished_at, usage_json, summary_json FROM runs
2227
2400
  WHERE finished_at >= ? AND finished_at < ? ORDER BY finished_at ASC, run_id ASC`,
2228
2401
  Math.max(sinceMs, cutoff),
2229
2402
  untilMs,
2230
2403
  )
2231
2404
  .toArray();
2232
2405
  let backfilled = 0;
2406
+ let pending = 0;
2233
2407
  const runs: UsageRun[] = rows.map((row) => {
2234
2408
  let usage = parseUsageJson(row.usage_json);
2235
2409
  if (usage === undefined && backfilled < USAGE_BACKFILL_PER_CALL) {
@@ -2237,31 +2411,38 @@ export class RunHistoryDO extends DurableObject<Env> {
2237
2411
  this.sql.exec(`UPDATE runs SET usage_json = ? WHERE run_id = ?`, JSON.stringify(usage), row.run_id);
2238
2412
  backfilled += 1;
2239
2413
  }
2414
+ if (usage === undefined) pending += 1;
2240
2415
  const who = identityOfSummary(row.summary_json);
2241
2416
  return {
2242
2417
  id: row.run_id,
2243
2418
  userId: row.user_id,
2244
2419
  ...(who.userName ? { userName: who.userName } : {}),
2245
2420
  ...(who.parentRunId ? { parentRunId: who.parentRunId } : {}),
2421
+ threadKey: row.thread_key,
2422
+ channelId: row.channel_id,
2423
+ ...(row.agent ? { agent: row.agent } : {}),
2246
2424
  startedAt: row.started_at,
2247
2425
  finishedAt: row.finished_at,
2248
2426
  ...(usage ? { usage } : {}),
2249
2427
  };
2250
2428
  });
2251
- const lookupParent = (id: string) => {
2429
+ // The parents a child names that are not in the batch: looked up once each, known or not.
2430
+ const inBatch = new Set(runs.map((r) => r.id));
2431
+ const parents: Record<string, UsageIdentity> = {};
2432
+ for (const id of new Set(runs.map((r) => r.parentRunId).filter((p): p is string => !!p && !inBatch.has(p)))) {
2252
2433
  const p = this.sql
2253
2434
  .exec<{ user_id: string; summary_json: string }>(`SELECT user_id, summary_json FROM runs WHERE run_id = ?`, id)
2254
2435
  .toArray()[0];
2255
- if (!p) return undefined;
2436
+ if (!p) continue;
2256
2437
  const who = identityOfSummary(p.summary_json);
2257
- return { userId: p.user_id, ...(who.userName ? { userName: who.userName } : {}) };
2258
- };
2259
- const { rows: out, pending } = aggregateUsageByUser(runs, lookupParent);
2438
+ parents[id] = { userId: p.user_id, ...(who.userName ? { userName: who.userName } : {}) };
2439
+ }
2260
2440
  const earliest = this.sql
2261
2441
  .exec<{ m: number | null }>(`SELECT MIN(finished_at) AS m FROM runs WHERE finished_at >= ?`, cutoff)
2262
2442
  .one().m;
2263
2443
  return {
2264
- rows: out,
2444
+ runs,
2445
+ parents,
2265
2446
  pending,
2266
2447
  ...(earliest !== null ? { earliestFinishedAt: earliest } : {}),
2267
2448
  retentionDays: policy.retentionDays,
@@ -2343,6 +2524,11 @@ export class RunHistoryDO extends DurableObject<Env> {
2343
2524
  where.push(`parent_run_id = ?`);
2344
2525
  params.push(q.parentRunId);
2345
2526
  }
2527
+ if (q.pr !== undefined) {
2528
+ // `namesPullRequest` in SQL: the row's repository and the number it names.
2529
+ where.push(`repo = ?`, `pr_number = ?`);
2530
+ params.push(q.pr.repo, q.pr.number);
2531
+ }
2346
2532
  if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
2347
2533
  const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
2348
2534
  // `LIMIT` holds on the over-bound path too: the kept set is the newest
@@ -2423,6 +2609,9 @@ const USAGE_BACKFILL_PER_CALL = 200;
2423
2609
  type UsageRow = {
2424
2610
  run_id: string;
2425
2611
  user_id: string;
2612
+ agent: string | null;
2613
+ channel_id: string;
2614
+ thread_key: string;
2426
2615
  started_at: number;
2427
2616
  finished_at: number;
2428
2617
  usage_json: string | null;
@@ -2590,12 +2779,26 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2590
2779
  if (!id.ok) return invalid("parentRunId must match ^[A-Za-z0-9_-]{1,64}$");
2591
2780
  query.parentRunId = id.value;
2592
2781
  }
2782
+ if (b.pr !== undefined) {
2783
+ const pr = b.pr as Record<string, unknown> | null;
2784
+ if (
2785
+ typeof pr !== "object" ||
2786
+ pr === null ||
2787
+ typeof pr.repo !== "string" ||
2788
+ !REPO_SLUG.test(pr.repo) ||
2789
+ typeof pr.number !== "number" ||
2790
+ !Number.isInteger(pr.number) ||
2791
+ pr.number < 1
2792
+ )
2793
+ return invalid("pr must be { repo: owner/name, number: a positive integer }");
2794
+ query.pr = { repo: pr.repo, number: pr.number };
2795
+ }
2593
2796
  if (b.visibleTo !== undefined) {
2594
2797
  // A malformed filter is a 400, never "all": the bot degrades to live rows
2595
2798
  // rather than the DO widening what an actor may see.
2596
2799
  if (!isRunVisibilityFilter(b.visibleTo)) return invalid("visibleTo must be a run visibility filter");
2597
- if (boundParameters(b.visibleTo) > DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS)
2598
- return invalid(`visibleTo names more than ${DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS} ids`);
2800
+ const headroom = DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS - (query.pr ? RUN_LIST_PR_PARAMETERS : 0);
2801
+ if (boundParameters(b.visibleTo) > headroom) return invalid(`visibleTo names more than ${headroom} ids`);
2599
2802
  query.visibleTo = b.visibleTo;
2600
2803
  }
2601
2804
  return { ok: true, value: { storeKey: key.value, query } };
@@ -2604,6 +2807,8 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2604
2807
  /** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
2605
2808
  * plus `agent`, `channel`, `threadKey`, `parentRunId`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2606
2809
  const RUN_LIST_BASE_PARAMETERS = 9;
2810
+ /** The two more a `pr` filter binds (`repo`, `pr_number`), taken from the same headroom only when asked. */
2811
+ const RUN_LIST_PR_PARAMETERS = 2;
2607
2812
 
2608
2813
  /** How many `?` a filter binds (one per id, one per user). */
2609
2814
  function boundParameters(f: RunVisibilityFilter): number {
@@ -3800,13 +4005,13 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
3800
4005
  );
3801
4006
  return json(result ?? { events: null });
3802
4007
  }
3803
- if (pathname === "/runs/usage-by-user") {
4008
+ if (pathname === "/runs/usage") {
3804
4009
  const parsed = parseRunUsageQuery(body);
3805
4010
  if (!parsed.ok) return json({ error: parsed.error }, 400);
3806
4011
  const { storeKey, sinceMs, untilMs } = parsed.value;
3807
- const report = await stub(storeKey).usageByUser(sinceMs, untilMs);
3808
- console.log(`[runs/usage-by-user] ${storeKey} -> ${report.rows.length} rows, ${report.pending} pending`);
3809
- return json(report);
4012
+ const rows = await stub(storeKey).usage(sinceMs, untilMs);
4013
+ console.log(`[runs/usage] ${storeKey} -> ${rows.runs.length} runs, ${rows.pending} pending`);
4014
+ return json(rows);
3810
4015
  }
3811
4016
  // /runs/delete
3812
4017
  const parsed = parseRunTarget(body);
@@ -3831,7 +4036,7 @@ const ROUTES = new Set([
3831
4036
  "/runs/summary",
3832
4037
  "/runs/list",
3833
4038
  "/runs/events",
3834
- "/runs/usage-by-user",
4039
+ "/runs/usage",
3835
4040
  "/runs/delete",
3836
4041
  ...LEDGER_ROUTES,
3837
4042
  ]);
@@ -79,6 +79,10 @@ export interface ReclaimInput {
79
79
  isDefaultRef: boolean;
80
80
  /** Thread exec/read/write currently running on this binding. */
81
81
  busy: number;
82
+ /** A run registered on the thread — attached and not yet released (item
83
+ * 44): its process lives in the container between the bot's calls, so
84
+ * `busy` reads 0 while its model thinks. Held is kept, whatever the fate. */
85
+ held: boolean;
82
86
  }
83
87
 
84
88
  /** The PR that decided the fate (for the audit trail): the open one, else the
@@ -93,11 +97,13 @@ export type ReclaimWhy =
93
97
  | "no-pr"
94
98
  | "fate-unknown"
95
99
  | "busy"
100
+ | "run-held"
96
101
  | "re-attached"
97
102
  | Extract<RefFate, "gone" | "merged" | "closed">;
98
103
 
99
104
  /** Evict this binding now? A finished ref (gone/merged/closed) is reclaimed
100
- * when nothing runs on it — and nothing else is asked. What its tree holds
105
+ * when nothing runs on it — no op in flight and no run registered from its
106
+ * attach to its release — and nothing else is asked. What its tree holds
101
107
  * is never a reason to keep it: a run starts from a clean tree, so dirt in a
102
108
  * tree no run is using protects nothing (the next attach wipes it); the
103
109
  * Worker measures and records what it removes instead. Keeps are named so
@@ -113,6 +119,7 @@ export function reclaimDecision(input: ReclaimInput): { reclaim: boolean; why: R
113
119
  return { reclaim: false, why: "fate-unknown" };
114
120
  }
115
121
  if (input.busy > 0) return { reclaim: false, why: "busy" };
122
+ if (input.held) return { reclaim: false, why: "run-held" };
116
123
  return { reclaim: true, why: input.fate };
117
124
  }
118
125