@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
@@ -0,0 +1,879 @@
1
+ /**
2
+ * lib/org/llm-token.mjs — the seat's short-lived cohort-llm token.
3
+ *
4
+ * A seat talks to the cohort-llm gateway with a `cst_…` seat token, never with
5
+ * its long-lived OrgApiKey and never with an Anthropic credential. The token is
6
+ * minted by exchanging the seat-bound OrgApiKey at `POST /cohort/v1/tokens`
7
+ * (hq docs/cohort-llm/design.md §4.3 lane 2, wire contract §4.8):
8
+ *
9
+ * POST <base>/cohort/v1/tokens
10
+ * Authorization: Bearer <seat-bound OrgApiKey>
11
+ * body {"purpose":"llm","ttlSeconds":3600}
12
+ * 200 {"token":"cst_<id>_<secret>","tokenType":"Bearer","expiresAt":"…",
13
+ * "orgId":"…","memberId":"…","seatBand":"…|null","baseUrl":"…"}
14
+ * 401 cohort_invalid_key · 403 cohort_seat_required · 503 cohort_unavailable
15
+ *
16
+ * What this module guarantees:
17
+ * - CACHED twice: in memory, and on disk at state/cohort-llm/token.json (mode
18
+ * 0600, through lib/fs-atomic.mjs — the repo's durable-write seam) so a
19
+ * second process on the seat (the apiKeyHelper, the cadence consumer) reuses
20
+ * the token instead of minting its own. The disk record carries a
21
+ * fingerprint of the OrgApiKey, never the key: a rotated key invalidates it.
22
+ * - REFRESHED at 80% of the token's lifetime, not at expiry, so a spawn never
23
+ * starts on a token about to die. A refresh that fails while the current
24
+ * token is still valid keeps serving it (and says so) — a gateway blip must
25
+ * not take down a seat whose credential still works.
26
+ * - SINGLE-FLIGHT: concurrent callers share one in-flight exchange.
27
+ * - TYPED returned errors: `{ok:false, error:{code, message, status}}` with
28
+ * code cohort_invalid_key | cohort_seat_required | cohort_unavailable |
29
+ * cohort_token_exchange_failed | cohort_seat_key_missing. Nothing throws.
30
+ * - NEVER LOGS A SECRET: every log line and error message passes through
31
+ * `redactSecrets`, which strips `cst_…`, `nlk_…`, `sk-ant-…` shapes and the
32
+ * literal key/token values.
33
+ *
34
+ * @module lib/org/llm-token
35
+ */
36
+
37
+ "use strict";
38
+
39
+ import { createHash } from "node:crypto";
40
+ import { existsSync, readFileSync, mkdirSync, openSync, closeSync, unlinkSync, writeFileSync } from "node:fs";
41
+ import { join, dirname, resolve } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+
44
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
45
+ import { loadOrgConfig } from "./client.mjs";
46
+ import { createQuotaClient, quotaStatePath } from "./quota.mjs";
47
+
48
+ /** The production gateway, used when neither config nor env names one. */
49
+ export const DEFAULT_LLM_BASE_URL = "https://llm.cohortapp.com";
50
+ /** Refresh once this fraction of the token's lifetime has elapsed. */
51
+ export const TOKEN_REFRESH_FRACTION = 0.8;
52
+ /** Requested lifetime; the contract caps it at 3600. */
53
+ export const DEFAULT_TOKEN_TTL_SECONDS = 3600;
54
+ /** How long one exchange may take before it counts as `cohort_unavailable`. */
55
+ export const EXCHANGE_TIMEOUT_MS = 10_000;
56
+ /**
57
+ * A FORCED exchange (after a 401) may run at most MAX_REMINTS_PER_WINDOW times
58
+ * per REMINT_WINDOW_MS for one seat key, across every process on the seat (the
59
+ * history is kept in the token record on disk, read and written under a lock).
60
+ * Without it each 401 minted a fresh gateway token, and a gateway that refuses
61
+ * every token became a mint storm (CF-136). A plain read that finds the token
62
+ * invalidated after a 401 is a forced exchange too.
63
+ */
64
+ export const REMINT_WINDOW_MS = 5 * 60_000;
65
+ export const MAX_REMINTS_PER_WINDOW = 3;
66
+ /**
67
+ * A forced attempt that ends in `cohort_unavailable` (the token endpoint was
68
+ * down, so no token was issued) spends budget for this shorter window instead:
69
+ * an outage still bounds attempts, but a recovered gateway is not locked out
70
+ * for the full REMINT_WINDOW_MS.
71
+ */
72
+ export const UNAVAILABLE_REMINT_WINDOW_MS = 60_000;
73
+ /**
74
+ * The re-mint caps as one named config (CF-143). The VALUES are the W7 defaults
75
+ * and stay as they are pending owner confirmation; `createTokenManager({
76
+ * remintCaps })` overrides them per manager (tests, or an owner decision
77
+ * landing in config). docs/engine/README.md "Retry caps" lists every cap.
78
+ */
79
+ export const DEFAULT_REMINT_CAPS = Object.freeze({
80
+ max: MAX_REMINTS_PER_WINDOW,
81
+ windowMs: REMINT_WINDOW_MS,
82
+ unavailableWindowMs: UNAVAILABLE_REMINT_WINDOW_MS,
83
+ });
84
+ /**
85
+ * A token exchange that meets a transient outage (503/5xx or 408 at POST
86
+ * /cohort/v1/tokens, or a refused connection) is retried inside the SAME
87
+ * exchange (CF-143): at most `maxRetries` times, sleeping retry-after when the
88
+ * gateway sent one (never longer than `maxRetryAfterMs` — a longer one ends the
89
+ * retries) else the `backoffMs` ladder. `deadlineMs` bounds the WHOLE exchange,
90
+ * sends included, from the first send: a retry starts only when its sleep plus
91
+ * `minSendMs` still fits, and every send's request timeout is cut to what is
92
+ * left of the deadline, so a slow outage ends by `deadlineMs` and the engine
93
+ * (which kills its token helper at 20 s, TOKEN_HELPER_TIMEOUT_MS) reads the
94
+ * helper's own `cohort_unavailable` instead of a kill. An exchange issues at
95
+ * most one token and spends the re-mint budget once, whatever it retries, so
96
+ * this cannot become a mint storm. A request that times out (our own
97
+ * EXCHANGE_TIMEOUT_MS, or the deadline cut) is not retried.
98
+ */
99
+ export const DEFAULT_EXCHANGE_RETRY = Object.freeze({
100
+ maxRetries: 2,
101
+ backoffMs: Object.freeze([500, 1000]),
102
+ maxRetryAfterMs: 5_000,
103
+ deadlineMs: 15_000,
104
+ minSendMs: 1_000,
105
+ });
106
+ /** The token record's cross-process lock: how long to wait, and when a holder counts as dead. */
107
+ const RECORD_LOCK_TIMEOUT_MS = 2_000;
108
+ const RECORD_LOCK_SPIN_MS = 5;
109
+ /** The env var the engine sets for the helper: the fingerprint of the token a 401 refused. */
110
+ export const REJECTED_TOKEN_FP_ENV = "COHORT_LLM_TOKEN_REJECTED_FP";
111
+
112
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
113
+ /** The helper Claude Code runs as `apiKeyHelper` on a cohort retarget. */
114
+ export const API_KEY_HELPER_PATH = join(REPO_ROOT, "scripts", "cohort-llm", "api-key-helper.mjs");
115
+
116
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Pure: configuration
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * The gateway base URL. Config `org.cohort.llmBaseUrl` first, then
124
+ * `COHORT_LLM_BASE_URL`, then the production default. Trailing slashes are
125
+ * dropped so `<base>/v1` and `<base>/cohort/v1/…` are always well formed.
126
+ * @param {{cfg?:object, env?:object}} [o]
127
+ * @returns {string}
128
+ */
129
+ export function resolveLlmBaseUrl({ cfg, env = process.env } = {}) {
130
+ const n = (cfg && cfg.org && cfg.org.cohort) || {};
131
+ const picked = [n.llmBaseUrl, env && env.COHORT_LLM_BASE_URL].find(nonEmpty) || DEFAULT_LLM_BASE_URL;
132
+ return String(picked).trim().replace(/\/+$/, "");
133
+ }
134
+
135
+ /**
136
+ * The catalog endpoints for the cohort provider at a base URL (design §8.3:
137
+ * both wires point at the gateway).
138
+ * @param {string} baseUrl
139
+ */
140
+ export function cohortEndpoints(baseUrl) {
141
+ const b = String(baseUrl || DEFAULT_LLM_BASE_URL).replace(/\/+$/, "");
142
+ return { openai: `${b}/v1`, anthropic: b };
143
+ }
144
+
145
+ /**
146
+ * The seat's OrgApiKey — the same resolution order as lib/org/client.mjs
147
+ * `configFromAgent` (config token → COHORT_API_TOKEN → COHORT_TOKEN →
148
+ * COHORT_API_KEY), with the env injected, and the agent's `.env` as a last
149
+ * resort for a child (the apiKeyHelper) whose env was scrubbed of credentials.
150
+ * @param {{cfg?:object, env?:object, dotEnv?:object}} [o]
151
+ * @returns {string} "" when the seat has none
152
+ */
153
+ export function seatApiKey({ cfg, env = process.env, dotEnv } = {}) {
154
+ const n = (cfg && cfg.org && cfg.org.cohort) || {};
155
+ const e = env || {};
156
+ const d = dotEnv || {};
157
+ const picked = [n.token, e.COHORT_API_TOKEN, e.COHORT_TOKEN, e.COHORT_API_KEY, d.COHORT_API_TOKEN, d.COHORT_TOKEN, d.COHORT_API_KEY].find(nonEmpty);
158
+ return picked ? String(picked).trim() : "";
159
+ }
160
+
161
+ /** Where a seat's token record lives. */
162
+ export function tokenStatePath(agentRoot) {
163
+ return join(resolve(agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd()), "state", "cohort-llm", "token.json");
164
+ }
165
+
166
+ /** A short, non-reversible fingerprint of a credential (for cache keys and logs). */
167
+ export function keyFingerprint(secret) {
168
+ return createHash("sha256").update(String(secret || "")).digest("hex").slice(0, 16);
169
+ }
170
+
171
+ /**
172
+ * Single-quote a string for /bin/sh (Claude Code runs apiKeyHelper in a shell).
173
+ * @param {string} s
174
+ */
175
+ function shq(s) {
176
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
177
+ }
178
+
179
+ /**
180
+ * The `apiKeyHelper` command for a seat: node + the helper + the agent root.
181
+ * No secret is ever on this command line — the helper reads the cached token,
182
+ * or the seat's key from config/.env, itself.
183
+ * @param {{agentRoot:string, nodeBin?:string, helperPath?:string}} o
184
+ */
185
+ export function apiKeyHelperCommand({ agentRoot, nodeBin = process.execPath, helperPath = API_KEY_HELPER_PATH } = {}) {
186
+ return `${shq(nodeBin)} ${shq(helperPath)} --agent-root ${shq(resolve(agentRoot || process.cwd()))}`;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Pure: redaction, refresh policy, error classification
191
+ // ---------------------------------------------------------------------------
192
+
193
+ const SECRET_SHAPES = [/cst_[A-Za-z0-9_\-]+/g, /nlk_[A-Za-z0-9_\-]+/g, /sk-ant-[A-Za-z0-9_\-]+/g];
194
+
195
+ /**
196
+ * Remove every secret from a string: the known credential shapes, plus any
197
+ * literal value passed in `secrets` (an OrgApiKey need not look like nlk_…).
198
+ * @param {unknown} text
199
+ * @param {string[]} [secrets]
200
+ * @returns {string}
201
+ */
202
+ export function redactSecrets(text, secrets = []) {
203
+ let out = String(text ?? "");
204
+ for (const s of secrets) {
205
+ if (nonEmpty(s) && s.length >= 6) out = out.split(s).join("***");
206
+ }
207
+ for (const re of SECRET_SHAPES) out = out.replace(re, "***");
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * Has this record reached its refresh point (80% of lifetime) or expired?
213
+ * A record with unparseable times always needs a refresh.
214
+ * @param {object|null} rec {token, issuedAt, expiresAt}
215
+ * @param {number} nowMs
216
+ * @param {number} [fraction]
217
+ */
218
+ export function needsRefresh(rec, nowMs, fraction = TOKEN_REFRESH_FRACTION) {
219
+ if (!rec || !nonEmpty(rec.token)) return true;
220
+ const issued = Date.parse(rec.issuedAt);
221
+ const expires = Date.parse(rec.expiresAt);
222
+ if (!Number.isFinite(issued) || !Number.isFinite(expires)) return true;
223
+ if (expires <= issued) return nowMs >= expires;
224
+ return nowMs >= issued + fraction * (expires - issued);
225
+ }
226
+
227
+ /** Is the token still valid at all (not yet expired)? */
228
+ export function isUnexpired(rec, nowMs) {
229
+ if (!rec || !nonEmpty(rec.token)) return false;
230
+ const expires = Date.parse(rec.expiresAt);
231
+ return Number.isFinite(expires) && nowMs < expires;
232
+ }
233
+
234
+ /** In-window entries of a history, ascending. A future-stamped entry (clock skew) still counts. */
235
+ function inWindow(history, nowMs, windowMs) {
236
+ return (Array.isArray(history) ? history : [])
237
+ .filter((at) => Number.isFinite(at) && at > nowMs - windowMs)
238
+ .sort((a, b) => a - b);
239
+ }
240
+
241
+ /**
242
+ * The sliding-window re-mint budget. PURE.
243
+ * @param {unknown} history epoch ms of earlier forced mints
244
+ * @param {number} nowMs
245
+ * @param {{max?:number, windowMs?:number, unavailable?:unknown, unavailableWindowMs?:number}} [o]
246
+ * `unavailable`: epoch ms of forced attempts that ended in cohort_unavailable,
247
+ * counted for the shorter `unavailableWindowMs`.
248
+ * @returns {{allowed:boolean, recent:number[], retryAt:number|null}}
249
+ */
250
+ export function remintBudget(history, nowMs, { max = MAX_REMINTS_PER_WINDOW, windowMs = REMINT_WINDOW_MS, unavailable = [], unavailableWindowMs = UNAVAILABLE_REMINT_WINDOW_MS } = {}) {
251
+ const recent = inWindow(history, nowMs, windowMs);
252
+ const expiries = [
253
+ ...recent.map((at) => at + windowMs),
254
+ ...inWindow(unavailable, nowMs, unavailableWindowMs).map((at) => at + unavailableWindowMs),
255
+ ].sort((a, b) => a - b);
256
+ if (expiries.length < max) return { allowed: true, recent, retryAt: null };
257
+ return { allowed: false, recent, retryAt: expiries[expiries.length - max] };
258
+ }
259
+
260
+ /**
261
+ * Merge copies of one re-mint history. PURE. Every copy is some process's view
262
+ * of the same append-only list, so a timestamp appears as many times as the
263
+ * copy that holds it most often (two mints in the same millisecond stay two).
264
+ * @param {...unknown} copies
265
+ * @returns {number[]} ascending
266
+ */
267
+ export function mergeRemintHistory(...copies) {
268
+ const counts = new Map();
269
+ for (const copy of copies) {
270
+ if (!Array.isArray(copy)) continue;
271
+ const here = new Map();
272
+ for (const at of copy) if (Number.isFinite(at)) here.set(at, (here.get(at) || 0) + 1);
273
+ for (const [at, n] of here) counts.set(at, Math.max(counts.get(at) || 0, n));
274
+ }
275
+ const out = [];
276
+ for (const [at, n] of counts) for (let i = 0; i < n; i++) out.push(at);
277
+ return out.sort((a, b) => a - b);
278
+ }
279
+
280
+ /**
281
+ * How long to wait before resending a token exchange that met a transient
282
+ * outage, or null to stop retrying. PURE.
283
+ * @param {{attempt:number, retryAfterMs?:number|null, elapsedMs?:number}} s
284
+ * attempt: retries already made (0 before the first retry)
285
+ * @param {{maxRetries:number, backoffMs:readonly number[], maxRetryAfterMs:number, deadlineMs:number, minSendMs?:number}} [caps]
286
+ * a retry is refused unless elapsed + wait + minSendMs ≤ deadlineMs, so the
287
+ * send it leads to still has time to complete inside the deadline
288
+ * @returns {number|null}
289
+ */
290
+ export function exchangeRetryDelay({ attempt, retryAfterMs = null, elapsedMs = 0 }, caps = DEFAULT_EXCHANGE_RETRY) {
291
+ if (!Number.isInteger(attempt) || attempt < 0 || attempt >= caps.maxRetries) return null;
292
+ let wait;
293
+ if (Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
294
+ if (retryAfterMs > caps.maxRetryAfterMs) return null; // not slept through
295
+ wait = retryAfterMs;
296
+ } else {
297
+ const ladder = caps.backoffMs;
298
+ wait = ladder.length ? ladder[Math.min(attempt, ladder.length - 1)] : 0;
299
+ }
300
+ const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
301
+ const minSend = Number.isFinite(caps.minSendMs) && caps.minSendMs > 0 ? caps.minSendMs : 0;
302
+ if (elapsed + wait + minSend > caps.deadlineMs) return null;
303
+ return wait;
304
+ }
305
+
306
+ /** retry-after (seconds or an HTTP date) in ms, or null. PURE. */
307
+ export function parseRetryAfterMs(value, nowMs) {
308
+ if (value == null || value === "") return null;
309
+ const secs = Number(value);
310
+ if (Number.isFinite(secs)) return secs >= 0 ? Math.round(secs * 1000) : null;
311
+ const at = Date.parse(String(value));
312
+ return Number.isFinite(at) ? Math.max(0, at - nowMs) : null;
313
+ }
314
+
315
+ const EXCHANGE_CODES = new Set(["cohort_invalid_key", "cohort_seat_required", "cohort_unavailable"]);
316
+
317
+ /**
318
+ * Map a failed exchange onto the typed error vocabulary. A cohort refusal code
319
+ * in the body wins; otherwise the status decides.
320
+ * @param {number|null} status
321
+ * @param {any} body parsed JSON body (or null)
322
+ * @returns {{code:string, message:string, status:number|null}}
323
+ */
324
+ export function classifyExchangeFailure(status, body) {
325
+ const b = body && typeof body === "object" ? body : {};
326
+ const bodyCode = (b.cohort && b.cohort.code) || (b.error && b.error.code) || null;
327
+ const message = (b.error && typeof b.error.message === "string" && b.error.message) || null;
328
+ let code;
329
+ if (typeof bodyCode === "string" && EXCHANGE_CODES.has(bodyCode)) code = bodyCode;
330
+ else if (status === 401) code = "cohort_invalid_key";
331
+ else if (status === 403) code = "cohort_seat_required";
332
+ // 408: the gateway's body idle timeout — the request stalled, the key is fine.
333
+ else if (status === 408 || status === 503 || status === null || status >= 500) code = "cohort_unavailable";
334
+ else code = "cohort_token_exchange_failed";
335
+ const why = {
336
+ cohort_invalid_key: "the seat's OrgApiKey was refused by cohort-llm",
337
+ cohort_seat_required: "cohort-llm tokens need a seat-bound key (this key is seatless or a guest key)",
338
+ cohort_unavailable: "cohort-llm is unavailable",
339
+ cohort_token_exchange_failed: `the token exchange failed (HTTP ${status})`,
340
+ }[code];
341
+ return { code, message: message ? `${why}: ${message}` : why, status };
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Edge: the token manager
346
+ // ---------------------------------------------------------------------------
347
+
348
+ function readRecord(path) {
349
+ try {
350
+ if (!existsSync(path)) return null;
351
+ const r = JSON.parse(readFileSync(path, "utf-8"));
352
+ return r && typeof r === "object" ? r : null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ function busyWaitMs(ms) {
359
+ const end = Date.now() + ms;
360
+ while (Date.now() < end) { /* spin */ }
361
+ }
362
+
363
+ /**
364
+ * O_EXCL lock on `<record>.lock`, the same discipline as
365
+ * lib/model-router/health.mjs: spin on EEXIST, steal a lock older than the
366
+ * timeout (its holder died). Wall-clock time, never an injected clock: the lock
367
+ * is about real processes. null when it cannot be taken.
368
+ */
369
+ function acquireRecordLock(lockPath) {
370
+ const deadline = Date.now() + RECORD_LOCK_TIMEOUT_MS;
371
+ for (;;) {
372
+ try {
373
+ const fd = openSync(lockPath, "wx");
374
+ try { writeFileSync(fd, JSON.stringify({ at: Date.now(), pid: process.pid })); } catch { /* stamp is best-effort */ }
375
+ return fd;
376
+ } catch (err) {
377
+ if (!err || err.code !== "EEXIST") return null;
378
+ try {
379
+ const heldAt = Number(JSON.parse(readFileSync(lockPath, "utf-8")).at);
380
+ if (Number.isFinite(heldAt) && Date.now() - heldAt > RECORD_LOCK_TIMEOUT_MS) {
381
+ try { unlinkSync(lockPath); } catch { /* another process stole it first */ }
382
+ continue;
383
+ }
384
+ } catch { /* unreadable (being written) — spin */ }
385
+ if (Date.now() >= deadline) return null;
386
+ busyWaitMs(RECORD_LOCK_SPIN_MS);
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * @param {object} o
393
+ * @param {string} o.baseUrl gateway base (no trailing slash)
394
+ * @param {string} o.apiKey the seat's OrgApiKey
395
+ * @param {string} [o.statePath] on-disk cache (null disables disk)
396
+ * @param {typeof fetch} [o.fetchImpl]
397
+ * @param {() => number} [o.now]
398
+ * @param {number} [o.ttlSeconds]
399
+ * @param {number} [o.timeoutMs]
400
+ * @param {(level:string, msg:string) => void} [o.log]
401
+ * @param {(ms:number) => Promise<void>} [o.sleep] the exchange's retry sleep (default a real timer)
402
+ * @param {Partial<typeof DEFAULT_REMINT_CAPS>} [o.remintCaps]
403
+ * @param {Partial<typeof DEFAULT_EXCHANGE_RETRY>} [o.exchangeRetry]
404
+ * @param {(ms:number) => AbortSignal} [o.timeoutSignal] one send's timeout signal (default AbortSignal.timeout; tests inject a clocked one)
405
+ */
406
+ export function createTokenManager(o = {}) {
407
+ const baseUrl = String(o.baseUrl || DEFAULT_LLM_BASE_URL).replace(/\/+$/, "");
408
+ const apiKey = nonEmpty(o.apiKey) ? o.apiKey.trim() : "";
409
+ const keyFp = apiKey ? keyFingerprint(apiKey) : null;
410
+ const statePath = o.statePath === undefined ? null : o.statePath;
411
+ const now = typeof o.now === "function" ? o.now : Date.now;
412
+ const ttlSeconds = Math.min(DEFAULT_TOKEN_TTL_SECONDS, Math.max(60, Number(o.ttlSeconds) || DEFAULT_TOKEN_TTL_SECONDS));
413
+ const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : EXCHANGE_TIMEOUT_MS;
414
+ const fetchImpl = typeof o.fetchImpl === "function" ? o.fetchImpl : globalThis.fetch;
415
+ const sleep = typeof o.sleep === "function" ? o.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
416
+ const caps = { ...DEFAULT_REMINT_CAPS, ...(o.remintCaps || {}) };
417
+ const retryCaps = { ...DEFAULT_EXCHANGE_RETRY, ...(o.exchangeRetry || {}) };
418
+ const timeoutSignal = typeof o.timeoutSignal === "function"
419
+ ? o.timeoutSignal
420
+ : (ms) => (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(ms) : undefined);
421
+ let mem = null;
422
+ let inflight = null;
423
+ /**
424
+ * Forced-mint history this process has seen, merged with the disk record's on
425
+ * every locked write: `remints` (REMINT_WINDOW_MS) and `unavailable` — forced
426
+ * attempts that ended in cohort_unavailable (UNAVAILABLE_REMINT_WINDOW_MS).
427
+ */
428
+ let hist = { remints: [], unavailable: [] };
429
+ /** This process invalidated the token and has not minted since (the disk-less twin of `invalidatedAt`). */
430
+ let invalidatedHere = false;
431
+
432
+ const secrets = () => [apiKey, mem && mem.token].filter(Boolean);
433
+ const log = (level, msg) => {
434
+ if (typeof o.log !== "function") return;
435
+ try { o.log(level, redactSecrets(msg, secrets())); } catch { /* never throw from logging */ }
436
+ };
437
+ const fail = (err) => ({ ok: false, error: { ...err, message: redactSecrets(err.message, secrets()) } });
438
+ const view = (rec, extra = {}) => ({
439
+ ok: true,
440
+ token: rec.token,
441
+ expiresAt: rec.expiresAt,
442
+ orgId: rec.orgId ?? null,
443
+ memberId: rec.memberId ?? null,
444
+ seatBand: rec.seatBand ?? null,
445
+ baseUrl: rec.baseUrl || baseUrl,
446
+ ...extra,
447
+ });
448
+
449
+ /** The best record we hold for THIS key and base URL (memory, then disk). */
450
+ function current() {
451
+ if (mem && mem.keyFp === keyFp && mem.baseUrl === baseUrl) return mem;
452
+ if (!statePath) return null;
453
+ const disk = readRecord(statePath);
454
+ if (disk && disk.keyFp === keyFp && disk.baseUrl === baseUrl && nonEmpty(disk.token)) {
455
+ mem = disk;
456
+ return disk;
457
+ }
458
+ return null;
459
+ }
460
+
461
+ const mineOf = (disk) => (disk && disk.keyFp === keyFp ? disk : null);
462
+ const histFields = (h) => ({ remints: h.remints, remintsUnavailable: h.unavailable });
463
+
464
+ /** This process's history merged with the disk record's, pruned to the windows. */
465
+ function mergedHist(disk, t) {
466
+ const m = mineOf(disk);
467
+ return {
468
+ remints: inWindow(mergeRemintHistory(hist.remints, m && m.remints), t, caps.windowMs),
469
+ unavailable: inWindow(mergeRemintHistory(hist.unavailable, m && m.remintsUnavailable), t, caps.unavailableWindowMs),
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Read-modify-write the disk record under its lock: `mutator(disk)` returns
475
+ * the record to write, or null to leave it. Without a state path the mutator
476
+ * runs against null and nothing is written. When the lock cannot be taken the
477
+ * write still happens, unlocked (a slightly racy history beats none). Never
478
+ * throws.
479
+ * @param {(disk:object|null) => object|null} mutator
480
+ */
481
+ function withRecord(mutator) {
482
+ if (!statePath) { mutator(null); return; }
483
+ const lockPath = `${statePath}.lock`;
484
+ let fd = null;
485
+ try {
486
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
487
+ fd = acquireRecordLock(lockPath);
488
+ } catch { fd = null; }
489
+ try {
490
+ const next = mutator(readRecord(statePath));
491
+ if (next) writeJsonAtomic(statePath, next, { mode: 0o600 });
492
+ } catch (err) {
493
+ log("warn", `could not persist the cohort-llm token record (${err && err.message ? err.message : err}); continuing in memory`);
494
+ } finally {
495
+ if (fd !== null) {
496
+ try { closeSync(fd); } catch { /* */ }
497
+ try { unlinkSync(lockPath); } catch { /* */ }
498
+ }
499
+ }
500
+ }
501
+
502
+ /** Was the token invalidated after a 401 with no mint since (here, or by any process on the seat)? */
503
+ function wasInvalidated() {
504
+ if (invalidatedHere) return true;
505
+ const m = statePath ? mineOf(readRecord(statePath)) : null;
506
+ return !!(m && !nonEmpty(m.token) && m.invalidatedAt);
507
+ }
508
+
509
+ /** @param {{forced?:boolean}} [xo] */
510
+ async function exchange(xo = {}) {
511
+ if (!apiKey) {
512
+ return fail({ code: "cohort_seat_key_missing", message: "this seat has no OrgApiKey (org.cohort.token / COHORT_API_KEY), so it cannot mint a cohort-llm token", status: null });
513
+ }
514
+ if (typeof fetchImpl !== "function") {
515
+ return fail({ code: "cohort_unavailable", message: "no fetch implementation available", status: null });
516
+ }
517
+ const issuedAt = now();
518
+ if (xo.forced) {
519
+ // Check and spend the budget in ONE locked step, so two processes cannot
520
+ // both read "2 of 3 used" and both mint. Counted when attempted, and
521
+ // persisted before the fetch: a helper that dies mid-mint still spent it.
522
+ let capped = null;
523
+ withRecord((disk) => {
524
+ const h = mergedHist(disk, issuedAt);
525
+ const budget = remintBudget(h.remints, issuedAt, { max: caps.max, windowMs: caps.windowMs, unavailable: h.unavailable, unavailableWindowMs: caps.unavailableWindowMs });
526
+ if (!budget.allowed) {
527
+ hist = h;
528
+ capped = budget;
529
+ return null;
530
+ }
531
+ hist = { remints: [...h.remints, issuedAt], unavailable: h.unavailable };
532
+ return { ...(mineOf(disk) || { keyFp }), ...histFields(hist) };
533
+ });
534
+ if (capped) {
535
+ return fail({
536
+ code: "cohort_token_remint_capped",
537
+ message: `cohort-llm refused ${caps.max} freshly minted tokens within ${Math.round(caps.windowMs / 60000)} minutes; not minting again before ${new Date(capped.retryAt).toISOString()}`,
538
+ status: null,
539
+ retryAt: capped.retryAt,
540
+ });
541
+ }
542
+ }
543
+ /** A forced attempt that issued no token because cohort-llm was down moves to the short window. */
544
+ const failExchange = (err) => {
545
+ if (xo.forced && err.code === "cohort_unavailable") {
546
+ const dropOne = (arr) => {
547
+ const list = Array.isArray(arr) ? arr : [];
548
+ const i = list.indexOf(issuedAt);
549
+ return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 1)];
550
+ };
551
+ hist = { remints: dropOne(hist.remints), unavailable: [...hist.unavailable, issuedAt] };
552
+ withRecord((disk) => {
553
+ const m = mineOf(disk);
554
+ const t = now();
555
+ hist = {
556
+ remints: inWindow(mergeRemintHistory(hist.remints, m && dropOne(m.remints)), t, caps.windowMs),
557
+ unavailable: inWindow(mergeRemintHistory(hist.unavailable, m && m.remintsUnavailable), t, caps.unavailableWindowMs),
558
+ };
559
+ return { ...(m || { keyFp }), ...histFields(hist) };
560
+ });
561
+ }
562
+ return fail(err);
563
+ };
564
+ // One exchange, bounded retries on a transient outage (DEFAULT_EXCHANGE_RETRY).
565
+ // The budget above was spent once for the whole exchange; at most one token
566
+ // is issued, so a retry here never counts as, or becomes, another mint.
567
+ let res;
568
+ let body = null;
569
+ for (let retries = 0; ; retries++) {
570
+ // Every send, and the body read it covers, ends by the exchange deadline:
571
+ // the request timeout is cut to what is left of it, so retries can never
572
+ // carry the helper past the engine's kill (TOKEN_HELPER_TIMEOUT_MS).
573
+ const leftMs = retryCaps.deadlineMs - (now() - issuedAt);
574
+ if (retries > 0 && leftMs <= 0) {
575
+ return failExchange({ code: "cohort_unavailable", message: `cohort-llm token endpoint still unavailable after ${retries} retries; the ${retryCaps.deadlineMs} ms exchange deadline has passed`, status: null });
576
+ }
577
+ const sendTimeoutMs = Math.max(1, Math.min(timeoutMs, leftMs));
578
+ const cutByDeadline = sendTimeoutMs < timeoutMs;
579
+ let transportError = null;
580
+ try {
581
+ res = await fetchImpl(`${baseUrl}/cohort/v1/tokens`, {
582
+ method: "POST",
583
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", accept: "application/json" },
584
+ body: JSON.stringify({ purpose: "llm", ttlSeconds }),
585
+ signal: timeoutSignal(sendTimeoutMs),
586
+ });
587
+ body = null;
588
+ try { body = await res.json(); } catch (err) {
589
+ // A body read aborted by the timeout is a transport failure, not an empty body.
590
+ if (err && (err.name === "TimeoutError" || err.name === "AbortError")) throw err;
591
+ body = null;
592
+ }
593
+ } catch (err) {
594
+ transportError = err || new Error("fetch failed");
595
+ }
596
+ const elapsedMs = now() - issuedAt;
597
+ if (transportError) {
598
+ // A timed-out send (EXCHANGE_TIMEOUT_MS, or cut short by the deadline) is not retried.
599
+ const timedOut = transportError.name === "TimeoutError" || transportError.name === "AbortError";
600
+ const wait = timedOut ? null : exchangeRetryDelay({ attempt: retries, elapsedMs }, retryCaps);
601
+ if (wait !== null) {
602
+ log("warn", `cohort-llm token exchange did not complete (${transportError.message || transportError}); retrying in ${wait} ms`);
603
+ await sleep(wait);
604
+ continue;
605
+ }
606
+ const why = timedOut && cutByDeadline
607
+ ? `no response before the ${retryCaps.deadlineMs} ms exchange deadline (after ${retries} retries)`
608
+ : `${transportError.message || transportError}`;
609
+ return failExchange({ code: "cohort_unavailable", message: `cohort-llm token exchange did not complete: ${why}`, status: null });
610
+ }
611
+ if (res.ok) break;
612
+ const failure = classifyExchangeFailure(res.status, body);
613
+ const header = (name) => (res.headers && typeof res.headers.get === "function" ? res.headers.get(name) : null);
614
+ if (failure.code === "cohort_unavailable" && header("x-should-retry") !== "false") {
615
+ const wait = exchangeRetryDelay({ attempt: retries, retryAfterMs: parseRetryAfterMs(header("retry-after"), now()), elapsedMs }, retryCaps);
616
+ if (wait !== null) {
617
+ log("warn", `cohort-llm token endpoint answered ${res.status} (cohort_unavailable); retrying in ${wait} ms`);
618
+ await sleep(wait);
619
+ continue;
620
+ }
621
+ }
622
+ return failExchange(failure);
623
+ }
624
+ const token = body && body.token;
625
+ const expiresMs = body ? Date.parse(body.expiresAt) : NaN;
626
+ if (!nonEmpty(token) || !Number.isFinite(expiresMs)) {
627
+ return fail({ code: "cohort_token_exchange_failed", message: "cohort-llm returned a token response without a token or a parseable expiresAt", status: res.status });
628
+ }
629
+ const rec = {
630
+ token,
631
+ tokenType: body.tokenType || "Bearer",
632
+ expiresAt: new Date(expiresMs).toISOString(),
633
+ issuedAt: new Date(issuedAt).toISOString(),
634
+ orgId: body.orgId ?? null,
635
+ memberId: body.memberId ?? null,
636
+ seatBand: body.seatBand ?? null,
637
+ baseUrl,
638
+ keyFp,
639
+ ...histFields(hist),
640
+ };
641
+ // Re-read under the lock and merge: another process may have spent budget
642
+ // while this fetch was in flight, and this write must not erase it.
643
+ withRecord((disk) => {
644
+ hist = mergedHist(disk, now());
645
+ Object.assign(rec, histFields(hist));
646
+ return rec;
647
+ });
648
+ mem = rec;
649
+ invalidatedHere = false;
650
+ log("info", `minted a cohort-llm seat token (key ${keyFp}, expires ${rec.expiresAt})`);
651
+ return view(rec, { refreshed: true });
652
+ }
653
+
654
+ /**
655
+ * A token fit to use now: cached when it is before its refresh point, else a
656
+ * (single-flight) exchange. A failed refresh falls back to a still-valid
657
+ * token, marked `stale:true`.
658
+ *
659
+ * After a 401 pass the refused token as `rejected` (or its fingerprint as
660
+ * `rejectedFp`, which is all the engine's helper is given). When the cached
661
+ * token is no longer that one — another request or process already replaced
662
+ * it — the replacement is served with no exchange. Otherwise the exchange is
663
+ * FORCED and counts against the re-mint budget (MAX_REMINTS_PER_WINDOW per
664
+ * REMINT_WINDOW_MS); past it the answer is `cohort_token_remint_capped`.
665
+ * `force:true` alone is a forced exchange under the same budget.
666
+ * @param {{force?:boolean, rejected?:string, rejectedFp?:string}} [opts]
667
+ */
668
+ async function getToken(opts = {}) {
669
+ let rec = current();
670
+ const t = now();
671
+ const rejectedFp = nonEmpty(opts.rejectedFp) ? opts.rejectedFp.trim() : nonEmpty(opts.rejected) ? keyFingerprint(opts.rejected) : null;
672
+ if (rejectedFp && rec && keyFingerprint(rec.token) === rejectedFp && statePath) {
673
+ // This process's memory holds the refused token; another process may
674
+ // already have written its replacement to disk.
675
+ const disk = readRecord(statePath);
676
+ if (disk && disk.keyFp === keyFp && disk.baseUrl === baseUrl && nonEmpty(disk.token) && keyFingerprint(disk.token) !== rejectedFp && isUnexpired(disk, t)) {
677
+ mem = disk;
678
+ rec = disk;
679
+ }
680
+ }
681
+ if (rejectedFp && rec && keyFingerprint(rec.token) !== rejectedFp && isUnexpired(rec, t)) {
682
+ return view(rec, { cached: true, replaced: true });
683
+ }
684
+ // No token because a 401 invalidated it: the next mint is a re-mint, whoever
685
+ // asks for it (a quota poll, the next helper run). Without this a plain read
686
+ // after invalidate() minted outside the budget.
687
+ const forced = !!opts.force || !!rejectedFp || (!rec && wasInvalidated());
688
+ if (!forced && rec && !needsRefresh(rec, t)) return view(rec, { cached: true });
689
+ if (!inflight) {
690
+ inflight = exchange({ forced }).finally(() => { inflight = null; });
691
+ }
692
+ const r = await inflight;
693
+ if (r.ok) return r;
694
+ const still = current();
695
+ if (!forced && still && isUnexpired(still, now())) {
696
+ log("warn", `cohort-llm token refresh failed (${r.error.code}); serving the current token until it expires at ${still.expiresAt}`);
697
+ return view(still, { stale: true, refreshError: r.error });
698
+ }
699
+ log("error", `cohort-llm token unavailable: ${r.error.code} — ${r.error.message}`);
700
+ return r;
701
+ }
702
+
703
+ /**
704
+ * Synchronous read of a token that is before its refresh point (or merely
705
+ * unexpired, with `allowStale`). null when there is nothing usable.
706
+ * @param {{allowStale?:boolean}} [opts]
707
+ */
708
+ function peek(opts = {}) {
709
+ const rec = current();
710
+ const t = now();
711
+ if (!rec) return null;
712
+ if (!needsRefresh(rec, t)) return view(rec, { cached: true });
713
+ if (opts.allowStale && isUnexpired(rec, t)) return view(rec, { stale: true });
714
+ return null;
715
+ }
716
+
717
+ /**
718
+ * Drop the cached token (after a 401 from the gateway). With `rejectedToken`,
719
+ * only when the cache still holds that token: a replacement another process
720
+ * already minted is kept. The re-mint history survives either way.
721
+ * @param {string} [rejectedToken]
722
+ */
723
+ function invalidate(rejectedToken) {
724
+ if (nonEmpty(rejectedToken)) {
725
+ if (mem && mem.token === rejectedToken) mem = null;
726
+ if (mem) return; // this process already holds a replacement
727
+ } else {
728
+ mem = null;
729
+ }
730
+ let replaced = false;
731
+ withRecord((disk) => {
732
+ const m = mineOf(disk);
733
+ if (nonEmpty(rejectedToken) && m && nonEmpty(m.token) && m.token !== rejectedToken) {
734
+ replaced = true; // another process wrote one
735
+ return null;
736
+ }
737
+ hist = mergedHist(disk, now());
738
+ // The marker makes the next mint on this seat a forced one, from any process.
739
+ return { invalidatedAt: new Date(now()).toISOString(), keyFp, baseUrl, ...histFields(hist) };
740
+ });
741
+ if (!replaced) invalidatedHere = true;
742
+ }
743
+
744
+ return { getToken, peek, invalidate, baseUrl, keyFp, statePath };
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Edge: the seat's own manager (config + env resolved once per process)
749
+ // ---------------------------------------------------------------------------
750
+
751
+ /** Parse KEY=value lines (quotes stripped). Never throws. */
752
+ function parseDotEnvFile(path) {
753
+ const out = {};
754
+ let body = "";
755
+ try { body = existsSync(path) ? readFileSync(path, "utf-8") : ""; } catch { return out; }
756
+ for (const raw of body.split("\n")) {
757
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(raw);
758
+ if (!m) continue;
759
+ const v = m[2].replace(/^(['"])(.*)\1$/, "$2");
760
+ if (v) out[m[1]] = v;
761
+ }
762
+ return out;
763
+ }
764
+
765
+ const _managers = new Map();
766
+
767
+ /**
768
+ * The token manager for a seat, memoised per (state path, key, base URL).
769
+ * @param {object} [o]
770
+ * @param {string} [o.agentRoot]
771
+ * @param {object} [o.cfg] org config (default: config/org.yaml)
772
+ * @param {object} [o.env]
773
+ * @param {boolean} [o.readDotEnv=false] also read <agentRoot>/.env for the key
774
+ * @param {typeof fetch} [o.fetchImpl]
775
+ * @param {() => number} [o.now]
776
+ * @param {(level:string, msg:string) => void} [o.log]
777
+ */
778
+ export function seatTokenManager(o = {}) {
779
+ const agentRoot = resolve(o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd());
780
+ const env = o.env || process.env;
781
+ const cfg = o.cfg !== undefined ? o.cfg : loadOrgConfig(agentRoot);
782
+ const dotEnv = o.readDotEnv ? parseDotEnvFile(join(agentRoot, ".env")) : undefined;
783
+ const baseUrl = resolveLlmBaseUrl({ cfg, env });
784
+ const apiKey = seatApiKey({ cfg, env, dotEnv });
785
+ const statePath = tokenStatePath(agentRoot);
786
+ const key = `${statePath}|${keyFingerprint(apiKey)}|${baseUrl}`;
787
+ if (!o.fetchImpl && !o.now && _managers.has(key)) return _managers.get(key);
788
+ const m = createTokenManager({ baseUrl, apiKey, statePath, fetchImpl: o.fetchImpl, now: o.now, log: o.log, sleep: o.sleep });
789
+ if (!o.fetchImpl && !o.now) _managers.set(key, m);
790
+ return m;
791
+ }
792
+
793
+ /**
794
+ * Can this seat authenticate to cohort-llm at all? True when it holds an
795
+ * OrgApiKey (config or env) or an unexpired cached token. Never throws.
796
+ *
797
+ * `helperReadable: true` is the router's question for a Path B retarget: can
798
+ * the apiKeyHelper — a grandchild of the scrubbed claude child, which never
799
+ * sees a `*_API_KEY` from the daemon's process env — mint a token? Only a key
800
+ * in config/org.yaml or <agentRoot>/.env, or a live cached token, counts. An
801
+ * env-only key would route the seat to the gateway with a helper that exits 1.
802
+ * @param {{agentRoot?:string, env?:object, cfg?:object, dotEnv?:object, now?:number, helperReadable?:boolean}} [o]
803
+ */
804
+ export function hasSeatCredential(o = {}) {
805
+ try {
806
+ const env = o.env || process.env;
807
+ const agentRoot = o.agentRoot || env.AGENT_ROOT || env.AGENT_DIR;
808
+ if (!o.helperReadable && seatApiKey({ env })) return true;
809
+ if (!agentRoot) return false;
810
+ const cfg = o.cfg !== undefined ? o.cfg : loadOrgConfig(agentRoot);
811
+ if (seatApiKey({ cfg, env: {} })) return true;
812
+ if (o.helperReadable) {
813
+ const dotEnv = o.dotEnv !== undefined ? o.dotEnv : parseDotEnvFile(join(resolve(agentRoot), ".env"));
814
+ if (seatApiKey({ env: {}, dotEnv })) return true;
815
+ }
816
+ return isUnexpired(readRecord(tokenStatePath(agentRoot)), Number.isFinite(o.now) ? o.now : Date.now());
817
+ } catch {
818
+ return false;
819
+ }
820
+ }
821
+
822
+ const _clients = new Map();
823
+
824
+ /**
825
+ * The seat's cohort-llm clients — its token manager and a quota client that
826
+ * authenticates with that token — memoised per seat so every gate in one
827
+ * process shares one quota cache.
828
+ * @param {object} [o] as seatTokenManager, plus ttlMs
829
+ * @returns {{token: ReturnType<typeof createTokenManager>, quota: ReturnType<typeof createQuotaClient>}}
830
+ */
831
+ export function seatCohortClients(o = {}) {
832
+ const agentRoot = resolve(o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd());
833
+ const token = seatTokenManager({ ...o, agentRoot });
834
+ const injected = !!(o.fetchImpl || o.now);
835
+ const key = `${token.statePath}|${token.keyFp}|${token.baseUrl}`;
836
+ if (!injected && _clients.has(key)) return _clients.get(key);
837
+ const quota = createQuotaClient({
838
+ baseUrl: token.baseUrl,
839
+ getToken: (opts) => token.getToken(opts),
840
+ invalidateToken: (rejected) => token.invalidate(rejected),
841
+ fetchImpl: o.fetchImpl,
842
+ now: o.now,
843
+ ttlMs: o.ttlMs,
844
+ statePath: quotaStatePath(agentRoot),
845
+ log: o.log,
846
+ });
847
+ const pair = { token, quota };
848
+ if (!injected) _clients.set(key, pair);
849
+ return pair;
850
+ }
851
+
852
+ /** Test seam: forget every memoised seat manager and client. */
853
+ export function _resetTokenManagersForTests() {
854
+ _managers.clear();
855
+ _clients.clear();
856
+ }
857
+
858
+ export default {
859
+ DEFAULT_LLM_BASE_URL,
860
+ resolveLlmBaseUrl,
861
+ cohortEndpoints,
862
+ seatApiKey,
863
+ tokenStatePath,
864
+ keyFingerprint,
865
+ apiKeyHelperCommand,
866
+ redactSecrets,
867
+ needsRefresh,
868
+ isUnexpired,
869
+ classifyExchangeFailure,
870
+ remintBudget,
871
+ exchangeRetryDelay,
872
+ parseRetryAfterMs,
873
+ DEFAULT_REMINT_CAPS,
874
+ DEFAULT_EXCHANGE_RETRY,
875
+ createTokenManager,
876
+ seatTokenManager,
877
+ seatCohortClients,
878
+ hasSeatCredential,
879
+ };