@cohortapp/agent-sdk 2.16.0 → 2.18.4

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 (529) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +23 -7
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/poller-daemon-setup.md +4 -1
  9. package/docs/guides/setup-wizard.md +1 -1
  10. package/docs/runbooks/fleet-rollout.md +156 -0
  11. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  12. package/lib/action-executor.js +19 -3
  13. package/lib/budget-guard.mjs +279 -3
  14. package/lib/channels/base-adapter.mjs +3 -1
  15. package/lib/channels/contract.mjs +2 -1
  16. package/lib/channels/inbox-item.mjs +8 -0
  17. package/lib/claude-bin.mjs +5 -6
  18. package/lib/cli/doctor-checks.mjs +141 -10
  19. package/lib/cli/global-setup-extras.mjs +5 -1
  20. package/lib/cli/inbox.mjs +100 -15
  21. package/lib/cli/seat-auth.mjs +463 -0
  22. package/lib/cli/session.mjs +80 -12
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/global-config.mjs +63 -1
  25. package/lib/collective/presence.mjs +142 -5
  26. package/lib/comms/send-gate.mjs +559 -1
  27. package/lib/context/budget.mjs +327 -0
  28. package/lib/context/history-scope.mjs +138 -0
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/economics.mjs +9 -0
  138. package/lib/model-router/failover.mjs +67 -16
  139. package/lib/model-router/llm-task.mjs +39 -3
  140. package/lib/model-router/resolve.mjs +95 -3
  141. package/lib/model-router/spawn.mjs +46 -47
  142. package/lib/model-router/taxonomy.mjs +126 -4
  143. package/lib/org/cost-sync.mjs +141 -11
  144. package/lib/org/inbound/broadcast.mjs +289 -0
  145. package/lib/org/inbound/collective.mjs +375 -0
  146. package/lib/org/inbound/directedness.mjs +96 -8
  147. package/lib/org/inbound/facts.mjs +82 -4
  148. package/lib/org/inbound/hydrate.mjs +555 -51
  149. package/lib/org/inbound/project.mjs +22 -0
  150. package/lib/org/inbound/surfaces.mjs +14 -0
  151. package/lib/org/llm-token.mjs +879 -0
  152. package/lib/org/mesh.mjs +61 -0
  153. package/lib/org/messaging.mjs +3 -1
  154. package/lib/org/protocol.checksum +1 -1
  155. package/lib/org/protocol.mjs +15 -0
  156. package/lib/org/quota.mjs +520 -0
  157. package/lib/org/tool-surface.mjs +104 -16
  158. package/lib/org/ui-parity.mjs +16 -1
  159. package/lib/org/work-ledger.mjs +37 -6
  160. package/lib/rate-guard.mjs +114 -1
  161. package/lib/resource-governor.mjs +41 -6
  162. package/lib/runtime/adapter.mjs +823 -0
  163. package/lib/runtime/child-env.mjs +191 -0
  164. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  165. package/lib/runtime/seat-engine.mjs +162 -0
  166. package/lib/session/ask-ledger.mjs +271 -0
  167. package/lib/session/current-work.mjs +676 -0
  168. package/lib/session/feed-core.mjs +40 -3
  169. package/lib/session/launch-args.mjs +56 -4
  170. package/lib/session/status-summary.mjs +26 -9
  171. package/lib/session/upgrade-notice.mjs +42 -0
  172. package/lib/setup/claude-probe.mjs +117 -13
  173. package/lib/setup/enrich.mjs +13 -10
  174. package/lib/setup/sections/model.mjs +39 -13
  175. package/lib/telemetry/collect.mjs +208 -9
  176. package/lib/upgrade/ignored-drift.mjs +105 -0
  177. package/lib/voice/post-call-brief.mjs +30 -17
  178. package/package.json +15 -3
  179. package/plugins/maestro-skills/skills/board-work.md +5 -0
  180. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  181. package/plugins/maestro-skills/skills/main-session.md +18 -7
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +29 -2
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +104 -59
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +804 -157
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/lib/session-router.mjs +310 -42
  198. package/scripts/daemon/maestro-daemon.mjs +11 -0
  199. package/scripts/daemon/prompt-builder.mjs +121 -12
  200. package/scripts/daemon/responder.mjs +315 -146
  201. package/scripts/daemon/sdk-version.mjs +98 -16
  202. package/scripts/eval/probe-gateway.mjs +635 -0
  203. package/scripts/eval/replay/extract.mjs +270 -0
  204. package/scripts/eval/replay/grade.mjs +260 -0
  205. package/scripts/eval/replay/lib/config.mjs +50 -0
  206. package/scripts/eval/replay/lib/effects.mjs +65 -0
  207. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  208. package/scripts/eval/replay/lib/judge.mjs +72 -0
  209. package/scripts/eval/replay/lib/redact.mjs +136 -0
  210. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  211. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  212. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  213. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  214. package/scripts/eval/replay/report.mjs +185 -0
  215. package/scripts/eval/replay/run.mjs +404 -0
  216. package/scripts/fleet/rollout.mjs +1094 -0
  217. package/scripts/hooks/pre-send-audit.sh +36 -245
  218. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  219. package/scripts/hooks/validate-state-yaml.sh +190 -0
  220. package/scripts/huddle/huddle-llm.mjs +361 -0
  221. package/scripts/huddle/huddle-server.mjs +46 -121
  222. package/scripts/local-triggers/autoupdate.sh +448 -78
  223. package/scripts/local-triggers/run-trigger.sh +13 -0
  224. package/scripts/maintenance/pin-integrity.mjs +364 -0
  225. package/scripts/poll-slack-events.sh +41 -9
  226. package/scripts/poller/slack-socket-mode.mjs +28 -3
  227. package/scripts/session/supervisor.mjs +80 -13
  228. package/scripts/spawn-session.sh +13 -0
  229. package/bin/maestro.test.mjs +0 -1574
  230. package/lib/action-executor.test.mjs +0 -871
  231. package/lib/archetype.test.mjs +0 -132
  232. package/lib/assurance/plan-note.test.mjs +0 -234
  233. package/lib/assurance/room-budget.test.mjs +0 -486
  234. package/lib/assurance/tier.test.mjs +0 -174
  235. package/lib/autonomy.test.mjs +0 -66
  236. package/lib/backlog.test.mjs +0 -302
  237. package/lib/backup/policy.test.mjs +0 -305
  238. package/lib/budget-escalate.test.mjs +0 -232
  239. package/lib/budget-guard.envelope.test.mjs +0 -476
  240. package/lib/budget-guard.test.mjs +0 -427
  241. package/lib/cadence-bus-requeue.test.mjs +0 -83
  242. package/lib/cadence-bus-schedule.test.mjs +0 -194
  243. package/lib/cadence-bus.test.mjs +0 -720
  244. package/lib/cadences.test.mjs +0 -230
  245. package/lib/capability/inventory.test.mjs +0 -232
  246. package/lib/capability.test.mjs +0 -78
  247. package/lib/channels/base-adapter.test.mjs +0 -590
  248. package/lib/channels/channels.test.mjs +0 -371
  249. package/lib/channels/contract.test.mjs +0 -162
  250. package/lib/channels/inbox-item.test.mjs +0 -368
  251. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  252. package/lib/channels/pairing.test.mjs +0 -270
  253. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  254. package/lib/channels/slack-adapter.test.mjs +0 -212
  255. package/lib/channels/telegram-adapter.test.mjs +0 -306
  256. package/lib/channels/voice/adapter.test.mjs +0 -278
  257. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  258. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  259. package/lib/charter.test.mjs +0 -89
  260. package/lib/claude-bin.test.mjs +0 -131
  261. package/lib/cli/board.test.mjs +0 -227
  262. package/lib/cli/design.test.mjs +0 -270
  263. package/lib/cli/doctor-checks.test.mjs +0 -336
  264. package/lib/cli/global-setup-extras.test.mjs +0 -462
  265. package/lib/cli/inbox.test.mjs +0 -230
  266. package/lib/cli/session-ack.test.mjs +0 -63
  267. package/lib/cli/session.test.mjs +0 -613
  268. package/lib/collective/capture.test.mjs +0 -121
  269. package/lib/collective/cards.test.mjs +0 -114
  270. package/lib/collective/config.test.mjs +0 -123
  271. package/lib/collective/global-config.test.mjs +0 -220
  272. package/lib/collective/global-skills.test.mjs +0 -126
  273. package/lib/collective/presence.test.mjs +0 -95
  274. package/lib/collective/recall.test.mjs +0 -116
  275. package/lib/collective/vendor-skills.test.mjs +0 -306
  276. package/lib/comms/send-gate.test.mjs +0 -770
  277. package/lib/comms.test.mjs +0 -41
  278. package/lib/cost/ledger-row.test.mjs +0 -183
  279. package/lib/design/design-md.test.mjs +0 -318
  280. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  281. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  282. package/lib/design/fixtures/foundation.json +0 -133
  283. package/lib/design/refresh-gate.test.mjs +0 -144
  284. package/lib/design/write.test.mjs +0 -241
  285. package/lib/diagnostics/alerts.test.mjs +0 -318
  286. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  287. package/lib/diagnostics/counters.test.mjs +0 -206
  288. package/lib/diagnostics/events.test.mjs +0 -290
  289. package/lib/diagnostics/otel.test.mjs +0 -196
  290. package/lib/diagnostics/trace.test.mjs +0 -251
  291. package/lib/env-compat.test.mjs +0 -104
  292. package/lib/execution/disposition.test.mjs +0 -553
  293. package/lib/execution/drive.test.mjs +0 -270
  294. package/lib/execution/effects.test.mjs +0 -344
  295. package/lib/execution/intake.test.mjs +0 -389
  296. package/lib/execution/journal.test.mjs +0 -261
  297. package/lib/execution/match.test.mjs +0 -235
  298. package/lib/execution/pipeline.test.mjs +0 -392
  299. package/lib/execution/route.test.mjs +0 -186
  300. package/lib/execution/surface-policy.test.mjs +0 -162
  301. package/lib/fs-atomic.test.mjs +0 -72
  302. package/lib/fs-ownership.test.mjs +0 -158
  303. package/lib/goals/admission.test.mjs +0 -164
  304. package/lib/goals/classify.test.mjs +0 -167
  305. package/lib/goals/collaborate.test.mjs +0 -336
  306. package/lib/goals/gaps.test.mjs +0 -284
  307. package/lib/goals/loop.test.mjs +0 -845
  308. package/lib/hooks/bus.test.mjs +0 -387
  309. package/lib/identity/persona.test.mjs +0 -142
  310. package/lib/kpi-sensors.test.mjs +0 -278
  311. package/lib/kpi.test.mjs +0 -244
  312. package/lib/learning/config.test.mjs +0 -75
  313. package/lib/learning/counters.test.mjs +0 -69
  314. package/lib/learning/curator-consolidate.test.mjs +0 -238
  315. package/lib/learning/curator.test.mjs +0 -106
  316. package/lib/learning/reflect.test.mjs +0 -0
  317. package/lib/learning/session-index.test.mjs +0 -125
  318. package/lib/learning/skill-writer.test.mjs +0 -210
  319. package/lib/mandate/audit.test.mjs +0 -195
  320. package/lib/mandate/contract.test.mjs +0 -185
  321. package/lib/mandate/derive.test.mjs +0 -274
  322. package/lib/mandate/model.test.mjs +0 -164
  323. package/lib/mandate/refresh.test.mjs +0 -389
  324. package/lib/mcp/server.test.mjs +0 -426
  325. package/lib/model-router/auth-profiles.test.mjs +0 -580
  326. package/lib/model-router/catalog.test.mjs +0 -385
  327. package/lib/model-router/economics.test.mjs +0 -438
  328. package/lib/model-router/failover.test.mjs +0 -439
  329. package/lib/model-router/health.test.mjs +0 -338
  330. package/lib/model-router/integration-coverage.test.mjs +0 -831
  331. package/lib/model-router/integration.test.mjs +0 -564
  332. package/lib/model-router/ledger.test.mjs +0 -415
  333. package/lib/model-router/llm-task.test.mjs +0 -392
  334. package/lib/model-router/org-credentials.test.mjs +0 -265
  335. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  336. package/lib/model-router/reconcile.test.mjs +0 -316
  337. package/lib/model-router/repair.test.mjs +0 -180
  338. package/lib/model-router/spawn.test.mjs +0 -446
  339. package/lib/model-router/taxonomy.test.mjs +0 -410
  340. package/lib/model-router.test.mjs +0 -1207
  341. package/lib/org/activity.test.mjs +0 -134
  342. package/lib/org/approvals.test.mjs +0 -216
  343. package/lib/org/awareness.test.mjs +0 -159
  344. package/lib/org/board-mine-cache.test.mjs +0 -53
  345. package/lib/org/board.test.mjs +0 -187
  346. package/lib/org/bootstrap-context.test.mjs +0 -153
  347. package/lib/org/client.test.mjs +0 -1206
  348. package/lib/org/cohort-client.test.mjs +0 -126
  349. package/lib/org/cost-sync.test.mjs +0 -153
  350. package/lib/org/doctor.test.mjs +0 -346
  351. package/lib/org/engagement-ledger.test.mjs +0 -112
  352. package/lib/org/engagement.test.mjs +0 -739
  353. package/lib/org/handoff.test.mjs +0 -269
  354. package/lib/org/inbound/directedness.test.mjs +0 -668
  355. package/lib/org/inbound/facts.test.mjs +0 -471
  356. package/lib/org/inbound/hydrate.test.mjs +0 -453
  357. package/lib/org/inbound/index.test.mjs +0 -429
  358. package/lib/org/inbound/project.test.mjs +0 -287
  359. package/lib/org/integration-tools.test.mjs +0 -160
  360. package/lib/org/keys.test.mjs +0 -92
  361. package/lib/org/knowledge.test.mjs +0 -326
  362. package/lib/org/leases.test.mjs +0 -235
  363. package/lib/org/mesh-directives.test.mjs +0 -110
  364. package/lib/org/mesh-integration.test.mjs +0 -127
  365. package/lib/org/mesh.test.mjs +0 -400
  366. package/lib/org/messaging.test.mjs +0 -471
  367. package/lib/org/param-contract.test.mjs +0 -477
  368. package/lib/org/policy.test.mjs +0 -237
  369. package/lib/org/protocol.checksum.test.mjs +0 -90
  370. package/lib/org/protocol.test.mjs +0 -323
  371. package/lib/org/push.test.mjs +0 -792
  372. package/lib/org/registry.test.mjs +0 -100
  373. package/lib/org/resource-tools.test.mjs +0 -361
  374. package/lib/org/tool-access.test.mjs +0 -144
  375. package/lib/org/tool-surface-integration.test.mjs +0 -120
  376. package/lib/org/tool-surface.test.mjs +0 -1268
  377. package/lib/org/typing.test.mjs +0 -291
  378. package/lib/org/ui-parity.test.mjs +0 -560
  379. package/lib/org/verify.test.mjs +0 -194
  380. package/lib/org/work-ledger.test.mjs +0 -273
  381. package/lib/plan/adoption-e2e.test.mjs +0 -366
  382. package/lib/plan/budget-enforcement.test.mjs +0 -400
  383. package/lib/plan/compile.test.mjs +0 -382
  384. package/lib/plan/emit.test.mjs +0 -269
  385. package/lib/plan/explain.test.mjs +0 -188
  386. package/lib/prompts/parallelism.test.mjs +0 -177
  387. package/lib/rag/rag.test.mjs +0 -505
  388. package/lib/rate-guard.test.mjs +0 -272
  389. package/lib/reactive-gate.test.mjs +0 -57
  390. package/lib/render.test.mjs +0 -68
  391. package/lib/resource-governor.test.mjs +0 -488
  392. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  393. package/lib/scheduling/jitter.test.mjs +0 -140
  394. package/lib/secrets/broker.test.mjs +0 -280
  395. package/lib/secrets/providers.test.mjs +0 -274
  396. package/lib/security/audit-engine.test.mjs +0 -424
  397. package/lib/security/coerce-args.test.mjs +0 -281
  398. package/lib/security/dangerous-tools.test.mjs +0 -68
  399. package/lib/security/external-content.test.mjs +0 -84
  400. package/lib/security/redact.test.mjs +0 -441
  401. package/lib/security/secret-equal.test.mjs +0 -55
  402. package/lib/session/config.test.mjs +0 -92
  403. package/lib/session/feed-core.test.mjs +0 -198
  404. package/lib/session/first-run.test.mjs +0 -121
  405. package/lib/session/frontdoor.test.mjs +0 -205
  406. package/lib/session/handoffs.test.mjs +0 -183
  407. package/lib/session/identity.test.mjs +0 -180
  408. package/lib/session/inbox-claims.test.mjs +0 -286
  409. package/lib/session/launch-args.test.mjs +0 -157
  410. package/lib/session/liveness.test.mjs +0 -100
  411. package/lib/session/status-summary.test.mjs +0 -118
  412. package/lib/session-permissions.test.mjs +0 -120
  413. package/lib/setup/claude-probe.test.mjs +0 -187
  414. package/lib/setup/completeness.test.mjs +0 -110
  415. package/lib/setup/context-pack.test.mjs +0 -89
  416. package/lib/setup/enrich.test.mjs +0 -115
  417. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  418. package/lib/setup/integration.test.mjs +0 -162
  419. package/lib/setup/io.test.mjs +0 -77
  420. package/lib/setup/runner.test.mjs +0 -132
  421. package/lib/setup/sections/identity.test.mjs +0 -234
  422. package/lib/setup/sections/inventory.test.mjs +0 -198
  423. package/lib/setup/sections/learning.test.mjs +0 -81
  424. package/lib/setup/sections/mandate.test.mjs +0 -388
  425. package/lib/setup/sections/messaging.test.mjs +0 -127
  426. package/lib/setup/sections/model.test.mjs +0 -240
  427. package/lib/setup/sections/org.test.mjs +0 -346
  428. package/lib/setup/sections/orgmail.test.mjs +0 -118
  429. package/lib/setup/sections/recovery.test.mjs +0 -98
  430. package/lib/setup/sections/subagents.test.mjs +0 -429
  431. package/lib/setup/sections/verify.test.mjs +0 -175
  432. package/lib/setup/sot.test.mjs +0 -81
  433. package/lib/setup/state.test.mjs +0 -115
  434. package/lib/singleton.test.mjs +0 -151
  435. package/lib/subagents/cli.test.mjs +0 -389
  436. package/lib/subagents/client.test.mjs +0 -309
  437. package/lib/subagents/gap.test.mjs +0 -234
  438. package/lib/subagents/lock.test.mjs +0 -248
  439. package/lib/subagents/manifest.test.mjs +0 -175
  440. package/lib/subagents/refs.test.mjs +0 -204
  441. package/lib/subagents/resolve.test.mjs +0 -422
  442. package/lib/subagents/schema.test.mjs +0 -328
  443. package/lib/telemetry/alerts.test.mjs +0 -109
  444. package/lib/telemetry/collect.test.mjs +0 -1274
  445. package/lib/tool-definitions-integration.test.mjs +0 -83
  446. package/lib/tool-definitions.test.mjs +0 -437
  447. package/lib/upgrade/global-refresh.test.mjs +0 -65
  448. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  449. package/lib/upgrade/post-steps.test.mjs +0 -200
  450. package/lib/upgrade/verify.test.mjs +0 -164
  451. package/lib/util/fetch-timeout.test.mjs +0 -202
  452. package/lib/util/reconnect.test.mjs +0 -369
  453. package/lib/util/unhandled.test.mjs +0 -216
  454. package/lib/voice/outbound.test.mjs +0 -69
  455. package/lib/voice/session-rotation.test.mjs +0 -114
  456. package/lib/voice/stt.test.mjs +0 -226
  457. package/lib/voice/voice.test.mjs +0 -990
  458. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  459. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  460. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  461. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  462. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  463. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  464. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  465. package/scripts/ci/check.test.mjs +0 -194
  466. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  467. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  468. package/scripts/collective/hook-runner.test.mjs +0 -173
  469. package/scripts/cost/fleet-digest.test.mjs +0 -207
  470. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  471. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  472. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  473. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  474. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  475. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  476. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  477. package/scripts/daemon/assurance.test.mjs +0 -1791
  478. package/scripts/daemon/board-mirror.test.mjs +0 -165
  479. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  480. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  481. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  482. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  483. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  484. package/scripts/daemon/classifier.test.mjs +0 -266
  485. package/scripts/daemon/classify-kind.test.mjs +0 -40
  486. package/scripts/daemon/context-compiler.test.mjs +0 -300
  487. package/scripts/daemon/deliver.test.mjs +0 -564
  488. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  489. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  490. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  491. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  492. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  493. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  494. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  495. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  496. package/scripts/daemon/integration.test.mjs +0 -149
  497. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  498. package/scripts/daemon/lib/session-router.test.mjs +0 -295
  499. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  500. package/scripts/daemon/prompt-builder.test.mjs +0 -344
  501. package/scripts/daemon/responder-cost.test.mjs +0 -68
  502. package/scripts/daemon/responder-history.test.mjs +0 -185
  503. package/scripts/daemon/sdk-version.test.mjs +0 -31
  504. package/scripts/daemon/session-lock.test.mjs +0 -252
  505. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  506. package/scripts/daemon/typing-registry.test.mjs +0 -102
  507. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  508. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  509. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  510. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  511. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  512. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  513. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  514. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  515. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  516. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  517. package/scripts/poller-launchd/install.test.mjs +0 -243
  518. package/scripts/restore-from-backup.test.mjs +0 -181
  519. package/scripts/session/feed.test.mjs +0 -196
  520. package/scripts/session/supervisor-sh.test.mjs +0 -218
  521. package/scripts/session/supervisor.test.mjs +0 -482
  522. package/scripts/setup/configure-macos.test.mjs +0 -306
  523. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  524. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  525. package/scripts/setup/generate-capability.test.mjs +0 -134
  526. package/scripts/setup/init-agent.test.mjs +0 -370
  527. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  528. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  529. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -30,17 +30,37 @@ let governor = resourceGovernor;
