@cohortapp/agent-sdk 2.17.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 (526) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +18 -5
  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/setup-wizard.md +1 -1
  9. package/docs/runbooks/fleet-rollout.md +156 -0
  10. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  11. package/lib/action-executor.js +19 -3
  12. package/lib/budget-guard.mjs +279 -3
  13. package/lib/channels/base-adapter.mjs +3 -1
  14. package/lib/channels/contract.mjs +2 -1
  15. package/lib/channels/inbox-item.mjs +8 -0
  16. package/lib/claude-bin.mjs +5 -6
  17. package/lib/cli/doctor-checks.mjs +141 -10
  18. package/lib/cli/global-setup-extras.mjs +5 -1
  19. package/lib/cli/inbox.mjs +100 -15
  20. package/lib/cli/seat-auth.mjs +463 -0
  21. package/lib/cli/session.mjs +80 -12
  22. package/lib/collective/capture.mjs +8 -6
  23. package/lib/collective/global-config.mjs +63 -1
  24. package/lib/collective/presence.mjs +142 -5
  25. package/lib/comms/send-gate.mjs +559 -1
  26. package/lib/diagnostics/alerts.mjs +49 -0
  27. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  28. package/lib/engine/agents/definitions.mjs +343 -0
  29. package/lib/engine/agents/persist.mjs +275 -0
  30. package/lib/engine/agents/runtime.mjs +748 -0
  31. package/lib/engine/agents/usage.mjs +95 -0
  32. package/lib/engine/auth-status.mjs +139 -0
  33. package/lib/engine/budget.mjs +194 -0
  34. package/lib/engine/cli.mjs +1204 -0
  35. package/lib/engine/commands/index.mjs +269 -0
  36. package/lib/engine/context/budget.mjs +219 -0
  37. package/lib/engine/context/cache.mjs +125 -0
  38. package/lib/engine/context/child-env.mjs +215 -0
  39. package/lib/engine/context/compaction.mjs +342 -0
  40. package/lib/engine/context/images.mjs +90 -0
  41. package/lib/engine/context/instructions.mjs +327 -0
  42. package/lib/engine/context/lazy-instructions.mjs +169 -0
  43. package/lib/engine/context/manager.mjs +182 -0
  44. package/lib/engine/context/real-path.mjs +91 -0
  45. package/lib/engine/context/secret-values.mjs +163 -0
  46. package/lib/engine/context/settings.mjs +274 -0
  47. package/lib/engine/context/stream-input.mjs +159 -0
  48. package/lib/engine/guard.mjs +152 -0
  49. package/lib/engine/hooks.mjs +713 -0
  50. package/lib/engine/loop.mjs +560 -0
  51. package/lib/engine/mcp/client.mjs +254 -0
  52. package/lib/engine/mcp/config.mjs +301 -0
  53. package/lib/engine/mcp/http.mjs +201 -0
  54. package/lib/engine/mcp/index.mjs +146 -0
  55. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  56. package/lib/engine/mcp/naming.mjs +66 -0
  57. package/lib/engine/mcp/resources.mjs +89 -0
  58. package/lib/engine/mcp/results.mjs +133 -0
  59. package/lib/engine/mcp/stdio.mjs +137 -0
  60. package/lib/engine/mcp/supervisor.mjs +116 -0
  61. package/lib/engine/messages.mjs +104 -0
  62. package/lib/engine/output/json.mjs +164 -0
  63. package/lib/engine/output/stream-json.mjs +266 -0
  64. package/lib/engine/permissions.mjs +845 -0
  65. package/lib/engine/process-identity.mjs +164 -0
  66. package/lib/engine/process-tree.mjs +551 -0
  67. package/lib/engine/prompt.mjs +60 -0
  68. package/lib/engine/session/store.mjs +299 -0
  69. package/lib/engine/session-runtime/args.mjs +97 -0
  70. package/lib/engine/session-runtime/host.mjs +143 -0
  71. package/lib/engine/session-runtime/inbox.mjs +122 -0
  72. package/lib/engine/session-runtime/notifications.mjs +129 -0
  73. package/lib/engine/session-runtime/registry.mjs +328 -0
  74. package/lib/engine/session-runtime/runner.mjs +344 -0
  75. package/lib/engine/session-runtime/socket.mjs +212 -0
  76. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  77. package/lib/engine/skills/index.mjs +321 -0
  78. package/lib/engine/tools/bash-background.mjs +533 -0
  79. package/lib/engine/tools/bash.mjs +216 -0
  80. package/lib/engine/tools/edit.mjs +97 -0
  81. package/lib/engine/tools/glob.mjs +81 -0
  82. package/lib/engine/tools/grep.mjs +224 -0
  83. package/lib/engine/tools/index.mjs +84 -0
  84. package/lib/engine/tools/list-agents.mjs +32 -0
  85. package/lib/engine/tools/ls.mjs +127 -0
  86. package/lib/engine/tools/monitor.mjs +82 -0
  87. package/lib/engine/tools/notebook-edit.mjs +218 -0
  88. package/lib/engine/tools/read.mjs +103 -0
  89. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  90. package/lib/engine/tools/schema.mjs +144 -0
  91. package/lib/engine/tools/send-message.mjs +77 -0
  92. package/lib/engine/tools/session.mjs +70 -0
  93. package/lib/engine/tools/todo.mjs +144 -0
  94. package/lib/engine/tools/toolsearch.mjs +217 -0
  95. package/lib/engine/tools/walk.mjs +193 -0
  96. package/lib/engine/tools/web-switch.mjs +31 -0
  97. package/lib/engine/tools/webfetch-html.mjs +387 -0
  98. package/lib/engine/tools/webfetch-net.mjs +340 -0
  99. package/lib/engine/tools/webfetch.mjs +198 -0
  100. package/lib/engine/tools/websearch.mjs +91 -0
  101. package/lib/engine/tools/workflow.mjs +95 -0
  102. package/lib/engine/tools/write.mjs +76 -0
  103. package/lib/engine/tui/line-editor.mjs +137 -0
  104. package/lib/engine/tui/render.mjs +86 -0
  105. package/lib/engine/tui/tui.mjs +274 -0
  106. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  107. package/lib/engine/wire/effort.mjs +36 -0
  108. package/lib/engine/wire/errors.mjs +496 -0
  109. package/lib/engine/wire/http.mjs +441 -0
  110. package/lib/engine/wire/index.mjs +76 -0
  111. package/lib/engine/wire/openai-chat.mjs +332 -0
  112. package/lib/engine/wire/prompt-cache.mjs +79 -0
  113. package/lib/engine/wire/search.mjs +140 -0
  114. package/lib/engine/wire/sse.mjs +114 -0
  115. package/lib/engine/wire/stall.mjs +349 -0
  116. package/lib/engine/wire/token-provider.mjs +175 -0
  117. package/lib/engine/wire/usage.mjs +192 -0
  118. package/lib/engine/workflow/host.mjs +524 -0
  119. package/lib/engine/workflow/journal.mjs +188 -0
  120. package/lib/engine/workflow/json-schema.mjs +171 -0
  121. package/lib/engine/workflow/meta.mjs +329 -0
  122. package/lib/engine/workflow/notifications.mjs +52 -0
  123. package/lib/engine/workflow/runtime.mjs +447 -0
  124. package/lib/engine/workflow/sandbox.mjs +534 -0
  125. package/lib/engine/workflow/worker.mjs +141 -0
  126. package/lib/engine/workflow/worktree.mjs +74 -0
  127. package/lib/execution/disposition.mjs +1 -1
  128. package/lib/execution/intake.mjs +10 -0
  129. package/lib/execution/surface-policy.mjs +15 -0
  130. package/lib/learning/curator.mjs +8 -6
  131. package/lib/learning/reflect.mjs +8 -6
  132. package/lib/model-router/catalog/cohort.yaml +137 -0
  133. package/lib/model-router/catalog.mjs +118 -1
  134. package/lib/model-router/failover.mjs +67 -16
  135. package/lib/model-router/llm-task.mjs +39 -3
  136. package/lib/model-router/resolve.mjs +89 -3
  137. package/lib/model-router/spawn.mjs +46 -47
  138. package/lib/model-router/taxonomy.mjs +126 -4
  139. package/lib/org/cost-sync.mjs +141 -11
  140. package/lib/org/inbound/broadcast.mjs +289 -0
  141. package/lib/org/inbound/collective.mjs +375 -0
  142. package/lib/org/inbound/directedness.mjs +96 -8
  143. package/lib/org/inbound/facts.mjs +78 -2
  144. package/lib/org/inbound/project.mjs +22 -0
  145. package/lib/org/inbound/surfaces.mjs +14 -0
  146. package/lib/org/llm-token.mjs +879 -0
  147. package/lib/org/mesh.mjs +61 -0
  148. package/lib/org/messaging.mjs +3 -1
  149. package/lib/org/protocol.checksum +1 -1
  150. package/lib/org/protocol.mjs +15 -0
  151. package/lib/org/quota.mjs +520 -0
  152. package/lib/org/tool-surface.mjs +104 -16
  153. package/lib/org/ui-parity.mjs +16 -1
  154. package/lib/org/work-ledger.mjs +37 -6
  155. package/lib/rate-guard.mjs +114 -1
  156. package/lib/resource-governor.mjs +41 -6
  157. package/lib/runtime/adapter.mjs +823 -0
  158. package/lib/runtime/child-env.mjs +191 -0
  159. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  160. package/lib/runtime/seat-engine.mjs +162 -0
  161. package/lib/session/ask-ledger.mjs +271 -0
  162. package/lib/session/current-work.mjs +676 -0
  163. package/lib/session/feed-core.mjs +40 -3
  164. package/lib/session/launch-args.mjs +56 -4
  165. package/lib/session/status-summary.mjs +26 -9
  166. package/lib/session/upgrade-notice.mjs +42 -0
  167. package/lib/setup/claude-probe.mjs +117 -13
  168. package/lib/setup/enrich.mjs +13 -10
  169. package/lib/setup/sections/model.mjs +39 -13
  170. package/lib/telemetry/collect.mjs +208 -9
  171. package/lib/upgrade/ignored-drift.mjs +105 -0
  172. package/lib/voice/post-call-brief.mjs +30 -17
  173. package/package.json +13 -3
  174. package/plugins/maestro-skills/skills/board-work.md +5 -0
  175. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  176. package/plugins/maestro-skills/skills/main-session.md +18 -7
  177. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  178. package/scripts/ci/run-tests.mjs +47 -19
  179. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  180. package/scripts/collective/hook-runner.mjs +29 -2
  181. package/scripts/continuous-monitor.sh +13 -0
  182. package/scripts/cost/track-claude-usage.mjs +15 -0
  183. package/scripts/daemon/agent-daemon.mjs +408 -20
  184. package/scripts/daemon/assurance.mjs +48 -12
  185. package/scripts/daemon/cadence-consumer.mjs +218 -68
  186. package/scripts/daemon/cadence-handlers.mjs +73 -4
  187. package/scripts/daemon/classifier.mjs +75 -26
  188. package/scripts/daemon/context-compiler.mjs +51 -37
  189. package/scripts/daemon/deliver.mjs +30 -1
  190. package/scripts/daemon/dispatcher.mjs +595 -149
  191. package/scripts/daemon/health.mjs +14 -1
  192. package/scripts/daemon/maestro-daemon.mjs +11 -0
  193. package/scripts/daemon/prompt-builder.mjs +24 -0
  194. package/scripts/daemon/responder.mjs +246 -79
  195. package/scripts/daemon/sdk-version.mjs +98 -16
  196. package/scripts/eval/probe-gateway.mjs +635 -0
  197. package/scripts/eval/replay/extract.mjs +270 -0
  198. package/scripts/eval/replay/grade.mjs +260 -0
  199. package/scripts/eval/replay/lib/config.mjs +50 -0
  200. package/scripts/eval/replay/lib/effects.mjs +65 -0
  201. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  202. package/scripts/eval/replay/lib/judge.mjs +72 -0
  203. package/scripts/eval/replay/lib/redact.mjs +136 -0
  204. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  205. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  206. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  207. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  208. package/scripts/eval/replay/report.mjs +185 -0
  209. package/scripts/eval/replay/run.mjs +404 -0
  210. package/scripts/fleet/rollout.mjs +1094 -0
  211. package/scripts/hooks/pre-send-audit.sh +36 -245
  212. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  213. package/scripts/hooks/validate-state-yaml.sh +190 -0
  214. package/scripts/huddle/huddle-llm.mjs +361 -0
  215. package/scripts/huddle/huddle-server.mjs +46 -121
  216. package/scripts/local-triggers/autoupdate.sh +448 -78
  217. package/scripts/local-triggers/run-trigger.sh +13 -0
  218. package/scripts/maintenance/pin-integrity.mjs +364 -0
  219. package/scripts/poll-slack-events.sh +41 -9
  220. package/scripts/poller/slack-socket-mode.mjs +28 -3
  221. package/scripts/session/supervisor.mjs +80 -13
  222. package/scripts/spawn-session.sh +13 -0
  223. package/bin/maestro.test.mjs +0 -1574
  224. package/lib/action-executor.test.mjs +0 -871
  225. package/lib/archetype.test.mjs +0 -132
  226. package/lib/assurance/plan-note.test.mjs +0 -234
  227. package/lib/assurance/room-budget.test.mjs +0 -486
  228. package/lib/assurance/tier.test.mjs +0 -174
  229. package/lib/autonomy.test.mjs +0 -66
  230. package/lib/backlog.test.mjs +0 -302
  231. package/lib/backup/policy.test.mjs +0 -305
  232. package/lib/budget-escalate.test.mjs +0 -232
  233. package/lib/budget-guard.envelope.test.mjs +0 -476
  234. package/lib/budget-guard.test.mjs +0 -427
  235. package/lib/cadence-bus-requeue.test.mjs +0 -83
  236. package/lib/cadence-bus-schedule.test.mjs +0 -194
  237. package/lib/cadence-bus.test.mjs +0 -720
  238. package/lib/cadences.test.mjs +0 -230
  239. package/lib/capability/inventory.test.mjs +0 -232
  240. package/lib/capability.test.mjs +0 -78
  241. package/lib/channels/base-adapter.test.mjs +0 -590
  242. package/lib/channels/channels.test.mjs +0 -371
  243. package/lib/channels/contract.test.mjs +0 -162
  244. package/lib/channels/inbox-item.test.mjs +0 -368
  245. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  246. package/lib/channels/pairing.test.mjs +0 -270
  247. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  248. package/lib/channels/slack-adapter.test.mjs +0 -212
  249. package/lib/channels/telegram-adapter.test.mjs +0 -306
  250. package/lib/channels/voice/adapter.test.mjs +0 -278
  251. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  252. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  253. package/lib/charter.test.mjs +0 -89
  254. package/lib/claude-bin.test.mjs +0 -131
  255. package/lib/cli/board.test.mjs +0 -227
  256. package/lib/cli/design.test.mjs +0 -270
  257. package/lib/cli/doctor-checks.test.mjs +0 -336
  258. package/lib/cli/global-setup-extras.test.mjs +0 -462
  259. package/lib/cli/inbox.test.mjs +0 -230
  260. package/lib/cli/session-ack.test.mjs +0 -63
  261. package/lib/cli/session.test.mjs +0 -613
  262. package/lib/collective/capture.test.mjs +0 -121
  263. package/lib/collective/cards.test.mjs +0 -114
  264. package/lib/collective/config.test.mjs +0 -123
  265. package/lib/collective/global-config.test.mjs +0 -220
  266. package/lib/collective/global-skills.test.mjs +0 -126
  267. package/lib/collective/presence.test.mjs +0 -95
  268. package/lib/collective/recall.test.mjs +0 -116
  269. package/lib/collective/vendor-skills.test.mjs +0 -306
  270. package/lib/comms/send-gate.test.mjs +0 -770
  271. package/lib/comms.test.mjs +0 -41
  272. package/lib/context/budget.test.mjs +0 -252
  273. package/lib/context/history-scope.test.mjs +0 -79
  274. package/lib/cost/ledger-row.test.mjs +0 -183
  275. package/lib/design/design-md.test.mjs +0 -318
  276. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  277. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  278. package/lib/design/fixtures/foundation.json +0 -133
  279. package/lib/design/refresh-gate.test.mjs +0 -144
  280. package/lib/design/write.test.mjs +0 -241
  281. package/lib/diagnostics/alerts.test.mjs +0 -318
  282. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  283. package/lib/diagnostics/counters.test.mjs +0 -206
  284. package/lib/diagnostics/events.test.mjs +0 -290
  285. package/lib/diagnostics/otel.test.mjs +0 -196
  286. package/lib/diagnostics/trace.test.mjs +0 -251
  287. package/lib/env-compat.test.mjs +0 -104
  288. package/lib/execution/disposition.test.mjs +0 -553
  289. package/lib/execution/drive.test.mjs +0 -270
  290. package/lib/execution/effects.test.mjs +0 -344
  291. package/lib/execution/intake.test.mjs +0 -389
  292. package/lib/execution/journal.test.mjs +0 -261
  293. package/lib/execution/match.test.mjs +0 -235
  294. package/lib/execution/pipeline.test.mjs +0 -392
  295. package/lib/execution/route.test.mjs +0 -186
  296. package/lib/execution/surface-policy.test.mjs +0 -162
  297. package/lib/fs-atomic.test.mjs +0 -72
  298. package/lib/fs-ownership.test.mjs +0 -158
  299. package/lib/goals/admission.test.mjs +0 -164
  300. package/lib/goals/classify.test.mjs +0 -167
  301. package/lib/goals/collaborate.test.mjs +0 -336
  302. package/lib/goals/gaps.test.mjs +0 -284
  303. package/lib/goals/loop.test.mjs +0 -845
  304. package/lib/hooks/bus.test.mjs +0 -387
  305. package/lib/identity/persona.test.mjs +0 -142
  306. package/lib/kpi-sensors.test.mjs +0 -278
  307. package/lib/kpi.test.mjs +0 -244
  308. package/lib/learning/config.test.mjs +0 -75
  309. package/lib/learning/counters.test.mjs +0 -69
  310. package/lib/learning/curator-consolidate.test.mjs +0 -238
  311. package/lib/learning/curator.test.mjs +0 -106
  312. package/lib/learning/reflect.test.mjs +0 -0
  313. package/lib/learning/session-index.test.mjs +0 -125
  314. package/lib/learning/skill-writer.test.mjs +0 -210
  315. package/lib/mandate/audit.test.mjs +0 -195
  316. package/lib/mandate/contract.test.mjs +0 -185
  317. package/lib/mandate/derive.test.mjs +0 -274
  318. package/lib/mandate/model.test.mjs +0 -164
  319. package/lib/mandate/refresh.test.mjs +0 -389
  320. package/lib/mcp/server.test.mjs +0 -426
  321. package/lib/model-router/auth-profiles.test.mjs +0 -580
  322. package/lib/model-router/catalog.test.mjs +0 -385
  323. package/lib/model-router/economics.test.mjs +0 -438
  324. package/lib/model-router/failover.test.mjs +0 -439
  325. package/lib/model-router/health.test.mjs +0 -338
  326. package/lib/model-router/integration-coverage.test.mjs +0 -831
  327. package/lib/model-router/integration.test.mjs +0 -564
  328. package/lib/model-router/ledger.test.mjs +0 -415
  329. package/lib/model-router/llm-task.test.mjs +0 -392
  330. package/lib/model-router/org-credentials.test.mjs +0 -265
  331. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  332. package/lib/model-router/reconcile.test.mjs +0 -316
  333. package/lib/model-router/repair.test.mjs +0 -180
  334. package/lib/model-router/spawn.test.mjs +0 -446
  335. package/lib/model-router/taxonomy.test.mjs +0 -410
  336. package/lib/model-router.test.mjs +0 -1207
  337. package/lib/org/activity.test.mjs +0 -134
  338. package/lib/org/approvals.test.mjs +0 -216
  339. package/lib/org/awareness.test.mjs +0 -159
  340. package/lib/org/board-mine-cache.test.mjs +0 -53
  341. package/lib/org/board.test.mjs +0 -187
  342. package/lib/org/bootstrap-context.test.mjs +0 -153
  343. package/lib/org/client.test.mjs +0 -1206
  344. package/lib/org/cohort-client.test.mjs +0 -126
  345. package/lib/org/cost-sync.test.mjs +0 -153
  346. package/lib/org/doctor.test.mjs +0 -346
  347. package/lib/org/engagement-ledger.test.mjs +0 -112
  348. package/lib/org/engagement.test.mjs +0 -739
  349. package/lib/org/handoff.test.mjs +0 -269
  350. package/lib/org/inbound/directedness.test.mjs +0 -668
  351. package/lib/org/inbound/facts.test.mjs +0 -471
  352. package/lib/org/inbound/hydrate.test.mjs +0 -908
  353. package/lib/org/inbound/index.test.mjs +0 -429
  354. package/lib/org/inbound/project.test.mjs +0 -287
  355. package/lib/org/integration-tools.test.mjs +0 -160
  356. package/lib/org/keys.test.mjs +0 -92
  357. package/lib/org/knowledge.test.mjs +0 -326
  358. package/lib/org/leases.test.mjs +0 -235
  359. package/lib/org/mesh-directives.test.mjs +0 -110
  360. package/lib/org/mesh-integration.test.mjs +0 -127
  361. package/lib/org/mesh.test.mjs +0 -400
  362. package/lib/org/messaging.test.mjs +0 -471
  363. package/lib/org/param-contract.test.mjs +0 -477
  364. package/lib/org/policy.test.mjs +0 -237
  365. package/lib/org/protocol.checksum.test.mjs +0 -90
  366. package/lib/org/protocol.test.mjs +0 -323
  367. package/lib/org/push.test.mjs +0 -792
  368. package/lib/org/registry.test.mjs +0 -100
  369. package/lib/org/resource-tools.test.mjs +0 -361
  370. package/lib/org/tool-access.test.mjs +0 -144
  371. package/lib/org/tool-surface-integration.test.mjs +0 -120
  372. package/lib/org/tool-surface.test.mjs +0 -1268
  373. package/lib/org/typing.test.mjs +0 -291
  374. package/lib/org/ui-parity.test.mjs +0 -560
  375. package/lib/org/verify.test.mjs +0 -194
  376. package/lib/org/work-ledger.test.mjs +0 -273
  377. package/lib/plan/adoption-e2e.test.mjs +0 -366
  378. package/lib/plan/budget-enforcement.test.mjs +0 -400
  379. package/lib/plan/compile.test.mjs +0 -382
  380. package/lib/plan/emit.test.mjs +0 -269
  381. package/lib/plan/explain.test.mjs +0 -188
  382. package/lib/prompts/parallelism.test.mjs +0 -177
  383. package/lib/rag/rag.test.mjs +0 -505
  384. package/lib/rate-guard.test.mjs +0 -272
  385. package/lib/reactive-gate.test.mjs +0 -57
  386. package/lib/render.test.mjs +0 -68
  387. package/lib/resource-governor.test.mjs +0 -488
  388. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  389. package/lib/scheduling/jitter.test.mjs +0 -140
  390. package/lib/secrets/broker.test.mjs +0 -280
  391. package/lib/secrets/providers.test.mjs +0 -274
  392. package/lib/security/audit-engine.test.mjs +0 -424
  393. package/lib/security/coerce-args.test.mjs +0 -281
  394. package/lib/security/dangerous-tools.test.mjs +0 -68
  395. package/lib/security/external-content.test.mjs +0 -84
  396. package/lib/security/redact.test.mjs +0 -441
  397. package/lib/security/secret-equal.test.mjs +0 -55
  398. package/lib/session/config.test.mjs +0 -92
  399. package/lib/session/feed-core.test.mjs +0 -198
  400. package/lib/session/first-run.test.mjs +0 -121
  401. package/lib/session/frontdoor.test.mjs +0 -205
  402. package/lib/session/handoffs.test.mjs +0 -183
  403. package/lib/session/identity.test.mjs +0 -180
  404. package/lib/session/inbox-claims.test.mjs +0 -286
  405. package/lib/session/launch-args.test.mjs +0 -157
  406. package/lib/session/liveness.test.mjs +0 -100
  407. package/lib/session/status-summary.test.mjs +0 -118
  408. package/lib/session-permissions.test.mjs +0 -120
  409. package/lib/setup/claude-probe.test.mjs +0 -187
  410. package/lib/setup/completeness.test.mjs +0 -110
  411. package/lib/setup/context-pack.test.mjs +0 -89
  412. package/lib/setup/enrich.test.mjs +0 -115
  413. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  414. package/lib/setup/integration.test.mjs +0 -162
  415. package/lib/setup/io.test.mjs +0 -77
  416. package/lib/setup/runner.test.mjs +0 -132
  417. package/lib/setup/sections/identity.test.mjs +0 -234
  418. package/lib/setup/sections/inventory.test.mjs +0 -198
  419. package/lib/setup/sections/learning.test.mjs +0 -81
  420. package/lib/setup/sections/mandate.test.mjs +0 -388
  421. package/lib/setup/sections/messaging.test.mjs +0 -127
  422. package/lib/setup/sections/model.test.mjs +0 -240
  423. package/lib/setup/sections/org.test.mjs +0 -346
  424. package/lib/setup/sections/orgmail.test.mjs +0 -118
  425. package/lib/setup/sections/recovery.test.mjs +0 -98
  426. package/lib/setup/sections/subagents.test.mjs +0 -429
  427. package/lib/setup/sections/verify.test.mjs +0 -175
  428. package/lib/setup/sot.test.mjs +0 -81
  429. package/lib/setup/state.test.mjs +0 -115
  430. package/lib/singleton.test.mjs +0 -151
  431. package/lib/subagents/cli.test.mjs +0 -389
  432. package/lib/subagents/client.test.mjs +0 -309
  433. package/lib/subagents/gap.test.mjs +0 -234
  434. package/lib/subagents/lock.test.mjs +0 -248
  435. package/lib/subagents/manifest.test.mjs +0 -175
  436. package/lib/subagents/refs.test.mjs +0 -204
  437. package/lib/subagents/resolve.test.mjs +0 -422
  438. package/lib/subagents/schema.test.mjs +0 -328
  439. package/lib/telemetry/alerts.test.mjs +0 -109
  440. package/lib/telemetry/collect.test.mjs +0 -1274
  441. package/lib/tool-definitions-integration.test.mjs +0 -83
  442. package/lib/tool-definitions.test.mjs +0 -437
  443. package/lib/upgrade/global-refresh.test.mjs +0 -65
  444. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  445. package/lib/upgrade/post-steps.test.mjs +0 -200
  446. package/lib/upgrade/verify.test.mjs +0 -164
  447. package/lib/util/fetch-timeout.test.mjs +0 -202
  448. package/lib/util/reconnect.test.mjs +0 -369
  449. package/lib/util/unhandled.test.mjs +0 -216
  450. package/lib/voice/outbound.test.mjs +0 -69
  451. package/lib/voice/session-rotation.test.mjs +0 -114
  452. package/lib/voice/stt.test.mjs +0 -226
  453. package/lib/voice/voice.test.mjs +0 -990
  454. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  455. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  456. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  457. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  458. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  459. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  460. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  461. package/scripts/ci/check.test.mjs +0 -194
  462. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  463. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  464. package/scripts/collective/hook-runner.test.mjs +0 -173
  465. package/scripts/cost/fleet-digest.test.mjs +0 -207
  466. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  467. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  468. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  469. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  470. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  471. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  472. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  473. package/scripts/daemon/assurance.test.mjs +0 -1791
  474. package/scripts/daemon/board-mirror.test.mjs +0 -165
  475. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  476. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  477. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  478. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  479. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  480. package/scripts/daemon/classifier.test.mjs +0 -266
  481. package/scripts/daemon/classify-kind.test.mjs +0 -40
  482. package/scripts/daemon/context-compiler.test.mjs +0 -406
  483. package/scripts/daemon/deliver.test.mjs +0 -564
  484. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  485. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  486. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  487. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  488. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  489. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  490. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  491. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  492. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  493. package/scripts/daemon/integration.test.mjs +0 -149
  494. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  495. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  496. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  497. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  498. package/scripts/daemon/responder-cost.test.mjs +0 -68
  499. package/scripts/daemon/responder-history.test.mjs +0 -221
  500. package/scripts/daemon/sdk-version.test.mjs +0 -31
  501. package/scripts/daemon/session-lock.test.mjs +0 -252
  502. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  503. package/scripts/daemon/typing-registry.test.mjs +0 -102
  504. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  505. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  506. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  507. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  508. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  509. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  510. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  511. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  512. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  513. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  514. package/scripts/poller-launchd/install.test.mjs +0 -243
  515. package/scripts/restore-from-backup.test.mjs +0 -181
  516. package/scripts/session/feed.test.mjs +0 -196
  517. package/scripts/session/supervisor-sh.test.mjs +0 -218
  518. package/scripts/session/supervisor.test.mjs +0 -482
  519. package/scripts/setup/configure-macos.test.mjs +0 -306
  520. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  521. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  522. package/scripts/setup/generate-capability.test.mjs +0 -134
  523. package/scripts/setup/init-agent.test.mjs +0 -370
  524. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  525. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  526. 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
+ };