30
30
  let rateGuard = rateGuardModule;
31
31
  let budgetGuard = budgetGuardModule;
32
32
  const RATE_PROVIDER = process.env.MAESTRO_RATE_PROVIDER || "anthropic";
33
+ // cohort-llm clients (seat token + quota). `undefined` = resolve lazily from the
34
+ // seat's config; `null` = none. Injectable for tests.
35
+ let cohortClients = undefined;
33
36
 
34
37
  /**
35
38
  * Test seam: swap the governance modules for fakes. Each arg is optional;
36
39
  * omitted modules keep the real implementation. Returns a restore fn.
40
+ * `cohort` is `{quota, token}` (lib/org/llm-token.mjs seatCohortClients shape).
37
41
  */
38
- export function setGovernanceForTests({ governor: g, rateGuard: r, budgetGuard: b } = {}) {
39
- const prev = { governor, rateGuard, budgetGuard };
42
+ export function setGovernanceForTests({ governor: g, rateGuard: r, budgetGuard: b, cohort: c } = {}) {
43
+ const prev = { governor, rateGuard, budgetGuard, cohortClients };
40
44
  if (g) governor = g;
41
45
  if (r) rateGuard = r;
42
46
  if (b) budgetGuard = b;
43
- return () => { governor = prev.governor; rateGuard = prev.rateGuard; budgetGuard = prev.budgetGuard; };
47
+ if (c !== undefined) cohortClients = c;
48
+ return () => { governor = prev.governor; rateGuard = prev.rateGuard; budgetGuard = prev.budgetGuard; cohortClients = prev.cohortClients; };
49
+ }
50
+
51
+ /** The seat's cohort-llm clients, or null when it has none. Never throws. */
52
+ function getCohortClients() {
53
+ if (cohortClients === undefined) {
54
+ try {
55
+ cohortClients = seatCohortClients({
56
+ agentRoot: AGENT_REPO_DIR,
57
+ log: (level, msg) => { try { console[level === "error" ? "error" : "warn"](`[dispatcher] cohort-llm: ${msg}`); } catch { /* */ } },
58
+ });
59
+ } catch {
60
+ cohortClients = null;
61
+ }
62
+ }
63
+ return cohortClients;
44
64
  }
45
65
 
46
66
  // Indirection so a test can capture the EXACT argv handed to `claude` by the
@@ -99,6 +119,10 @@ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.ur
99
119
  // Resolve the claude binary against the agent's PATH (not launchd's bare
100
120
  // env). Without this, every daemon-spawned `claude --print` exits ENOENT.
101
121
  import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
122
+ import { buildSpawn, admitSpawnCached, isCohortRouted, readSeatEngine } from "../../lib/runtime/adapter.mjs";
123
+ import { seatEngineFields, engineRunShape, sessionBudgetUsd, seatTokenSync } from "../../lib/runtime/seat-engine.mjs";
124
+ import { seatCohortClients } from "../../lib/org/llm-token.mjs";
125
+ import { COHORT_LLM_BILLED_BY } from "../../lib/org/cost-sync.mjs";
102
126
  const CLAUDE_BIN = resolveClaudeBin();
103
127
 
104
128
  // Model router — opt-in. When config/model-routing.yaml is present in the
@@ -112,10 +136,11 @@ const CLAUDE_BIN = resolveClaudeBin();
112
136
  // - v2 (schema_version: 2): resolveChain → RouteDecision. The decision carries
113
137
  // the chosen backend, the session-retarget env, the spawn knobs, the
114
138
  // ordered failover chain, an estimated cost, and a decision_id that joins
115
- // the ledger row ↔ resume marker ↔ routing audit. We build the argv + child
116
- // env from the decision via the execution layer's pure builders
117
- // (buildSpawnArgs / buildChildEnv from lib/model-router/spawn.mjs) so the
118
- // §7.3 child-env scrubbing + argv contract match spawnRouted exactly.
139
+ // the ledger row ↔ resume marker ↔ routing audit. The argv + child env are
140
+ // built from the decision by the runtime adapter (lib/runtime/adapter.mjs),
141
+ // whose retarget env goes through the same runtime env builder
142
+ // (lib/runtime/child-env.mjs) that spawnRouted uses, so the credential scrub
143
+ // matches spawnRouted exactly.
119
144
  //
120
145
  // NOTE on spawnRouted: the daemon's spawnSession() must register the child
121
146
  // process SYNCHRONOUSLY (tests + getStatus() observe active_sessions right after
@@ -135,7 +160,6 @@ import {
135
160
  modelFlagFor,
136
161
  resolveChain,
137
162
  } from "../../lib/model-router.mjs";
138
- import { buildSpawnArgs, buildChildEnv } from "../../lib/model-router/spawn.mjs";
139
163
  import { budgetLadder, spawnKnobsFor } from "../../lib/model-router/economics.mjs";
140
164
  // Observability spine (WS — diagnostics). The dispatcher emits `dispatched` at
141
165
  // spawn and `session_opened`/`session_closed` from the existing close hook,
@@ -143,6 +167,126 @@ import { budgetLadder, spawnKnobsFor } from "../../lib/model-router/economics.mj
143
167
  // AsyncLocalStorage cannot cross the proc-event boundary) + the v2 decision_id.
144
168
  // emitEvent is fail-open (never throws), so this is purely additive telemetry.
145
169
  import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
170
+ import {
171
+ createRouterSync,
172
+ routingKey as deriveRoutingKey,
173
+ routerItemFromDaemonItem,
174
+ claimSession,
175
+ releaseSession,
176
+ } from "./lib/session-router.mjs";
177
+ import { replyTier } from "../../lib/assurance/tier.mjs";
178
+ import { activeEntryFor, linkBoardTask } from "../../lib/session/current-work.mjs";
179
+
180
+ /**
181
+ * The reply tier comes from `lib/assurance/tier.mjs` (design §5.1) — the ONE
182
+ * definition, imported statically now that both halves of the design have
183
+ * merged.
184
+ *
185
+ * There used to be a `localReplyTier` here carrying "the same table" for the
186
+ * window in which this package shipped before the acknowledgement package. It
187
+ * was not the same table: it predated the review fix that added
188
+ * `willSpawnSession !== true` to rule (2), so an item the quick path fell
189
+ * through on would have been tiered `answer` — total silence — while a long
190
+ * session ran. The duplicate's own test ("localReplyTier agrees with
191
+ * lib/assurance/tier.mjs wherever that module exists") is what caught it at
192
+ * the merge, which is the only reason the divergence is a footnote and not an
193
+ * outage. Two copies of one decision is the bug; there is now one.
194
+ */
195
+
196
+ /** The reply tier → the router task class that carries its effort knobs. */
197
+ const TASK_CLASS_BY_TIER = Object.freeze({
198
+ answer: "session.answer",
199
+ work: "session.responder",
200
+ plan: "session.plan",
201
+ });
202
+
203
+ /** Rungs, as `lib/execution/route.RUNGS` numbers them. */
204
+ const ANSWER_MAX_RUNG = 1;
205
+ const PLAN_MIN_RUNG = 3;
206
+
207
+ /**
208
+ * A rung worth reasoning from, or null. An out-of-range value, a string "3" or
209
+ * a NaN is a stale field, and reading one as a rung would let a typo pick the
210
+ * turn's effort. Same coercion as `lib/assurance/tier.mjs#routedRung`.
211
+ */
212
+ function routedRung(rung) {
213
+ if (typeof rung !== "number" || !Number.isInteger(rung)) return null;
214
+ return rung < 0 || rung > 5 ? null : rung;
215
+ }
216
+
217
+
218
+ /**
219
+ * The routed execution rung for an item, or null.
220
+ *
221
+ * `agent-daemon.mjs:617` stamps the ladder's decision onto `item.execution`
222
+ * precisely so everything downstream can see which rung the turn is serving.
223
+ * The classifier emits no `rung` field of its own, so reading `classResult.rung`
224
+ * finds `undefined` forever — which would make `session.answer`'s knobs dead
225
+ * code and the tier table a decoration.
226
+ */
227
+ export function rungForItem(item) {
228
+ const ex = item && typeof item === "object" ? item.execution : null;
229
+ if (ex && typeof ex === "object") {
230
+ const r = routedRung(ex.rung);
231
+ if (r != null) return r;
232
+ }
233
+ return routedRung(item && item.rung);
234
+ }
235
+
236
+ /**
237
+ * The task class for this spawn. Was hardcoded `"session.responder"`, so every
238
+ * session took the same 40-turn, null-effort knobs no matter what was asked
239
+ * (design §3 R13).
240
+ *
241
+ * @param {object} classResult
242
+ * @param {object} [item] the inbox item, which carries the ladder's decision
243
+ */
244
+ export function taskClassFor(classResult, item = null) {
245
+ const c = classResult || {};
246
+ const args = {
247
+ answerable: c.answerable,
248
+ action: c.action,
249
+ priority: c.priority,
250
+ rung: rungForItem(item) ?? routedRung(c.rung),
251
+ // DELIBERATELY LEFT UNKNOWN, even though this function is only ever called
252
+ // on the spawn path and `true` is the literally accurate value.
253
+ //
254
+ // `replyTier` answers two different questions and the spawn flag matters to
255
+ // only one of them:
256
+ //
257
+ // "will the reply arrive in this turn?" — the ACKNOWLEDGEMENT question.
258
+ // A spawning item must never be tiered `answer`, because `answer`
259
+ // means total silence and the premise (an in-turn reply) is false.
260
+ // `assurance.mjs` asserts `willSpawnSession: true` for exactly that.
261
+ //
262
+ // "how hard is this ask?" — the EFFORT question, ours.
263
+ // Whether a session is spawning says nothing about it. A cheap,
264
+ // answerable, rung-0 ask deserves `session.answer`'s knobs whether it
265
+ // is answered in-turn or in a session.
266
+ //
267
+ // Asserting `true` here collapsed the two: rule (2) requires
268
+ // `willSpawnSession !== true`, so on the spawn path — the only path this
269
+ // function has — NOTHING could ever reach the `answer` tier and the
270
+ // `session.answer` effort class became unreachable. Caught at the merge by
271
+ // "taskClassFor maps each tier onto the class that carries its knobs".
272
+ // Leaving it unknown asks the tier the question this caller actually has.
273
+ willSpawnSession: undefined,
274
+ };
275
+ // A tier module that throws must not stop a dispatch: `work` is the tier the
276
+ // flood was made of and the one that speaks at most once, so it is the right
277
+ // thing to fall back to.
278
+ let tier = "work";
279
+ try { tier = replyTier(args) || "work"; } catch { tier = "work"; }
280
+ return TASK_CLASS_BY_TIER[tier] || "session.responder";
281
+ }
282
+
283
+ /**
284
+ * The session-continuity registry, shared with `responder.mjs` — same file,
285
+ * same key function, same decision. Synchronous because `spawnSession` builds
286
+ * its argv and spawns in one pass; see `createRouterSync`'s note.
287
+ */
288
+ const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
289
+ const sessionRouter = createRouterSync({ registryPath: SESSION_REGISTRY_PATH });
146
290
  // Lazy + cached so a misconfigured YAML doesn't break agents that didn't
147
291
  // opt in. The cache is invalidated only on daemon restart.
148
292
  let _routingConfigCache;
@@ -245,14 +389,15 @@ export function parseUsageFromText(text) {
245
389
  }
246
390
  }
247
391
  if (!obj || typeof obj !== "object") return { ok: false, reason: "no-json-object" };
392
+ const cohortMeta = cohortResultMeta(obj);
248
393
  const usage = obj.usage && typeof obj.usage === "object" ? obj.usage : null;
249
- if (!usage) return { ok: false, reason: "no-usage-field" };
394
+ if (!usage) return { ok: false, reason: "no-usage-field", ...cohortMeta };
250
395
  const inputTokens = Number(usage.input_tokens);
251
396
  const outputTokens = Number(usage.output_tokens);
252
397
  if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) {
253
- return { ok: false, reason: "non-numeric-tokens" };
398
+ return { ok: false, reason: "non-numeric-tokens", ...cohortMeta };
254
399
  }
255
- const out = { ok: true, inputTokens, outputTokens };
400
+ const out = { ok: true, inputTokens, outputTokens, ...cohortMeta };
256
401
  const cacheRead = Number(usage.cache_read_input_tokens);
257
402
  if (Number.isFinite(cacheRead)) out.cacheReadTokens = cacheRead;
258
403
  // Cache CREATION tokens bill at 1.25x input and were previously dropped on
@@ -261,14 +406,35 @@ export function parseUsageFromText(text) {
261
406
  // the CLI's total_cost_usd.
262
407
  const cacheWrite = Number(usage.cache_creation_input_tokens);
263
408
  if (Number.isFinite(cacheWrite)) out.cacheWriteTokens = cacheWrite;
264
- const totalCost = Number(obj.total_cost_usd);
265
- if (Number.isFinite(totalCost) && totalCost >= 0) out.totalCostUsd = totalCost;
409
+ // A NUMBER, not anything Number() will coerce. The Cohort Engine writes
410
+ // `total_cost_usd: null` when not every request reported gateway cost
411
+ // (lib/engine/output/json.mjs), and `Number(null)` is 0 — finite, >= 0 —
412
+ // which recorded such a session as $0.00 authoritative: the free-looking
413
+ // row this whole parser exists to never write. The stock CLI always sends a
414
+ // number, so the coercion was harmless until the engine port made null a
415
+ // value this field can carry. (engine-envelope-parity.test.mjs)
416
+ const totalCost = obj.total_cost_usd;
417
+ if (typeof totalCost === "number" && Number.isFinite(totalCost) && totalCost >= 0) out.totalCostUsd = totalCost;
266
418
  if (typeof obj.model === "string" && obj.model) {
267
419
  out.model = /opus/i.test(obj.model) ? "opus" : /haiku/i.test(obj.model) ? "haiku" : "sonnet";
268
420
  }
269
421
  return out;
270
422
  }
271
423
 
424
+ /**
425
+ * What a Cohort Engine result says about the gateway that served it: the
426
+ * `cohort` block (lib/engine/output/json.mjs) carries the gateway request ids.
427
+ * PURE. Empty for a claude result.
428
+ * @param {object} obj the parsed run result
429
+ * @returns {{servedByCohort?:true, requestIds?:string[]}}
430
+ */
431
+ export function cohortResultMeta(obj) {
432
+ const c = obj && typeof obj === "object" && obj.cohort && typeof obj.cohort === "object" ? obj.cohort : null;
433
+ if (!c) return {};
434
+ const ids = Array.isArray(c.requestIds) ? c.requestIds.filter((x) => typeof x === "string" && x) : [];
435
+ return { servedByCohort: true, requestIds: ids };
436
+ }
437
+
272
438
  /**
273
439
  * Append a TRUTHFUL cost-ledger row for a finished dispatcher session via
274
440
  * scripts/cost/track-claude-usage.mjs (source "dispatcher"). Real token counts
@@ -284,7 +450,7 @@ export function parseUsageFromText(text) {
284
450
  *
285
451
  * Best-effort + detached; never blocks the close path.
286
452
  */
287
- function recordDispatcherCost({ stdout, model, durationMs, exitCode, decisionId }) {
453
+ function recordDispatcherCost({ stdout, model, durationMs, exitCode, decisionId, billedBy = null }) {
288
454
  const usage = parseUsageFromText(stdout);
289
455
  try {
290
456
  const trackerPath = join(AGENT_REPO_DIR, "scripts/cost/track-claude-usage.mjs");
@@ -301,6 +467,10 @@ function recordDispatcherCost({ stdout, model, durationMs, exitCode, decisionId
301
467
  // routing decision + resume marker (SPEC §4.7). The tracker captures it as a
302
468
  // generic flag; absent on the v1 / no-config paths.
303
469
  if (decisionId) trackerArgs.push("--decision-id", String(decisionId));
470
+ // A session cohort-llm served was metered by the gateway: mark the row so
471
+ // org cost-sync never reports it twice, and keep the gateway request ids.
472
+ if (billedBy || usage.servedByCohort) trackerArgs.push("--billed-by", String(billedBy || COHORT_LLM_BILLED_BY));
473
+ if (Array.isArray(usage.requestIds) && usage.requestIds.length) trackerArgs.push("--request-ids", usage.requestIds.join(","));
304
474
  if (usage.ok) {
305
475
  trackerArgs.push("--input-tokens", String(usage.inputTokens));
306
476
  trackerArgs.push("--output-tokens", String(usage.outputTokens));
@@ -586,6 +756,21 @@ export function reconcileResumePending(opts = {}) {
586
756
  continue;
587
757
  }
588
758
 
759
+ // cohort-llm admission: a marker whose work now routes to cohort-llm is not
760
+ // resumed into a refused quota. A deferral is not a try (no strike).
761
+ // W4-E1: the admission (with the engine it resolved and its token) rides to the spawn.
762
+ let admission = { ok: true };
763
+ {
764
+ const admitResume = typeof opts.admitResume === "function" ? opts.admitResume : defaultAdmitResume;
765
+ try { admission = admitResume({ marker }) || { ok: true }; } catch { admission = { ok: true }; }
766
+ if (!admission.ok) {
767
+ const d = admission.deferral || {};
768
+ logSession({ event: "resume_cohort_deferred", sessionId: marker.sessionId, item_id: marker.itemId, code: d.code || null, kind: d.kind || null, until: Number.isFinite(d.until) ? new Date(d.until).toISOString() : null, cold: admission.cold === true });
769
+ stats.deferred++;
770
+ continue;
771
+ }
772
+ }
773
+
589
774
  // Bump the attempt count on the marker BEFORE re-dispatch so a crash mid-
590
775
  // resume still advances toward the 3-strike cap (no infinite loop).
591
776
  marker.recoveryAttempts = attempts + 1;
@@ -597,7 +782,7 @@ export function reconcileResumePending(opts = {}) {
597
782
 
598
783
  const spawnResume = typeof opts.spawnResume === "function" ? opts.spawnResume : defaultSpawnResume;
599
784
  try {
600
- spawnResume({ marker });
785
+ spawnResume({ marker, admission });
601
786
  logSession({ event: "resume_dispatched", sessionId: marker.sessionId, claudeSessionId: marker.claudeSessionId, item_id: marker.itemId, attempt: marker.recoveryAttempts });
602
787
  stats.resumed++;
603
788
  } catch (err) {
@@ -652,9 +837,12 @@ export function resumeModelFlag(marker, target) {
652
837
  * [--print, --output-format json, <permission args>, <daemon args>,
653
838
  * <router knobs>, --session-id <id>, --model <flag>, <prompt>]
654
839
  *
655
- * and the same env rule: a retarget (envForSpawn carries ANTHROPIC_BASE_URL)
656
- * goes through buildChildEnv's §7.3 credential scrub; the Anthropic path keeps
657
- * the explicit empty-key posture (keychain / OAuth-token fallthrough).
840
+ * and the same env rule (both built by the runtime adapter): a retarget
841
+ * (envForSpawn carries ANTHROPIC_BASE_URL) goes through the runtime env
842
+ * builder's credential scrub — foreign keys AND the seat's OAuth token — and a
843
+ * retarget without a third-party token is refused and resumed on the stock
844
+ * path instead (`refusedRetarget`); the Anthropic path keeps the explicit
845
+ * empty-key posture (keychain / OAuth-token fallthrough).
658
846
  * MAESTRO_SESSION_ID rides the env so delivery receipts attribute correctly.
659
847
  *
660
848
  * @param {object} o
@@ -670,31 +858,67 @@ export function buildResumeSpawn(o = {}) {
670
858
  const marker = o.marker || {};
671
859
  const target = o.target && typeof o.target === "object" ? o.target : null;
672
860
  const pathFn = typeof o.augmentedPath === "function" ? o.augmentedPath : augmentedPath;
673
- const modelFlag = resumeModelFlag(marker, target);
674
- const knobArgs = [];
675
- if (target) {
676
- if (target.maxTurns != null && Number.isFinite(Number(target.maxTurns))) knobArgs.push("--max-turns", String(target.maxTurns));
677
- if (target.effort) knobArgs.push("--effort", String(target.effort));
678
- if (target.agentsJson) knobArgs.push("--agents", typeof target.agentsJson === "string" ? target.agentsJson : JSON.stringify(target.agentsJson));
679
- }
680
- const args = [
681
- "--print",
682
- "--output-format", "json",
683
- ...(Array.isArray(o.permissionArgs) ? o.permissionArgs : []),
684
- ...(Array.isArray(o.daemonArgs) ? o.daemonArgs : []),
685
- ...knobArgs,
686
- "--session-id", marker.claudeSessionId,
687
- "--model", modelFlag,
688
- marker.prompt,
689
- ];
861
+ // argv + env come from the runtime adapter (lib/runtime/adapter.mjs, lane
862
+ // "resume"): the same contract as a fresh dispatcher spawn, built in one place.
690
863
  const base = o.baseEnv && typeof o.baseEnv === "object" ? o.baseEnv : {};
691
- const envForSpawn = (target && target.envForSpawn) || {};
692
- const retargeting = !!envForSpawn.ANTHROPIC_BASE_URL;
693
- const env = retargeting
694
- ? { ...buildChildEnv({ ...base, PATH: pathFn() }, envForSpawn), PATH: pathFn() }
695
- : { ...base, PATH: pathFn(), ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", ...envForSpawn };
696
- if (marker.sessionId) env.MAESTRO_SESSION_ID = String(marker.sessionId);
697
- return { args, env, modelFlag, retargeting };
864
+ const daemonArgs = Array.isArray(o.daemonArgs) ? o.daemonArgs : [];
865
+ const deps = {
866
+ augmentedPath: pathFn,
867
+ daemonArgs: () => daemonArgs,
868
+ resolveBin: typeof o.resolveBin === "function" ? o.resolveBin : () => CLAUDE_BIN,
869
+ };
870
+ // W4-E1: engine cohort continues the same --session-id through `cli.mjs run`
871
+ // (never --resume), on the marker's Anthropic slot mapped to a tier, with no retarget.
872
+ const engine = o.engine === "cohort" ? "cohort" : "claude";
873
+ if (engine === "cohort") {
874
+ const shape = engineRunShape({ target, laneModel: marker.model || "sonnet" });
875
+ const spec = buildSpawn({
876
+ lane: "resume",
877
+ model: shape.model,
878
+ prompt: marker.prompt,
879
+ sessionId: marker.claudeSessionId,
880
+ permissions: Array.isArray(o.permissionArgs) ? o.permissionArgs : [],
881
+ mcp: {},
882
+ knobs: shape.knobs,
883
+ env: base,
884
+ retarget: shape.retarget,
885
+ extraEnv: { MAESTRO_SESSION_ID: marker.sessionId || undefined },
886
+ ...seatEngineFields({ engine, agentRoot: o.agentRoot || null, token: o.cohortToken || null, baseUrl: o.cohortBaseUrl || null }),
887
+ ...(o.maxBudgetUsd != null ? { maxBudgetUsd: o.maxBudgetUsd } : {}),
888
+ }, deps);
889
+ if (!spec.ok) throw new Error(`buildResumeSpawn: ${spec.error.message}`);
890
+ return { args: spec.argv, env: spec.env, bin: spec.bin, modelFlag: shape.model, retargeting: false, refusedRetarget: null, engine };
891
+ }
892
+ const spawnInput = (t, flag) => ({
893
+ lane: "resume",
894
+ model: flag,
895
+ prompt: marker.prompt,
896
+ sessionId: marker.claudeSessionId,
897
+ permissions: Array.isArray(o.permissionArgs) ? o.permissionArgs : [],
898
+ mcp: {},
899
+ knobs: t,
900
+ env: base,
901
+ retarget: (t && t.envForSpawn) || {},
902
+ extraEnv: { MAESTRO_SESSION_ID: marker.sessionId || undefined },
903
+ });
904
+ let modelFlag = resumeModelFlag(marker, target);
905
+ let spec = buildSpawn(spawnInput(target, modelFlag), deps);
906
+ let refusedRetarget = null;
907
+ if (!spec.ok && /^retarget_/.test(spec.error.code)) {
908
+ // The retarget was refused (no third-party token): resume on the stock
909
+ // Anthropic path instead of handing that backend the seat's own
910
+ // credential. resumeModelFlag with a non-retarget target keeps F11 — a
911
+ // foreign pinned id is never passed to Anthropic.
912
+ refusedRetarget = spec.error;
913
+ // CF-11: the Anthropic rerun must not inherit the third-party decision's
914
+ // knobs (--max-turns/--effort/--agents were sized for that backend).
915
+ const stock = { ...target, envForSpawn: {}, modelFlag: null, maxTurns: null, effort: null, agentsJson: null };
916
+ modelFlag = resumeModelFlag(marker, stock);
917
+ spec = buildSpawn(spawnInput(stock, modelFlag), deps);
918
+ }
919
+ // A claude resume past the retarget fallback cannot fail: a failure is a defect.
920
+ if (!spec.ok) throw new Error(`buildResumeSpawn: ${spec.error.message}`);
921
+ return { args: spec.argv, env: spec.env, bin: spec.bin, modelFlag, retargeting: spec.retargeting, refusedRetarget, engine };
698
922
  }
699
923
 
700
924
  /**
@@ -713,7 +937,7 @@ export function buildResumeSpawn(o = {}) {
713
937
  * and the fresh prompt continues the work.
714
938
  *
715
939
  * Audit F11: the argv and env are built through the SAME resolveSpawnTarget /
716
- * buildChildEnv path as spawnSession (buildResumeSpawn), so a retargeted
940
+ * runtime-adapter path as spawnSession (buildResumeSpawn), so a retargeted
717
941
  * session resumes on its backend, the router's knobs ride along, and
718
942
  * `--output-format json` makes the run's usage measurable for the ledger.
719
943
  *
@@ -721,41 +945,85 @@ export function buildResumeSpawn(o = {}) {
721
945
  * Best-effort; failures are logged, the marker stays (next reconcile retries
722
946
  * up to the strike cap). On a clean close the marker is cleared here.
723
947
  */
724
- function defaultSpawnResume({ marker }) {
948
+ /**
949
+ * The router's CURRENT decision for a resume marker. resolveSpawnTarget never
950
+ * throws (it degrades to the v1 path / stock CLI internally); the request is
951
+ * rebuilt from what the marker kept of the original classification. null when
952
+ * there is no router or it faulted.
953
+ */
954
+ function resumeTargetFor(marker) {
725
955
  const source = marker.source || "backlog";
726
- // The router's CURRENT decision for this work. resolveSpawnTarget never
727
- // throws (it degrades to the v1 path / stock CLI internally); the request is
728
- // rebuilt from what the marker kept of the original classification.
729
- let target = null;
730
956
  try {
731
957
  const routingConfig = getRoutingConfig();
732
- if (routingConfig) {
733
- const classResult = { model: marker.model || "sonnet", priority: marker.priority || "normal", summary: marker.summary || null };
734
- const routingRequest = requestFromClassifierResult(classResult, { source, role: "responder" });
735
- target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, marker.modelFlag || marker.model || "sonnet");
736
- }
958
+ if (!routingConfig) return null;
959
+ const classResult = { model: marker.model || "sonnet", priority: marker.priority || "normal", summary: marker.summary || null };
960
+ const routingRequest = requestFromClassifierResult(classResult, { source, role: "responder" });
961
+ return resolveSpawnTarget(routingConfig, routingRequest, classResult, source, marker.modelFlag || marker.model || "sonnet");
737
962
  } catch (err) {
738
963
  // A router fault must not block a resume: fall back to the marker's flag.
739
964
  console.warn(`[dispatcher] resume routing failed for ${marker.sessionId}, using marker flag: ${err.message}`);
740
- target = null;
965
+ return null;
966
+ }
967
+ }
968
+
969
+ /** The cohort-llm gate for a resume; a cold cache primes itself for the next reconcile. */
970
+ function defaultAdmitResume({ marker }) {
971
+ const target = resumeTargetFor(marker);
972
+ // W4-E1: the engine is resolved once here and rides the admission to the spawn.
973
+ const engine = readSeatEngine(AGENT_REPO_DIR);
974
+ const gate = cohortGateFor(target, engine);
975
+ if (!gate.ok && gate.cold) {
976
+ const c = getCohortClients();
977
+ if (c && c.quota) Promise.resolve().then(() => c.quota.canAdmit()).catch(() => null);
741
978
  }
742
- const { args, env: spawnEnv, modelFlag } = buildResumeSpawn({
979
+ if (gate.ok && engine === "cohort") {
980
+ // An engine-cohort resume starts with a seat token. None cached (a cleared
981
+ // cache) is a short retry, exactly as a fresh dispatch parks it: a deferral,
982
+ // not a try, so the marker takes no strike. Mint one for the next reconcile.
983
+ const token = gate.token || seatTokenSync(getCohortClients()?.token);
984
+ if (!token) {
985
+ const tm = getCohortClients()?.token;
986
+ if (tm && typeof tm.getToken === "function") Promise.resolve().then(() => tm.getToken()).catch(() => null);
987
+ return { ok: false, cohort: true, engine, deferral: { kind: "retry", until: Date.now() + 30_000, code: "cohort_token_unavailable", message: "no cohort-llm seat token for the engine", refusal: null } };
988
+ }
989
+ return { ...gate, engine, token };
990
+ }
991
+ return { ...gate, engine };
992
+ }
993
+
994
+ function defaultSpawnResume({ marker, admission = null }) {
995
+ const target = resumeTargetFor(marker);
996
+ // W4-E1: the engine the admission resolved (else read now), its token, and the envelope's cap.
997
+ const engine = admission && (admission.engine === "cohort" || admission.engine === "claude") ? admission.engine : readSeatEngine(AGENT_REPO_DIR);
998
+ const engineFields = engine === "cohort"
999
+ ? { engine, agentRoot: AGENT_REPO_DIR, cohortToken: (admission && admission.token) || seatTokenSync(getCohortClients()?.token), cohortBaseUrl: getCohortClients()?.token?.baseUrl || null, ...(() => { const b = engineSessionBudget(); return b.maxBudgetUsd != null ? { maxBudgetUsd: b.maxBudgetUsd } : {}; })() }
1000
+ : {};
1001
+ const { args, env: spawnEnv, bin, modelFlag, refusedRetarget } = buildResumeSpawn({
743
1002
  marker,
744
1003
  target,
745
1004
  baseEnv: process.env,
746
1005
  permissionArgs: sessionPermissionArgs({ source: "dispatcher-resume", priority: marker.priority }),
747
1006
  daemonArgs: daemonClaudeArgs(),
1007
+ ...engineFields,
748
1008
  });
1009
+ if (refusedRetarget) {
1010
+ logSession({ event: "resume_retarget_refused", sessionId: marker.sessionId, code: refusedRetarget.code, error: refusedRetarget.message });
1011
+ }
1012
+ // An engine-cohort seat is cohort-served whatever routing picked (the same test
1013
+ // cohortGateFor applies), so its refusals key the "cohort" backend.
1014
+ const resumeEngineCohort = engine === "cohort";
1015
+ const resumeServedByCohort = resumeEngineCohort || (!refusedRetarget && isCohortRouted({ target }));
1016
+ const resumeBreaker = resumeServedByCohort ? rateGuardModule.COHORT_BACKEND : RATE_PROVIDER;
749
1017
  const decisionId = (target && target.decisionId) || marker.decision_id || null;
750
1018
  const startedAt = Date.now();
751
- const proc = _spawn(CLAUDE_BIN, args, { cwd: AGENT_REPO_DIR, env: spawnEnv, stdio: ["ignore", "pipe", "pipe"] });
1019
+ const proc = _spawn(bin, args, { cwd: AGENT_REPO_DIR, env: spawnEnv, stdio: ["ignore", "pipe", "pipe"] });
752
1020
  let stdout = "";
753
1021
  let stderr = "";
754
1022
  proc.stdout.on("data", (c) => { stdout += c.toString(); });
755
1023
  proc.stderr.on("data", (c) => { stderr += c.toString(); });
756
1024
  proc.on("close", (code) => {
757
1025
  // A resumed run costs money too: parse its --output-format json usage.
758
- try { recordDispatcherCost({ stdout, model: marker.model, durationMs: Date.now() - startedAt, exitCode: code, decisionId }); }
1026
+ try { recordDispatcherCost({ stdout, model: marker.model, durationMs: Date.now() - startedAt, exitCode: code, decisionId, billedBy: resumeServedByCohort ? COHORT_LLM_BILLED_BY : null }); }
759
1027
  catch { /* cost tracking is best-effort */ }
760
1028
  if (code === 0) {
761
1029
  // Resume succeeded — the underlying work is done; clear the marker.
@@ -764,11 +1032,18 @@ function defaultSpawnResume({ marker }) {
764
1032
  } else {
765
1033
  // A usage limit or 429 during resume must open the breaker so subsequent
766
1034
  // spawns gate. Usage-limit → hold until reset; 429 → transient backoff.
767
- const ul = rateGuard.classifyUsageLimit?.(stderr, { agentRoot: AGENT_REPO_DIR }) ?? { isLimit: false, resetAt: null };
768
- if (ul.isLimit) {
769
- try { rateGuard.recordUsageLimit(RATE_PROVIDER, ul.resetAt, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
1035
+ // Structured cohort refusal FIRST; an engine-cohort lane never reads the
1036
+ // English subscription-limit text (see the spawn path below).
1037
+ const resumeRefusal = resumeServedByCohort ? (rateGuard.classifyCohortRefusal?.(`${stdout}\n${stderr}`) ?? null) : null;
1038
+ const resumeUlText = resumeRefusal ? { isLimit: false, resetAt: null } : (rateGuard.classifyUsageLimit?.(stderr, { agentRoot: AGENT_REPO_DIR }) ?? { isLimit: false, resetAt: null });
1039
+ const ul = resumeEngineCohort ? { isLimit: false, resetAt: null } : resumeUlText;
1040
+ if (resumeEngineCohort && resumeUlText.isLimit) logSession({ event: "usage_limit_text_ignored", sessionId: marker.sessionId, engine: "cohort" });
1041
+ if (resumeRefusal) {
1042
+ try { rateGuard.recordCohortRefusal?.(resumeRefusal, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
1043
+ } else if (ul.isLimit) {
1044
+ try { rateGuard.recordUsageLimit(resumeBreaker, ul.resetAt, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
770
1045
  } else if (rateGuard.classifyStderr(stderr)) {
771
- try { rateGuard.recordRateLimit(RATE_PROVIDER, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
1046
+ try { rateGuard.recordRateLimit(resumeBreaker, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
772
1047
  }
773
1048
  logSession({ event: "resume_exit_nonzero", sessionId: marker.sessionId, item_id: marker.itemId, exit_code: code });
774
1049
  }
@@ -778,18 +1053,29 @@ function defaultSpawnResume({ marker }) {
778
1053
  });
779
1054
  }
780
1055
 
1056
+ /**
1057
+ * The entry shape is `lib/session/current-work.activeEntryFor` — the SAME
1058
+ * module that reads active.json back into the "what am I doing right now"
1059
+ * block, so the writer and the reader cannot drift. It carries the Cohort ask
1060
+ * identity (channelId + messageId) so the board row hq opens for an inbox ask
1061
+ * can be linked to this session ({@link linkActiveSessionBoard}), and the
1062
+ * board item id when a backlog row is for one.
1063
+ */
781
1064
  function writeActiveSession(sessionId, entry) {
782
1065
  try {
783
1066
  let active = {};
784
1067
  try { active = JSON.parse(readFileSync(ACTIVE_PATH, "utf-8")); } catch {}
785
- active[sessionId] = {
786
- sender: entry.item?.sender || null,
787
- channel: entry.item?.channel || entry.item?.channel_id || null,
788
- summary: entry.classResult?.summary || "unknown",
789
- model: entry.classResult?.model || "sonnet",
790
- startTime: Date.now(),
791
- source: entry.source,
792
- };
1068
+ active[sessionId] = activeEntryFor({ item: entry.item, classResult: entry.classResult, source: entry.source, now: Date.now() });
1069
+ // A board row that resolved BEFORE the spawn (the daemon's `working` track
1070
+ // is fire-and-forget and can win the race) is applied now, not lost.
1071
+ const ask = active[sessionId].ask;
1072
+ if (ask) {
1073
+ const pending = pendingBoardLinks.get(`${ask.channelId}:${ask.messageId}`);
1074
+ if (pending) {
1075
+ active[sessionId] = { ...active[sessionId], boardTaskId: pending.taskId, boardCol: pending.col || null };
1076
+ pendingBoardLinks.delete(`${ask.channelId}:${ask.messageId}`);
1077
+ }
1078
+ }
793
1079
  const tmpPath = ACTIVE_PATH + ".tmp";
794
1080
  writeFileSync(tmpPath, JSON.stringify(active, null, 2));
795
1081
  renameSync(tmpPath, ACTIVE_PATH);
@@ -798,6 +1084,45 @@ function writeActiveSession(sessionId, entry) {
798
1084
  }
799
1085
  }
800
1086
 
1087
+ /** Board rows that resolved for an ask whose session is not in active.json yet. Bounded. */
1088
+ const pendingBoardLinks = new Map();
1089
+ const PENDING_BOARD_LINKS_MAX = 64;
1090
+
1091
+ /**
1092
+ * Attach the board row hq opened for an ask to the live session working it,
1093
+ * so the current-work block can cite the item id. Called by the daemon when
1094
+ * its `working` board.track resolves with a taskId (agent-daemon.mjs).
1095
+ *
1096
+ * PURE core (`linkBoardTask`) + one atomic rewrite of active.json. Fail-open:
1097
+ * a board link must never break a dispatch. When no live session carries the
1098
+ * ask yet (the track won the race against the spawn), the link is parked and
1099
+ * applied by the next writeActiveSession for that ask.
1100
+ *
1101
+ * @param {{channelId:string, messageId:string}} ask
1102
+ * @param {{taskId:string, col?:string|null}} row
1103
+ * @returns {{linked:boolean, sessionId:string|null, parked:boolean}}
1104
+ */
1105
+ export function linkActiveSessionBoard(ask, row) {
1106
+ try {
1107
+ if (!ask || !ask.channelId || !ask.messageId || !row || !row.taskId) return { linked: false, sessionId: null, parked: false };
1108
+ let active = {};
1109
+ try { active = JSON.parse(readFileSync(ACTIVE_PATH, "utf-8")); } catch {}
1110
+ const r = linkBoardTask(active, ask, row);
1111
+ if (!r.sessionId) {
1112
+ if (pendingBoardLinks.size >= PENDING_BOARD_LINKS_MAX) pendingBoardLinks.delete(pendingBoardLinks.keys().next().value);
1113
+ pendingBoardLinks.set(`${ask.channelId}:${ask.messageId}`, { taskId: String(row.taskId), col: row.col || null });
1114
+ return { linked: false, sessionId: null, parked: true };
1115
+ }
1116
+ const tmpPath = ACTIVE_PATH + ".tmp";
1117
+ writeFileSync(tmpPath, JSON.stringify(r.active, null, 2));
1118
+ renameSync(tmpPath, ACTIVE_PATH);
1119
+ return { linked: true, sessionId: r.sessionId, parked: false };
1120
+ } catch (err) {
1121
+ console.warn(`[dispatcher] Failed to link board row to session: ${err.message}`);
1122
+ return { linked: false, sessionId: null, parked: false };
1123
+ }
1124
+ }
1125
+
801
1126
  function removeActiveSession(sessionId) {
802
1127
  try {
803
1128
  let active = {};
@@ -965,6 +1290,13 @@ export function canDispatchBacklog(item) {
965
1290
  * fires onClose once it is later drained and spawned). It does NOT fire on the
966
1291
  * pre-spawn drop paths (backlog dedup / claim-denied / governor-DEFER backlog),
967
1292
  * which leave the durable on-disk item untouched for the next sweep anyway.
1293
+ * @param {(o:{sessionId:string, item:object})=>void} [opts.onSpawn]
1294
+ * Invoked EXACTLY ONCE, when a child process has actually been started for
1295
+ * this entry, with the session id the dispatcher minted. This is the ONLY
1296
+ * truthful "started" signal: `dispatch()` returns silently on every not-now
1297
+ * path (rate-limited, governor QUEUE/DEFER, backlog cap, claim denied, the
1298
+ * cohort pre-spawn gate), and a caller that announced "started" at the call
1299
+ * site announced it for those too. Survives queueing like onClose.
968
1300
  */
969
1301
  export function dispatch(prompt, item, classResult, source = "inbox", opts = {}) {
970
1302
  // `obligationKey` names the debt this session was spawned to discharge. It is
@@ -977,6 +1309,8 @@ export function dispatch(prompt, item, classResult, source = "inbox", opts = {})
977
1309
  prompt, item, classResult, source,
978
1310
  obligationKey: opts.obligationKey || null,
979
1311
  onClose: typeof opts.onClose === "function" ? opts.onClose : null,
1312
+ onSpawn: typeof opts.onSpawn === "function" ? opts.onSpawn : null,
1313
+ cohortPrimed: opts.cohortPrimed === true,
980
1314
  };
981
1315
  const priority = classResult.priority;
982
1316
  const isPriorityInbox = source === "inbox" && (priority === "critical" || priority === "high");
@@ -1167,17 +1501,21 @@ function currentBudgetBand() {
1167
1501
  * @param {object} classResult
1168
1502
  * @param {string} source
1169
1503
  * @param {string} fallbackModel the coarse sonnet/opus class for the no-config path
1504
+ * @param {object} [item] the inbox item, which carries the routed rung
1170
1505
  */
1171
- function resolveSpawnTarget(routingConfig, routingRequest, classResult, source, fallbackModel) {
1506
+ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source, fallbackModel, item = null) {
1172
1507
  // No config at all → stock Claude-CLI-on-Max behaviour (unchanged).
1173
1508
  if (!routingConfig) {
1174
- return { modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1509
+ return { taskClass: taskClassFor(classResult, item), modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1175
1510
  }
1176
1511
 
1177
1512
  // v2 path — resolveChain produces a full RouteDecision.
1178
1513
  if (routingConfig.schema_version === 2) {
1179
1514
  try {
1180
- const taskClass = "session.responder";
1515
+ // The task class carries the spawn knobs (`spawnKnobsFor`), so hardcoding
1516
+ // one made `effort` always null and `maxTurns` always 40. It is now the
1517
+ // reply tier — see `taskClassFor`.
1518
+ const taskClass = taskClassFor(classResult, item);
1181
1519
  const req = {
1182
1520
  ...routingRequest,
1183
1521
  task_class: taskClass,
@@ -1194,6 +1532,7 @@ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source,
1194
1532
  const knobs = spawnKnobsFor(taskClass);
1195
1533
  const sa = decision.spawnArgs || {};
1196
1534
  return {
1535
+ taskClass,
1197
1536
  modelFlag: sa.modelFlag || decision.chosen.model || fallbackModel,
1198
1537
  envForSpawn: decision.envForSpawn || {},
1199
1538
  decisionId: decision.decision_id || null,
@@ -1216,9 +1555,10 @@ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source,
1216
1555
  // v1 path — resolveBackend → modelFlagFor (preserved verbatim).
1217
1556
  const resolved = resolveBackend(routingRequest, { config: routingConfig });
1218
1557
  if (!resolved) {
1219
- return { modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1558
+ return { taskClass: taskClassFor(classResult, item), modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1220
1559
  }
1221
1560
  return {
1561
+ taskClass: taskClassFor(classResult, item),
1222
1562
  modelFlag: modelFlagFor(resolved, routingRequest),
1223
1563
  envForSpawn: resolved.envForSpawn || {},
1224
1564
  decisionId: null,
@@ -1261,13 +1601,6 @@ function spawnSession(entry) {
1261
1601
  ? (source === "backlog" ? OPUS_BACKLOG_TIMEOUT : OPUS_INBOX_TIMEOUT)
1262
1602
  : (source === "backlog" ? SONNET_BACKLOG_TIMEOUT : SONNET_INBOX_TIMEOUT);
1263
1603
 
1264
- // Track backlog items to prevent retry storms
1265
- if (source === "backlog") {
1266
- const key = backlogKey(item);
1267
- activeBacklogKeys.add(key);
1268
- backlogRetryCount.set(key, (backlogRetryCount.get(key) || 0) + 1);
1269
- }
1270
-
1271
1604
  // Resolve the spawn target via the model router. v2 configs go through
1272
1605
  // resolveChain (full RouteDecision: model + retarget env + spawn knobs +
1273
1606
  // decision_id + estimated cost + failover chain); v1 configs keep the legacy
@@ -1275,80 +1608,160 @@ function spawnSession(entry) {
1275
1608
  // default exactly. NEVER throws (resolveSpawnTarget degrades internally).
1276
1609
  const routingConfig = getRoutingConfig();
1277
1610
  const routingRequest = requestFromClassifierResult(classResult, { source, role: "responder" });
1278
- const target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, model);
1279
- const effectiveModelFlag = target.modelFlag || model;
1280
-
1281
- // WS4: pre-mint a stable Claude session id so a crash/reboot mid-flight can
1282
- // be resumed deterministically with `claude --print --resume <id>`. (Same
1283
- // mechanism the responder already uses; safe in --print text mode.)
1284
- const claudeSessionId = randomUUID();
1285
-
1286
- // The v2 router supplies spawn knobs (SPEC §4.4 / §6.5): --max-turns bounds
1287
- // the session, --effort tunes reasoning where supported, --agents attaches the
1288
- // cheap-subagent (Haiku-Explore) fan-out map — the sanctioned intra-session
1289
- // cost lever that keeps the main loop's cache intact. v1 / no-config spawns
1290
- // carry none of these (knobs are null), so this is purely additive.
1291
- const knobArgs = [];
1292
- if (target.maxTurns != null && Number.isFinite(Number(target.maxTurns))) {
1293
- knobArgs.push("--max-turns", String(target.maxTurns));
1611
+ const target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, model, item);
1612
+ let effectiveModelFlag = target.modelFlag || model;
1613
+
1614
+ // cohort-llm pre-spawn gate (design C6 / §8.3). Stock Claude Code retries a
1615
+ // 429 ten times and ignores the gateway's quota headers, so a session that
1616
+ // cohort-llm would refuse must not start. Decided BEFORE any counter, claim
1617
+ // or lock below moves, so a deferral leaves no trace but the requeue. Human
1618
+ // replies are not exempt: a refused seat would 429 them just the same.
1619
+ //
1620
+ // W4-E1 (CF-44): the seat's engine is resolved ONCE for this spawn and used by
1621
+ // the gate, the spawn and the breaker alike. Read per spawn, so flipping
1622
+ // runtime.engine back to "claude" restores claude on the next spawn.
1623
+ const seatEngine = readSeatEngine(AGENT_REPO_DIR);
1624
+ const engineCohortSpawn = seatEngine === "cohort";
1625
+ let engineToken = null;
1626
+ {
1627
+ const gate = cohortGateFor(target, seatEngine);
1628
+ if (!gate.ok) {
1629
+ deferSpawnForCohort(entry, gate, target);
1630
+ return false;
1631
+ }
1632
+ if (engineCohortSpawn) {
1633
+ // The engine starts with the seat token the admission read. None (a
1634
+ // cleared cache) is a short retry, parked like any cohort deferral —
1635
+ // never a claude spawn on the seat's Anthropic credential.
1636
+ engineToken = gate.token || seatTokenSync(getCohortClients()?.token);
1637
+ if (!engineToken) {
1638
+ deferSpawnForCohort(entry, { ok: false, cohort: true, deferral: { kind: "retry", until: Date.now() + 30_000, code: "cohort_token_unavailable", message: "no cohort-llm seat token for the engine", refusal: null } }, target);
1639
+ return false;
1640
+ }
1641
+ }
1294
1642
  }
1295
- if (target.effort) knobArgs.push("--effort", String(target.effort));
1296
- if (target.agentsJson) {
1297
- knobArgs.push("--agents", typeof target.agentsJson === "string" ? target.agentsJson : JSON.stringify(target.agentsJson));
1643
+
1644
+ // Track backlog items to prevent retry storms
1645
+ if (source === "backlog") {
1646
+ const key = backlogKey(item);
1647
+ activeBacklogKeys.add(key);
1648
+ backlogRetryCount.set(key, (backlogRetryCount.get(key) || 0) + 1);
1298
1649
  }
1299
1650
 
1300
- const args = [
1301
- "--print",
1302
- // --output-format json so this run's stdout carries the REAL token usage we
1303
- // record to the cost ledger (recovery C1). The dispatcher's stdout is only
1304
- // tee'd to a per-session log file — it's never streamed to the user (the
1305
- // session sends its own user-facing messages via the Slack/Gmail APIs), so
1306
- // switching the format does not affect any reply.
1307
- "--output-format", "json",
1308
- ...sessionPermissionArgs({ source: "dispatcher", priority: classResult?.priority }),
1309
- ...daemonClaudeArgs(),
1310
- ...knobArgs,
1311
- "--session-id", claudeSessionId,
1312
- "--model", effectiveModelFlag,
1313
- prompt,
1314
- ];
1315
-
1316
- // Build the spawn env.
1317
- // Default (no router): strip ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN so
1318
- // `claude` falls through to the keychain OAuth (Max subscription) per
1319
- // CEO directive 2026-04-27.
1320
- // Router active: merge the resolved target's envForSpawn — this points the
1321
- // CLI at the chosen backend (Moonshot, OpenRouter, NIM, etc.) and sets
1322
- // ANTHROPIC_API_KEY="" explicitly so Claude Code doesn't fall back to
1323
- // OAuth against api.anthropic.com.
1651
+ // Session continuity. A fresh `randomUUID()` per dispatch meant every full
1652
+ // session in a live thread started COLD — the agent re-read the room, re-did
1653
+ // the orientation work, and answered a follow-up as if it were an opening
1654
+ // (design §3 R10). The router the responder already consults keys the
1655
+ // conversation; a follow-up inside the TTL resumes the same session id, which
1656
+ // in this daemon IS continuation (`--session-id <id> <prompt>`, never
1657
+ // `--resume` — see the resume-pending notes above).
1324
1658
  //
1325
- // For a v2 retarget (envForSpawn carries ANTHROPIC_BASE_URL), we build the
1326
- // child env through the execution layer's buildChildEnv so the §7.3 allowlist
1327
- // scrub applies (no foreign *_API_KEY / *_AUTH_TOKEN leaks into a third-party
1328
- // session). buildChildEnv already injects ANTHROPIC_API_KEY="" for a retarget.
1329
- // For the common Anthropic-session case (envForSpawn === {}) and the v1 path
1330
- // we keep the prior explicit empty-key env verbatim (byte-compatible).
1331
- const retargeting = !!(target.envForSpawn && target.envForSpawn.ANTHROPIC_BASE_URL);
1332
- const spawnEnv = retargeting
1333
- ? { ...buildChildEnv({ ...process.env, PATH: augmentedPath() }, target.envForSpawn), PATH: augmentedPath() }
1334
- : {
1335
- ...process.env,
1336
- PATH: augmentedPath(),
1337
- ANTHROPIC_API_KEY: "",
1338
- ANTHROPIC_AUTH_TOKEN: "",
1339
- ...(target.envForSpawn || {}),
1340
- };
1341
- // ATTRIBUTION for delivery receipts. Set after the merges above so a router
1342
- // target's envForSpawn can never drop it — a retargeted session replies
1343
- // through exactly the same CLI lanes and owes exactly the same receipt.
1344
- // These are plain identifiers, never credentials, so the §7.3 allowlist scrub
1345
- // in buildChildEnv has nothing to object to.
1346
- spawnEnv.MAESTRO_SESSION_ID = sessionId;
1347
- if (entry.obligationKey) spawnEnv.MAESTRO_OBLIGATION_KEY = String(entry.obligationKey);
1348
-
1349
- const proc = _spawn(CLAUDE_BIN, args, {
1659
+ // Inbox only. Backlog work is not a conversation, and keying it would put
1660
+ // unrelated items in one session.
1661
+ //
1662
+ // Everything here is fail-open: no key, an unreadable registry, or a throw
1663
+ // from `routingKey` all fall back to the fresh UUID, which is exactly the
1664
+ // behaviour being replaced. Crash-recovery resume is UNCHANGED — it still
1665
+ // rides `writeResumePending(..., claudeSessionId, ...)` below, with whichever
1666
+ // id this resolved to.
1667
+ //
1668
+ // AND ONE PROCESS PER KEY. `route()` refuses to resume a key this daemon
1669
+ // already has a child on, and the claim below is what tells it so. A burst in
1670
+ // a busy top-level channel — the measured shape: ~38 messages an hour in one
1671
+ // room, where the thread lock does not apply because there is no thread id —
1672
+ // would otherwise put three or four `claude --print --session-id <same id>`
1673
+ // processes on ONE transcript. The later turns spawn cold instead, which is
1674
+ // exactly the pre-router behaviour, and continuity returns on the next turn.
1675
+ let sessionRoutingKey = null;
1676
+ let resumedSessionId = null;
1677
+ if (source === "inbox") {
1678
+ const routerItem = routerItemFromDaemonItem(item);
1679
+ if (routerItem) {
1680
+ try {
1681
+ const candidateKey = deriveRoutingKey(routerItem);
1682
+ const decision = sessionRouter.route(candidateKey);
1683
+ if (claimSession(candidateKey)) {
1684
+ sessionRoutingKey = candidateKey;
1685
+ if (decision.decision === "RESUME" && decision.resumeId) resumedSessionId = decision.resumeId;
1686
+ } else {
1687
+ // Something is already running on this room. Spawn cold, and do NOT
1688
+ // hold the key — the running session owns the registry row, and the
1689
+ // second turn must not overwrite it on close.
1690
+ console.log(`[dispatcher] ${candidateKey} already in flight — spawning cold (no session reuse)`);
1691
+ }
1692
+ } catch (err) {
1693
+ console.warn(`[dispatcher] session routing key failed: ${err.message} — spawning cold`);
1694
+ sessionRoutingKey = null;
1695
+ }
1696
+ }
1697
+ }
1698
+ const claudeSessionId = resumedSessionId || randomUUID();
1699
+
1700
+ // argv + env come from the runtime adapter (lib/runtime/adapter.mjs, lane
1701
+ // "dispatcher"). What it builds, unchanged from the inline version:
1702
+ // argv --print --output-format json <permissions> <daemon MCP args>
1703
+ // <router knobs> --session-id <id> --model <flag> <prompt>
1704
+ // --output-format json so stdout carries the REAL token usage for the
1705
+ // cost ledger (recovery C1); the dispatcher's stdout only goes to a
1706
+ // per-session log, never to a user. The v2 knobs (--max-turns,
1707
+ // --effort, --agents Haiku-Explore) are null on v1 / no-config spawns.
1708
+ // env no router / an Anthropic decision: stock posture — strip
1709
+ // ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN so `claude` rides the seat's
1710
+ // subscription credential (CEO directive 2026-04-27).
1711
+ // A v2 retarget (envForSpawn carries ANTHROPIC_BASE_URL): the one
1712
+ // runtime env builder scrubs every foreign credential AND the seat's
1713
+ // OAuth token, and forces ANTHROPIC_API_KEY="" and
1714
+ // CLAUDE_CODE_ATTRIBUTION_HEADER=0.
1715
+ // MAESTRO_SESSION_ID / MAESTRO_OBLIGATION_KEY are set after every merge so a
1716
+ // router target can never drop the delivery-receipt attribution; they are
1717
+ // plain identifiers, never credentials.
1718
+ // W4-E1: an engine-cohort seat runs `cli.mjs run` — its tier from the router
1719
+ // only when the router chose cohort, no retarget, and a per-session spend cap
1720
+ // from the seat's funded envelope. A claude seat's input is exactly as before.
1721
+ const engineShape = engineCohortSpawn ? engineRunShape({ target, laneModel: model }) : null;
1722
+ const spawnInput = {
1723
+ lane: "dispatcher",
1724
+ bin: CLAUDE_BIN,
1725
+ model: engineShape ? engineShape.model : effectiveModelFlag,
1726
+ prompt,
1727
+ sessionId: claudeSessionId,
1728
+ permissions: sessionPermissionArgs({ source: "dispatcher", priority: classResult?.priority }),
1729
+ mcp: {},
1730
+ knobs: engineShape ? engineShape.knobs : target,
1731
+ env: process.env,
1732
+ retarget: engineShape ? engineShape.retarget : (target.envForSpawn || {}),
1733
+ extraEnv: { MAESTRO_SESSION_ID: sessionId, MAESTRO_OBLIGATION_KEY: entry.obligationKey || undefined },
1734
+ ...seatEngineFields({ engine: seatEngine, agentRoot: AGENT_REPO_DIR, token: engineToken, baseUrl: engineCohortSpawn ? getCohortClients()?.token?.baseUrl : null }),
1735
+ ...(engineCohortSpawn ? engineSessionBudget() : {}),
1736
+ };
1737
+ let spawnSpec = buildSpawn(spawnInput);
1738
+ let refusedRetarget = null;
1739
+ if (!spawnSpec.ok && /^retarget_/.test(spawnSpec.error.code)) {
1740
+ // Refused retarget (no third-party token). Answer on the stock Anthropic
1741
+ // path with the classifier's Anthropic model rather than send that backend
1742
+ // the seat's own credential — or drop the item.
1743
+ console.warn(`[dispatcher] ${spawnSpec.error.message} — spawning ${sessionId} on the stock path`);
1744
+ logSession({ event: "retarget_refused", sessionId, code: spawnSpec.error.code, decision_id: target.decisionId || null });
1745
+ refusedRetarget = spawnSpec.error;
1746
+ effectiveModelFlag = model;
1747
+ // CF-11: the rerun is an ANTHROPIC session. The third-party decision's
1748
+ // --max-turns/--effort/--agents were sized for that backend and must not
1749
+ // ride along.
1750
+ spawnSpec = buildSpawn({ ...spawnInput, model, knobs: null, retarget: {} });
1751
+ }
1752
+ // A seat on engine cohort is cohort-served whatever routing picked — the same
1753
+ // test cohortGateFor admits it by — so its breaker is the "cohort" backend.
1754
+ const engineCohort = engineCohortSpawn;
1755
+ const servedByCohort = engineCohort || (!refusedRetarget && isCohortRouted({ target }));
1756
+ const breakerProvider = servedByCohort ? rateGuardModule.COHORT_BACKEND : RATE_PROVIDER;
1757
+ // Both engines are wired and the engine's token was checked before any claim,
1758
+ // so a failure past the retarget fallback is a defect.
1759
+ if (!spawnSpec.ok) throw new Error(`[dispatcher] runtime adapter: ${spawnSpec.error.message}`);
1760
+ const args = spawnSpec.argv;
1761
+
1762
+ const proc = _spawn(spawnSpec.bin, args, {
1350
1763
  cwd: AGENT_REPO_DIR,
1351
- env: spawnEnv,
1764
+ env: spawnSpec.env,
1352
1765
  stdio: ["ignore", "pipe", "pipe"],
1353
1766
  });
1354
1767
 
@@ -1356,14 +1769,20 @@ function spawnSession(entry) {
1356
1769
  // and it isn't the stock Anthropic CLI no-op. The v2 path also carries the
1357
1770
  // decision_id + the one-line explain so `maestro router why` can join this
1358
1771
  // session to its routing-audit + ledger rows.
1772
+ //
1773
+ // CF-11: the event describes the run that ACTUALLY happened. After a refused
1774
+ // retarget that is the stock Anthropic session with the classifier's model
1775
+ // and no router knobs; the intended backend rides along as `refused`.
1359
1776
  if (target.backend && target.transport !== "anthropic-cli") {
1360
1777
  logSession({
1361
1778
  event: "routed",
1362
1779
  sessionId,
1363
1780
  decision_id: target.decisionId,
1364
- backend: target.backend,
1365
- transport: target.transport,
1366
- model: target.model,
1781
+ backend: refusedRetarget ? "anthropic" : target.backend,
1782
+ transport: refusedRetarget ? "anthropic-cli" : target.transport,
1783
+ model: refusedRetarget ? model : target.model,
1784
+ knobs: refusedRetarget ? null : { maxTurns: target.maxTurns ?? null, effort: target.effort ?? null, agents: target.agentsJson ? true : false },
1785
+ ...(refusedRetarget ? { refused: { backend: target.backend, model: target.model, code: refusedRetarget.code } } : {}),
1367
1786
  tried: target.decision ? target.decision.tried : target._v1Resolved?.tried,
1368
1787
  fallback_reason: target.decision ? target.decision.audit?.fallback_reason : target._v1Resolved?.fallback_reason,
1369
1788
  explain: target.explain,
@@ -1393,6 +1812,11 @@ function spawnSession(entry) {
1393
1812
  const startTime = Date.now();
1394
1813
  activeSessions.set(sessionId, { process: proc, item, classResult, startTime, model, source, claudeSessionId });
1395
1814
  writeActiveSession(sessionId, entry);
1815
+ // A child exists from this line on — the one moment "started" is true.
1816
+ if (entry.onSpawn) {
1817
+ try { entry.onSpawn({ sessionId, item }); }
1818
+ catch (err) { console.warn(`[dispatcher] onSpawn callback threw for ${sessionId}: ${err.message}`); }
1819
+ }
1396
1820
 
1397
1821
  // WS4: drop the in-flight resume marker. Present-after-crash = mid-flight, so
1398
1822
  // a startup reconcile can re-dispatch `claude --print --resume`. Cleared on a
@@ -1416,6 +1840,9 @@ function spawnSession(entry) {
1416
1840
  priority: classResult.priority,
1417
1841
  summary: classResult.summary,
1418
1842
  active_count: activeSessions.size,
1843
+ task_class: target.taskClass || null,
1844
+ session_key: sessionRoutingKey,
1845
+ continued: Boolean(resumedSessionId),
1419
1846
  });
1420
1847
 
1421
1848
  // Observability: the interaction's trace_id rode in on item.trace_id (set by
@@ -1448,6 +1875,7 @@ function spawnSession(entry) {
1448
1875
  // happened. Skip to avoid double-count and double-release.
1449
1876
  if (spawnErrorHandled.has(sessionId)) {
1450
1877
  spawnErrorHandled.delete(sessionId);
1878
+ if (sessionRoutingKey) releaseSession(sessionRoutingKey);
1451
1879
  clearResumePending(sessionId); // error path already terminal — no resume
1452
1880
  // The error handler already fired onClose(ok:false) AND emitted
1453
1881
  // session_closed; the single-fire guard makes the onClose a no-op, and we
@@ -1461,6 +1889,31 @@ function spawnSession(entry) {
1461
1889
  recordSession(true, code === 0);
1462
1890
  const duration = ((Date.now() - startTime) / 1000).toFixed(1);
1463
1891
 
1892
+ // Session continuity: a clean exit keeps the key live for the next turn in
1893
+ // this conversation; a non-zero one marks it killed so the next route
1894
+ // returns EPHEMERAL_REPLACE rather than resuming into a broken session.
1895
+ // Best-effort — a registry we cannot write costs continuity, never work.
1896
+ if (sessionRoutingKey) {
1897
+ try {
1898
+ if (code === 0) {
1899
+ sessionRouter.touch(sessionRoutingKey, {
1900
+ claudeSessionId,
1901
+ daemonSessionId: sessionId,
1902
+ model: effectiveModelFlag,
1903
+ });
1904
+ } else {
1905
+ sessionRouter.recordExit(sessionRoutingKey, code);
1906
+ }
1907
+ } catch (err) {
1908
+ console.warn(`[dispatcher] session router update failed for ${sessionRoutingKey}: ${err.message}`);
1909
+ } finally {
1910
+ // The key is free the moment the child is gone — released in a `finally`
1911
+ // so a registry write that throws cannot leave the room permanently
1912
+ // unable to continue a session.
1913
+ releaseSession(sessionRoutingKey);
1914
+ }
1915
+ }
1916
+
1464
1917
  // WS4: a clean exit retires the resume marker (work finished — nothing to
1465
1918
  // resume) and closes the shared 429 breaker. A non-zero exit whose stderr
1466
1919
  // looks like a rate limit opens the breaker so ALL spawn sources back off
@@ -1469,7 +1922,7 @@ function spawnSession(entry) {
1469
1922
  // bound re-dispatch.
1470
1923
  if (code === 0) {
1471
1924
  clearResumePending(sessionId);
1472
- try { rateGuard.recordSuccess(RATE_PROVIDER, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
1925
+ try { rateGuard.recordSuccess(breakerProvider, { agentRoot: AGENT_REPO_DIR }); } catch { /* */ }
1473
1926
  } else {
1474
1927
  // A Max/subscription USAGE limit ("you've hit your session limit · resets
1475
1928
  // 9:40pm") is checked FIRST and on BOTH streams — for `claude --print` it
@@ -1477,15 +1930,32 @@ function spawnSession(entry) {
1477
1930
  // RESETS (not a decorrelated 429 backoff), so the seat stops storming a
1478
1931
  // drained pool. A plain 429/overload falls through to the transient path.
1479
1932
  const combined = `${stdout || ""}\n${stderr || ""}`;
1480
- const ul = rateGuard.classifyUsageLimit?.(combined, { agentRoot: AGENT_REPO_DIR }) ?? { isLimit: false, resetAt: null };
1481
- if (ul.isLimit) {
1933
+ // A cohort-llm refusal is checked FIRST on a cohort-served run: its body
1934
+ // says "rate_limit_error", and the English path would otherwise read a
1935
+ // spent seat window as an Anthropic incident. It holds the "cohort"
1936
+ // backend until the refusal's resetsAt and never opens a provider breaker.
1937
+ const cohortRefusal = servedByCohort ? (rateGuard.classifyCohortRefusal?.(combined) ?? null) : null;
1938
+ // An engine-cohort lane has no Claude subscription: its limits arrive as the
1939
+ // structured refusal above. Its English text ("Session limit reached") is
1940
+ // never read as a subscription usage limit — without a structured refusal
1941
+ // it is logged and left to the transient-429 check (cohort backend).
1942
+ const ulText = cohortRefusal ? { isLimit: false, resetAt: null } : (rateGuard.classifyUsageLimit?.(combined, { agentRoot: AGENT_REPO_DIR }) ?? { isLimit: false, resetAt: null });
1943
+ const ul = engineCohort ? { isLimit: false, resetAt: null } : ulText;
1944
+ if (engineCohort && ulText.isLimit) logSession({ event: "usage_limit_text_ignored", sessionId, engine: "cohort" });
1945
+ if (cohortRefusal) {
1946
+ try {
1947
+ const rec = rateGuard.recordCohortRefusal(cohortRefusal, { agentRoot: AGENT_REPO_DIR });
1948
+ logSession({ event: "cohort_refusal_recorded", sessionId, code: cohortRefusal.code, disposition: rec.disposition && rec.disposition.kind, open_until: rec.openUntil });
1949
+ } catch { /* */ }
1950
+ try { const c = getCohortClients(); if (c && c.quota && typeof c.quota.invalidate === "function") c.quota.invalidate(); } catch { /* */ }
1951
+ } else if (ul.isLimit) {
1482
1952
  try {
1483
- const rec = rateGuard.recordUsageLimit(RATE_PROVIDER, ul.resetAt, { agentRoot: AGENT_REPO_DIR });
1953
+ const rec = rateGuard.recordUsageLimit(breakerProvider, ul.resetAt, { agentRoot: AGENT_REPO_DIR });
1484
1954
  logSession({ event: "usage_limit_recorded", sessionId, open_until: rec.openUntil, reset_at: rec.resetAt });
1485
1955
  } catch { /* */ }
1486
1956
  } else if (rateGuard.classifyStderr(stderr)) {
1487
1957
  try {
1488
- const rec = rateGuard.recordRateLimit(RATE_PROVIDER, { agentRoot: AGENT_REPO_DIR });
1958
+ const rec = rateGuard.recordRateLimit(breakerProvider, { agentRoot: AGENT_REPO_DIR });
1489
1959
  logSession({ event: "rate_limit_recorded", sessionId, open_until: rec.openUntil, consecutive: rec.consecutive429 });
1490
1960
  } catch { /* */ }
1491
1961
  }
@@ -1579,6 +2049,7 @@ function spawnSession(entry) {
1579
2049
  durationMs: Date.now() - startTime,
1580
2050
  exitCode: code,
1581
2051
  decisionId: target.decisionId,
2052
+ billedBy: servedByCohort ? COHORT_LLM_BILLED_BY : null,
1582
2053
  });
1583
2054
  if (!costUsage.ok) {
1584
2055
  logSession({ event: "cost_usage_parse_failed", sessionId, reason: costUsage.reason });
@@ -1622,6 +2093,9 @@ function spawnSession(entry) {
1622
2093
  if (source === "inbox") { try { stopTyping(item); } catch { /* */ } }
1623
2094
  // Mark so the trailing proc.on("close") doesn't double-process.
1624
2095
  spawnErrorHandled.add(sessionId);
2096
+ // A spawn that never ran still holds the room's key. Release it here — the
2097
+ // close handler's release is behind an early return on this path.
2098
+ if (sessionRoutingKey) releaseSession(sessionRoutingKey);
1625
2099
  activeSessions.delete(sessionId);
1626
2100
  removeActiveSession(sessionId);
1627
2101
  // Spawn never ran — there is nothing to resume; retire the marker so the
@@ -1760,10 +2234,181 @@ function drainQueue() {
1760
2234
  // Commit: remove from whichever queue it sat at the head of, then spawn.
1761
2235
  if (priorityQueue[0] === next) priorityQueue.shift();
1762
2236
  else normalQueue.shift();
2237
+ // A cohort-llm deferral parks the entry OFF the queue (cohortParked) and
2238
+ // returns false; keep draining — the entries behind it may route elsewhere.
1763
2239
  spawnSession(next);
1764
2240
  }
1765
2241
  }
1766
2242
 
2243
+ // ── cohort-llm pre-spawn gate ────────────────────────────────────────────────
2244
+
2245
+ /**
2246
+ * The gate for one spawn target. Synchronous (spawnSession must register its
2247
+ * child synchronously), so it reads the quota CACHE; a cold cache answers
2248
+ * `{cold:true}` and the caller primes it.
2249
+ */
2250
+ function cohortGateFor(target, engine = readSeatEngine(AGENT_REPO_DIR)) {
2251
+ // A seat on engine cohort is cohort-metered whatever the router picked: the
2252
+ // engine talks to cohort-llm directly, so its spawns are gated even when no
2253
+ // model routing targets the cohort provider. The caller passes the engine it
2254
+ // resolved for this spawn (W4-E1), so the gate and the spawn cannot disagree.
2255
+ if (!isCohortRouted({ engine, target })) return { ok: true, cohort: false };
2256
+ const clients = getCohortClients();
2257
+ if (!clients || !clients.quota) {
2258
+ return {
2259
+ ok: false,
2260
+ cohort: true,
2261
+ deferral: { kind: "retry", until: Date.now() + 30_000, code: "cohort_quota_unconfigured", message: "this seat has no cohort-llm client (no OrgApiKey?)", refusal: null },
2262
+ };
2263
+ }
2264
+ return admitSpawnCached({ engine, target }, { quota: clients.quota, token: clients.token, rateGuard, rateGuardDeps: { agentRoot: AGENT_REPO_DIR } });
2265
+ }
2266
+
2267
+ /**
2268
+ * W4-E1 (design §8.3, A2 "no per-session dollar cap"): an engine-cohort
2269
+ * session's `--max-budget-usd`, from the seat's funded envelope — one day's
2270
+ * binding allowance (lib/runtime/seat-engine.mjs sessionBudgetUsd). No funded
2271
+ * envelope, or a budget guard that cannot say: no cap is invented.
2272
+ * @returns {{maxBudgetUsd:number}|{}}
2273
+ */
2274
+ function engineSessionBudget() {
2275
+ try {
2276
+ const allowance = typeof budgetGuard.dailyAllowance === "function" ? budgetGuard.dailyAllowance({ agentRoot: AGENT_REPO_DIR }) : null;
2277
+ const usd = sessionBudgetUsd(allowance);
2278
+ return usd == null ? {} : { maxBudgetUsd: usd };
2279
+ } catch {
2280
+ return {}; // the guard's own bands still govern the seat
2281
+ }
2282
+ }
2283
+
2284
+ /**
2285
+ * Hand a deferred spawn back without losing it:
2286
+ * inbox PARKED off the queue (cohortParked — never dropped) until the
2287
+ * deferral lifts or the cold cache is primed, then put back at the
2288
+ * head of its queue. Parking, not requeueing at the head: a seat
2289
+ * that routes only SOME work to cohort must keep draining the
2290
+ * entries behind a refused one (they may route to Anthropic), and a
2291
+ * 7d/30d window or a credits hold must not stall the whole inbox.
2292
+ * backlog the item claim is released and the item cools until the deferral
2293
+ * lifts (the on-disk item is retried by the sweep).
2294
+ * A cold cache is primed and the work retried once the reading lands. The
2295
+ * deferral is logged once per hold, not on every re-gate.
2296
+ */
2297
+ function deferSpawnForCohort(entry, gate, target) {
2298
+ const { item, classResult, source } = entry;
2299
+ const d = gate.deferral || {};
2300
+ const until = Number.isFinite(d.until) ? d.until : Date.now() + 30_000;
2301
+ const holdKey = `${gate.cold === true ? "cold" : `${d.code || ""}|${d.kind || ""}|${until}`}`;
2302
+ if (entry.cohortHoldKey !== holdKey) {
2303
+ entry.cohortHoldKey = holdKey;
2304
+ logSession({
2305
+ event: "cohort_deferred",
2306
+ source,
2307
+ code: d.code || null,
2308
+ kind: d.kind || null,
2309
+ until: new Date(until).toISOString(),
2310
+ cold: gate.cold === true,
2311
+ decision_id: (target && target.decisionId) || null,
2312
+ summary: classResult && classResult.summary,
2313
+ });
2314
+ }
2315
+ if (source === "backlog") {
2316
+ if (item && item.id) { try { releaseItemClaim(item.id); } catch { /* */ } }
2317
+ if (!gate.cold) {
2318
+ backlogCooldownUntil.set(backlogKey(item), until);
2319
+ try { saveCooldowns(); } catch { /* */ }
2320
+ }
2321
+ } else {
2322
+ parkForCohort(entry, gate.cold ? null : until);
2323
+ }
2324
+ if (gate.cold) primeCohortQuota(entry);
2325
+ }
2326
+
2327
+ // ── cohort-parked inbox entries ──────────────────────────────────────────────
2328
+ // {entry, until} — `until` null means "waiting on a cold-cache prime".
2329
+ const cohortParked = [];
2330
+ let cohortParkTimer = null;
2331
+ /** Parked entries re-check at least this often (and setTimeout cannot hold a 30d delay). */
2332
+ const COHORT_PARK_RECHECK_MAX_MS = 10 * 60_000;
2333
+ const COHORT_PARK_RECHECK_MIN_MS = 1_000;
2334
+ let cohortInboxPrimeInFlight = false;
2335
+
2336
+ function parkForCohort(entry, until) {
2337
+ cohortParked.push({ entry, until });
2338
+ armCohortRelease();
2339
+ }
2340
+
2341
+ function armCohortRelease() {
2342
+ if (cohortParkTimer) { clearTimeout(cohortParkTimer); cohortParkTimer = null; }
2343
+ const timed = cohortParked.filter((p) => p.until !== null);
2344
+ if (timed.length === 0) return;
2345
+ const earliest = Math.min(...timed.map((p) => p.until));
2346
+ const delay = Math.max(COHORT_PARK_RECHECK_MIN_MS, Math.min(COHORT_PARK_RECHECK_MAX_MS, earliest - Date.now()));
2347
+ cohortParkTimer = setTimeout(() => {
2348
+ cohortParkTimer = null;
2349
+ try { releaseCohortParked(); } catch (err) { console.warn(`[dispatcher] cohort-llm release failed: ${err.message}`); }
2350
+ }, delay);
2351
+ if (cohortParkTimer && typeof cohortParkTimer.unref === "function") cohortParkTimer.unref();
2352
+ }
2353
+
2354
+ /**
2355
+ * Put parked entries back at the head of their queues (original order) and
2356
+ * drain: `cold:true` releases the entries waiting on a prime; otherwise the
2357
+ * ones whose deferral has lifted. They are gated again on the way out.
2358
+ */
2359
+ function releaseCohortParked({ cold = false } = {}) {
2360
+ const now = Date.now();
2361
+ const back = [];
2362
+ const keep = [];
2363
+ for (const p of cohortParked) {
2364
+ const due = cold ? p.until === null : p.until !== null && p.until <= now;
2365
+ (due ? back : keep).push(p);
2366
+ }
2367
+ cohortParked.length = 0;
2368
+ cohortParked.push(...keep);
2369
+ for (let i = back.length - 1; i >= 0; i--) {
2370
+ const e = back[i].entry;
2371
+ const high = e.classResult && (e.classResult.priority === "critical" || e.classResult.priority === "high");
2372
+ (high ? priorityQueue : normalQueue).unshift(e);
2373
+ }
2374
+ armCohortRelease();
2375
+ if (back.length > 0) drainQueue();
2376
+ }
2377
+
2378
+ /** Fetch a quota reading, then retry the deferred work once. */
2379
+ function primeCohortQuota(entry) {
2380
+ const clients = getCohortClients();
2381
+ if (!clients || !clients.quota) return;
2382
+ if (entry.source !== "backlog") {
2383
+ if (cohortInboxPrimeInFlight) return; // one read releases every cold-parked entry
2384
+ cohortInboxPrimeInFlight = true;
2385
+ }
2386
+ Promise.resolve()
2387
+ .then(() => clients.quota.canAdmit())
2388
+ .catch(() => null)
2389
+ .then(() => {
2390
+ try {
2391
+ if (entry.source === "backlog") {
2392
+ // One re-dispatch per prime. A failed read is cached too, so the
2393
+ // second gate answers with a real deferral instead of cold again.
2394
+ if (entry.cohortPrimed) return;
2395
+ dispatch(entry.prompt, entry.item, entry.classResult, "backlog", { obligationKey: entry.obligationKey, onClose: entry.onClose, onSpawn: entry.onSpawn, cohortPrimed: true });
2396
+ } else {
2397
+ cohortInboxPrimeInFlight = false;
2398
+ releaseCohortParked({ cold: true });
2399
+ }
2400
+ } catch (err) {
2401
+ cohortInboxPrimeInFlight = false;
2402
+ console.warn(`[dispatcher] cohort-llm retry after quota prime failed: ${err.message}`);
2403
+ }
2404
+ });
2405
+ }
2406
+
2407
+ /** Test seam: run one drain pass now (the queue is otherwise drained by closes and timers). */
2408
+ export function _drainQueueForTests() {
2409
+ drainQueue();
2410
+ }
2411
+
1767
2412
  /** Get current status for health checks */
1768
2413
  export function getStatus() {
1769
2414
  return {
@@ -1771,6 +2416,8 @@ export function getStatus() {
1771
2416
  max_concurrent: MAX_CONCURRENT,
1772
2417
  priority_queue_length: priorityQueue.length,
1773
2418
  normal_queue_length: normalQueue.length,
2419
+ // Inbox entries held off the queues by a cohort-llm deferral (never dropped).
2420
+ cohort_parked_length: cohortParked.length,
1774
2421
  sessions: Array.from(activeSessions.entries()).map(([id, s]) => ({
1775
2422
  id,
1776
2423
  model: s.model,