@intentic/sandbox-contract 1.240.0 → 1.243.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/README.md +52 -16
  2. package/dist/agent-catalog.d.ts +5 -28
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +21 -157
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -2
  7. package/dist/agent-run-model.d.ts.map +1 -1
  8. package/dist/agent-run-model.js +9 -6
  9. package/dist/agent-run-model.js.map +1 -1
  10. package/dist/agent-runtimes.d.ts +27 -0
  11. package/dist/agent-runtimes.d.ts.map +1 -0
  12. package/dist/agent-runtimes.js +113 -0
  13. package/dist/agent-runtimes.js.map +1 -0
  14. package/dist/approvals-execution.d.ts +11 -0
  15. package/dist/approvals-execution.d.ts.map +1 -0
  16. package/dist/approvals-execution.js +44 -0
  17. package/dist/approvals-execution.js.map +1 -0
  18. package/dist/arrival.d.ts +151 -0
  19. package/dist/arrival.d.ts.map +1 -0
  20. package/dist/arrival.js +56 -0
  21. package/dist/arrival.js.map +1 -0
  22. package/dist/card-status.d.ts +5 -0
  23. package/dist/card-status.d.ts.map +1 -0
  24. package/dist/card-status.js +74 -0
  25. package/dist/card-status.js.map +1 -0
  26. package/dist/chores/chores.d.ts.map +1 -1
  27. package/dist/chores/chores.js +1 -1
  28. package/dist/chores/chores.js.map +1 -1
  29. package/dist/command-classes.d.ts +0 -1
  30. package/dist/command-classes.d.ts.map +1 -1
  31. package/dist/command-classes.js +9 -2
  32. package/dist/command-classes.js.map +1 -1
  33. package/dist/command-run.d.ts +10 -0
  34. package/dist/command-run.d.ts.map +1 -0
  35. package/dist/command-run.js +46 -0
  36. package/dist/command-run.js.map +1 -0
  37. package/dist/contracts/agent.contract.d.ts +991 -771
  38. package/dist/contracts/agent.contract.d.ts.map +1 -1
  39. package/dist/contracts/agents.contract.d.ts +345 -337
  40. package/dist/contracts/agents.contract.d.ts.map +1 -1
  41. package/dist/contracts/agents.contract.js +20 -2
  42. package/dist/contracts/agents.contract.js.map +1 -1
  43. package/dist/contracts/approvals.contract.d.ts +99 -0
  44. package/dist/contracts/approvals.contract.d.ts.map +1 -0
  45. package/dist/contracts/approvals.contract.js +32 -0
  46. package/dist/contracts/approvals.contract.js.map +1 -0
  47. package/dist/contracts/ci.contract.d.ts +1 -4
  48. package/dist/contracts/ci.contract.d.ts.map +1 -1
  49. package/dist/contracts/ci.contract.js +1 -9
  50. package/dist/contracts/ci.contract.js.map +1 -1
  51. package/dist/contracts/claude.contract.d.ts +9 -0
  52. package/dist/contracts/claude.contract.d.ts.map +1 -1
  53. package/dist/contracts/cursor.contract.d.ts +6 -0
  54. package/dist/contracts/cursor.contract.d.ts.map +1 -1
  55. package/dist/contracts/git.contract.d.ts +63 -1
  56. package/dist/contracts/git.contract.d.ts.map +1 -1
  57. package/dist/contracts/git.contract.js +22 -4
  58. package/dist/contracts/git.contract.js.map +1 -1
  59. package/dist/contracts/grok.contract.d.ts +3 -0
  60. package/dist/contracts/grok.contract.d.ts.map +1 -1
  61. package/dist/contracts/keys.contract.d.ts +81 -0
  62. package/dist/contracts/keys.contract.d.ts.map +1 -0
  63. package/dist/contracts/keys.contract.js +51 -0
  64. package/dist/contracts/keys.contract.js.map +1 -0
  65. package/dist/contracts/panels.contract.d.ts +7 -0
  66. package/dist/contracts/panels.contract.d.ts.map +1 -1
  67. package/dist/contracts/prepush.contract.js +2 -2
  68. package/dist/contracts/providers.contract.d.ts +2 -0
  69. package/dist/contracts/providers.contract.d.ts.map +1 -1
  70. package/dist/contracts/runner.contract.d.ts +84 -86
  71. package/dist/contracts/runner.contract.d.ts.map +1 -1
  72. package/dist/contracts/safety.contract.d.ts +35 -0
  73. package/dist/contracts/safety.contract.d.ts.map +1 -0
  74. package/dist/contracts/safety.contract.js +32 -0
  75. package/dist/contracts/safety.contract.js.map +1 -0
  76. package/dist/contracts/sessions.contract.d.ts +73 -337
  77. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  78. package/dist/contracts/settings.contract.d.ts +30 -32
  79. package/dist/contracts/settings.contract.d.ts.map +1 -1
  80. package/dist/contracts/system.contract.d.ts +139 -337
  81. package/dist/contracts/system.contract.d.ts.map +1 -1
  82. package/dist/contracts/translator.contract.d.ts +31 -3
  83. package/dist/contracts/translator.contract.d.ts.map +1 -1
  84. package/dist/contracts/usage.contract.d.ts +29 -20
  85. package/dist/contracts/usage.contract.d.ts.map +1 -1
  86. package/dist/contracts/usage.contract.js +13 -0
  87. package/dist/contracts/usage.contract.js.map +1 -1
  88. package/dist/contracts/workspace.contract.d.ts +8 -0
  89. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  90. package/dist/conversation-ids.d.ts +2 -0
  91. package/dist/conversation-ids.d.ts.map +1 -1
  92. package/dist/conversation-ids.js +8 -0
  93. package/dist/conversation-ids.js.map +1 -1
  94. package/dist/credential-material.d.ts +1 -0
  95. package/dist/credential-material.d.ts.map +1 -1
  96. package/dist/credential-material.js +24 -2
  97. package/dist/credential-material.js.map +1 -1
  98. package/dist/definition.d.ts +51 -93
  99. package/dist/definition.d.ts.map +1 -1
  100. package/dist/definition.js +6 -28
  101. package/dist/definition.js.map +1 -1
  102. package/dist/events.d.ts +1831 -2285
  103. package/dist/events.d.ts.map +1 -1
  104. package/dist/events.js +155 -55
  105. package/dist/events.js.map +1 -1
  106. package/dist/history-state.d.ts.map +1 -1
  107. package/dist/history-state.js +1 -0
  108. package/dist/history-state.js.map +1 -1
  109. package/dist/index.d.ts +1959 -1903
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +20 -5
  112. package/dist/index.js.map +1 -1
  113. package/dist/ingress-protocol.d.ts +13 -1
  114. package/dist/ingress-protocol.d.ts.map +1 -1
  115. package/dist/ingress-protocol.js +19 -0
  116. package/dist/ingress-protocol.js.map +1 -1
  117. package/dist/mentions.d.ts +3 -0
  118. package/dist/mentions.d.ts.map +1 -0
  119. package/dist/mentions.js +13 -0
  120. package/dist/mentions.js.map +1 -0
  121. package/dist/needs-action.d.ts +7 -0
  122. package/dist/needs-action.d.ts.map +1 -0
  123. package/dist/needs-action.js +3 -0
  124. package/dist/needs-action.js.map +1 -0
  125. package/dist/overlay-lint.d.ts +7 -0
  126. package/dist/overlay-lint.d.ts.map +1 -0
  127. package/dist/overlay-lint.js +68 -0
  128. package/dist/overlay-lint.js.map +1 -0
  129. package/dist/owner-ticket.d.ts +18 -0
  130. package/dist/owner-ticket.d.ts.map +1 -0
  131. package/dist/owner-ticket.js +51 -0
  132. package/dist/owner-ticket.js.map +1 -0
  133. package/dist/plan-pools.d.ts +13 -0
  134. package/dist/plan-pools.d.ts.map +1 -0
  135. package/dist/plan-pools.js +31 -0
  136. package/dist/plan-pools.js.map +1 -0
  137. package/dist/provider-specs.d.ts +228 -0
  138. package/dist/provider-specs.d.ts.map +1 -0
  139. package/dist/provider-specs.js +124 -0
  140. package/dist/provider-specs.js.map +1 -0
  141. package/dist/quick-model.d.ts.map +1 -1
  142. package/dist/quick-model.js +2 -1
  143. package/dist/quick-model.js.map +1 -1
  144. package/dist/runner-protocol.js +2 -2
  145. package/dist/runner-protocol.js.map +1 -1
  146. package/dist/runtime-state.d.ts +2 -2
  147. package/dist/runtime-state.d.ts.map +1 -1
  148. package/dist/runtime-state.js +1 -1
  149. package/dist/runtime-state.js.map +1 -1
  150. package/dist/safety-policy.d.ts +49 -0
  151. package/dist/safety-policy.d.ts.map +1 -0
  152. package/dist/safety-policy.js +52 -0
  153. package/dist/safety-policy.js.map +1 -0
  154. package/dist/schemas/agent.d.ts +21 -3
  155. package/dist/schemas/agent.d.ts.map +1 -1
  156. package/dist/schemas/agent.js +18 -8
  157. package/dist/schemas/agent.js.map +1 -1
  158. package/dist/schemas/agents.d.ts +19 -0
  159. package/dist/schemas/agents.d.ts.map +1 -1
  160. package/dist/schemas/agents.js +15 -0
  161. package/dist/schemas/agents.js.map +1 -1
  162. package/dist/schemas/approvals.d.ts +241 -0
  163. package/dist/schemas/approvals.d.ts.map +1 -0
  164. package/dist/schemas/approvals.js +63 -0
  165. package/dist/schemas/approvals.js.map +1 -0
  166. package/dist/schemas/automations.d.ts +5 -0
  167. package/dist/schemas/automations.d.ts.map +1 -1
  168. package/dist/schemas/ci.d.ts +5 -9
  169. package/dist/schemas/ci.d.ts.map +1 -1
  170. package/dist/schemas/ci.js +3 -10
  171. package/dist/schemas/ci.js.map +1 -1
  172. package/dist/schemas/computers.d.ts +40 -5
  173. package/dist/schemas/computers.d.ts.map +1 -1
  174. package/dist/schemas/computers.js +16 -5
  175. package/dist/schemas/computers.js.map +1 -1
  176. package/dist/schemas/environment.d.ts +11 -131
  177. package/dist/schemas/environment.d.ts.map +1 -1
  178. package/dist/schemas/environment.js +5 -42
  179. package/dist/schemas/environment.js.map +1 -1
  180. package/dist/schemas/git-history.d.ts +4 -0
  181. package/dist/schemas/git-history.d.ts.map +1 -1
  182. package/dist/schemas/git.d.ts +179 -0
  183. package/dist/schemas/git.d.ts.map +1 -1
  184. package/dist/schemas/git.js +39 -1
  185. package/dist/schemas/git.js.map +1 -1
  186. package/dist/schemas/panels.d.ts +21 -0
  187. package/dist/schemas/panels.d.ts.map +1 -1
  188. package/dist/schemas/panels.js +5 -0
  189. package/dist/schemas/panels.js.map +1 -1
  190. package/dist/schemas/plan-limits.d.ts +50 -3
  191. package/dist/schemas/plan-limits.d.ts.map +1 -1
  192. package/dist/schemas/plan-limits.js +11 -6
  193. package/dist/schemas/plan-limits.js.map +1 -1
  194. package/dist/schemas/provider-oauth.d.ts +6 -0
  195. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  196. package/dist/schemas/provider-subscriptions.d.ts +2 -1
  197. package/dist/schemas/provider-subscriptions.d.ts.map +1 -1
  198. package/dist/schemas/provider-subscriptions.js +2 -1
  199. package/dist/schemas/provider-subscriptions.js.map +1 -1
  200. package/dist/schemas/settings.d.ts +29 -16
  201. package/dist/schemas/settings.d.ts.map +1 -1
  202. package/dist/schemas/settings.js +13 -20
  203. package/dist/schemas/settings.js.map +1 -1
  204. package/dist/schemas/system.d.ts +3 -0
  205. package/dist/schemas/system.d.ts.map +1 -1
  206. package/dist/schemas/system.js +5 -1
  207. package/dist/schemas/system.js.map +1 -1
  208. package/dist/schemas/workspace-repos.d.ts +14 -0
  209. package/dist/schemas/workspace-repos.d.ts.map +1 -1
  210. package/dist/schemas/workspace-repos.js +5 -0
  211. package/dist/schemas/workspace-repos.js.map +1 -1
  212. package/dist/schemas/workspace-tree.d.ts +1 -0
  213. package/dist/schemas/workspace-tree.d.ts.map +1 -1
  214. package/dist/schemas/workspace-tree.js +3 -0
  215. package/dist/schemas/workspace-tree.js.map +1 -1
  216. package/dist/transcript-fold.d.ts +31 -0
  217. package/dist/transcript-fold.d.ts.map +1 -0
  218. package/dist/transcript-fold.js +457 -0
  219. package/dist/transcript-fold.js.map +1 -0
  220. package/dist/workspace-state.d.ts +20 -3
  221. package/dist/workspace-state.d.ts.map +1 -1
  222. package/dist/workspace-state.js +22 -2
  223. package/dist/workspace-state.js.map +1 -1
  224. package/package.json +27 -5
  225. package/src/agent-catalog.test.ts +1 -1
  226. package/src/agent-catalog.ts +76 -555
  227. package/src/agent-run-model.test.ts +31 -15
  228. package/src/agent-run-model.ts +26 -12
  229. package/src/agent-runtimes.ts +419 -0
  230. package/src/approvals-execution.ts +96 -0
  231. package/src/arrival.ts +160 -0
  232. package/src/capability-ledger.test.ts +6 -4
  233. package/src/card-status.ts +93 -0
  234. package/src/chores/chores.ts +6 -3
  235. package/src/command-classes.test.ts +25 -0
  236. package/src/command-classes.ts +47 -5
  237. package/src/command-run.ts +78 -0
  238. package/src/contract-lock.test.ts +1 -1
  239. package/src/contract-lock.ts +2 -2
  240. package/src/contracts/agents.contract.ts +35 -2
  241. package/src/contracts/approvals.contract.ts +37 -0
  242. package/src/contracts/ci.contract.ts +0 -13
  243. package/src/contracts/git.contract.ts +28 -3
  244. package/src/contracts/keys.contract.ts +79 -0
  245. package/src/contracts/prepush.contract.ts +3 -3
  246. package/src/contracts/safety.contract.ts +48 -0
  247. package/src/contracts/usage.contract.ts +22 -0
  248. package/src/conversation-ids.test.ts +31 -1
  249. package/src/conversation-ids.ts +37 -3
  250. package/src/credential-material.test.ts +69 -1
  251. package/src/credential-material.ts +86 -5
  252. package/src/definition.ts +25 -53
  253. package/src/events.ts +374 -173
  254. package/src/history-state.ts +6 -0
  255. package/src/index.ts +24 -5
  256. package/src/ingress-protocol.ts +52 -1
  257. package/src/mentions.test.ts +22 -0
  258. package/src/mentions.ts +25 -0
  259. package/src/needs-action.ts +14 -0
  260. package/src/overlay-lint.test.ts +89 -0
  261. package/src/overlay-lint.ts +116 -0
  262. package/src/owner-ticket.test.ts +54 -0
  263. package/src/owner-ticket.ts +102 -0
  264. package/src/plan-pools.test.ts +71 -0
  265. package/src/plan-pools.ts +92 -0
  266. package/src/provider-specs.test.ts +130 -0
  267. package/src/provider-specs.ts +352 -0
  268. package/src/quick-model.ts +9 -5
  269. package/src/runner-protocol.ts +2 -2
  270. package/src/runtime-state.ts +6 -6
  271. package/src/safety-policy.ts +165 -0
  272. package/src/schemas/agent.ts +62 -17
  273. package/src/schemas/agents.ts +71 -0
  274. package/src/schemas/approvals.ts +174 -0
  275. package/src/schemas/ci.ts +19 -26
  276. package/src/schemas/computers.ts +91 -15
  277. package/src/schemas/environment.ts +21 -100
  278. package/src/schemas/git.ts +121 -6
  279. package/src/schemas/issues.ts +1 -1
  280. package/src/schemas/panels.ts +20 -0
  281. package/src/schemas/plan-limits.ts +58 -11
  282. package/src/schemas/provider-subscriptions.ts +7 -2
  283. package/src/schemas/settings.ts +67 -93
  284. package/src/schemas/system.ts +12 -1
  285. package/src/schemas/workspace-repos.ts +9 -0
  286. package/src/schemas/workspace-tree.ts +11 -0
  287. package/src/transcript-fold.test.ts +493 -0
  288. package/src/transcript-fold.ts +655 -0
  289. package/src/workspace-state.test.ts +77 -29
  290. package/src/workspace-state.ts +99 -31
  291. package/dist/contracts/drafts.contract.d.ts +0 -57
  292. package/dist/contracts/drafts.contract.d.ts.map +0 -1
  293. package/dist/contracts/drafts.contract.js +0 -32
  294. package/dist/contracts/drafts.contract.js.map +0 -1
  295. package/dist/definition-action.d.ts +0 -7
  296. package/dist/definition-action.d.ts.map +0 -1
  297. package/dist/definition-action.js +0 -3
  298. package/dist/definition-action.js.map +0 -1
  299. package/dist/publish-drafts.d.ts +0 -7
  300. package/dist/publish-drafts.d.ts.map +0 -1
  301. package/dist/publish-drafts.js +0 -20
  302. package/dist/publish-drafts.js.map +0 -1
  303. package/dist/schemas/drafts.d.ts +0 -84
  304. package/dist/schemas/drafts.d.ts.map +0 -1
  305. package/dist/schemas/drafts.js +0 -44
  306. package/dist/schemas/drafts.js.map +0 -1
  307. package/src/contracts/drafts.contract.ts +0 -35
  308. package/src/definition-action.ts +0 -8
  309. package/src/publish-drafts.ts +0 -59
  310. package/src/schemas/drafts.ts +0 -108
@@ -0,0 +1,71 @@
1
+ import { expect, test } from "vitest";
2
+ import { bindingWindow, gatesModel, gatingWindows, scopedWindow } from "./plan-pools.js";
3
+ import type { AccountUsage, UsageWindow } from "./schemas/plan-limits.js";
4
+
5
+ /* ONE RULE FOR "WHICH POOL STANDS IN THE WAY OF THIS MODEL", shared by the daemon's picker and the browser's
6
+ * rings. What these pin is the case the six rules it replaced disagreed on: a plan that meters models
7
+ * separately, where the account's fullest pool and the pool a given model spends are different allowances. */
8
+
9
+ const window = (over: Partial<UsageWindow> & Pick<UsageWindow, "kind">): UsageWindow => ({ utilization: 10, gates: "all", ...over });
10
+ const usage = (...windows: UsageWindow[]): AccountUsage => ({ windows, measuredAt: 0 });
11
+
12
+ const GOOGLE = usage(
13
+ window({ kind: "google:gemini-weekly", label: "Gemini models", utilization: 100, gates: { models: ["gemini"] } }),
14
+ window({ kind: "google:3p-weekly", label: "Claude and GPT models", utilization: 27, gates: { models: ["claude", "gpt"] } }),
15
+ );
16
+
17
+ const CLAUDE = usage(
18
+ window({ kind: "five_hour", utilization: 12 }),
19
+ window({ kind: "seven_day", utilization: 30 }),
20
+ window({ kind: "model:Opus", label: "Opus", utilization: 100, gates: { models: ["Opus"] } }),
21
+ window({ kind: "surface:Cowork", label: "Cowork", utilization: 99, gates: "none" }),
22
+ );
23
+
24
+ test("matches a pool's names as runs of whole words against the id and the label alike", () => {
25
+ expect(gatesModel({ models: ["opus"] }, { id: "claude-opus-4-6" })).toBe(true);
26
+ expect(gatesModel({ models: ["Opus"] }, { id: "some-id", label: "Claude Opus 4.6" })).toBe(true);
27
+ // "Sonnet" is not in "claude-opus-4-6", and a substring test is what would have said "son" was.
28
+ expect(gatesModel({ models: ["son"] }, { id: "claude-sonnet-4-6" })).toBe(false);
29
+ expect(gatesModel({ models: ["claude opus"] }, { id: "claude-opus-4-6" })).toBe(true);
30
+ expect(gatesModel({ models: ["opus claude"] }, { id: "claude-opus-4-6" })).toBe(false);
31
+ expect(gatesModel("all", { id: "anything" })).toBe(true);
32
+ expect(gatesModel("none", { id: "anything" })).toBe(false);
33
+ });
34
+
35
+ test("a Google account spent for Gemini still has room for Claude Opus, and the other way round", () => {
36
+ // The Antigravity case: one sign-in, two allowances on two clocks. Reading the account's fullest pool put a
37
+ // red ring over Claude Opus while its own pool sat at 27%.
38
+ expect(bindingWindow(GOOGLE, { id: "claude-opus-4-6-thinking" })?.kind).toBe("google:3p-weekly");
39
+ expect(bindingWindow(GOOGLE, { id: "gpt-oss-120b" })?.kind).toBe("google:3p-weekly");
40
+ expect(bindingWindow(GOOGLE, { id: "gemini-3-pro" })?.kind).toBe("google:gemini-weekly");
41
+ // A family the channel has not published a pool for is gated by nothing: unmeasured, never blocked.
42
+ expect(bindingWindow(GOOGLE, { id: "kimi-k2" })).toBeUndefined();
43
+ });
44
+
45
+ test("a Claude account's spent Opus slice does not bind a Haiku call, and its own 5-hour and weekly pools do", () => {
46
+ expect(gatingWindows(CLAUDE, { id: "claude-haiku-4-5" }).map((entry) => entry.kind)).toEqual(["five_hour", "seven_day"]);
47
+ expect(bindingWindow(CLAUDE, { id: "claude-haiku-4-5" })?.kind).toBe("seven_day");
48
+ expect(bindingWindow(CLAUDE, { id: "claude-opus-4-6" })?.kind).toBe("model:Opus");
49
+ });
50
+
51
+ test("with no model named, the account's tightest pool is the answer, and a pool gating nothing is never it", () => {
52
+ // The Cowork surface pool is at 99% and gates none of this sandbox's turns: shown on the roster, never the
53
+ // account's headroom.
54
+ expect(bindingWindow(CLAUDE)?.kind).toBe("model:Opus");
55
+ expect(gatingWindows(CLAUDE).map((entry) => entry.kind)).toEqual(["five_hour", "seven_day", "model:Opus"]);
56
+ expect(bindingWindow(undefined)).toBeUndefined();
57
+ expect(bindingWindow(usage())).toBeUndefined();
58
+ });
59
+
60
+ test("names the pool a plan meters this model by on its own, preferring the more specific and refusing a tie", () => {
61
+ expect(scopedWindow(CLAUDE, { id: "claude-opus-4-6" })?.kind).toBe("model:Opus");
62
+ // The all-models weekly is not this model's own allowance, so a plan with no slice for it says nothing.
63
+ expect(scopedWindow(CLAUDE, { id: "claude-haiku-4-5" })).toBeUndefined();
64
+ const layered = usage(
65
+ window({ kind: "model:Opus", gates: { models: ["Opus"] } }),
66
+ window({ kind: "model:Claude Opus", gates: { models: ["Claude Opus"] } }),
67
+ );
68
+ expect(scopedWindow(layered, { id: "claude-opus-4-6" })?.kind).toBe("model:Claude Opus");
69
+ const tied = usage(window({ kind: "model:Opus", gates: { models: ["Opus"] } }), window({ kind: "model:Claude", gates: { models: ["Claude"] } }));
70
+ expect(scopedWindow(tied, { id: "claude-opus-4-6" })).toBeUndefined();
71
+ });
@@ -0,0 +1,92 @@
1
+ import type { AccountUsage, UsageWindow, WindowGates } from "./schemas/plan-limits.js";
2
+
3
+ /* WHICH OF AN ACCOUNT'S POOLS STAND BETWEEN A TURN AND A MODEL, read once, for both sides of the wire.
4
+ *
5
+ * Every question about headroom is really a question about ONE model: which account should serve this Haiku
6
+ * call, is this Google fleet spent for Claude Opus, when does the pool that refused this turn reopen, what
7
+ * does the ring beside the composer measure. The pools a reading carries answer that only through their
8
+ * `gates` (UsageWindowSchema says why the reader decides them), and this file is the one place the gate is
9
+ * read, so the daemon's account picker, its quick-model walk, its refusal dressing and the browser's rings,
10
+ * rail and picker rows all agree about which pool is binding for a given model.
11
+ *
12
+ * WITHOUT A MODEL the answer is the account's own tightest pool, which is what a roster or a rail that has not
13
+ * chosen a model yet is asking. Pools gated to `none` are left out of that too: a code-review limit is the
14
+ * account's to see, never the thing that decides whether a chat turn can run. */
15
+
16
+ /* WHEN A POOL HAS NOTHING LEFT IN IT, on the wire's own scale, read the same way by both sides.
17
+ *
18
+ * 100, and deliberately not the browser's 90 (usageStatus' SPENT_PERCENT): the two thresholds answer different
19
+ * questions. 90 is a WARNING — the point at which a surface stops recommending an account for a long turn, and
20
+ * what the rings and the bands take their red from. This is EXHAUSTION: the point past which a call is certain
21
+ * to be refused, which is what the daemon needs before it steers a turn elsewhere and the only line a list of
22
+ * offers may hide an account behind. A subscription with a tenth of its week left can still run the next task,
23
+ * and dropping it off a rail titled "Ready to run" answers the reader's question with the wrong one. */
24
+ export const SPENT_UTILIZATION = 100;
25
+
26
+ export interface ModelRef {
27
+ // The wire id ("claude-opus-4-6", "gemini-3-pro").
28
+ readonly id: string;
29
+ // What the picker calls it ("Claude Opus 4.6"), when the caller has it. Both are matched, because which of
30
+ // the two carries the tier word differs by vendor.
31
+ readonly label?: string | undefined;
32
+ }
33
+
34
+ /* TOKENS, NOT SUBSTRINGS. A pool is named by the plan ("Opus", "gemini") and the model by its vendor
35
+ * ("claude-opus-4-6", "Claude Opus 4.6"), and the two only ever agree on a word. A substring test would match
36
+ * "opus" inside an id that merely mentions it, and a normalized-string equality would match nothing at all. */
37
+ export const wordsOf = (text: string): readonly string[] =>
38
+ text
39
+ .toLowerCase()
40
+ .split(/[^a-z0-9]+/)
41
+ .filter(Boolean);
42
+
43
+ // Whether `needle` appears as a run of whole words in `words`: "claude opus" is in "claude-opus-4-6", "opus" is
44
+ // in "Claude Opus 4.6", and "sonnet" is in neither.
45
+ export const runOfWords = (words: readonly string[], needle: readonly string[]): boolean =>
46
+ needle.length > 0 && words.some((_, at) => needle.every((word, index) => words[at + index] === word));
47
+
48
+ const nameMatches = (name: string, model: ModelRef): boolean => {
49
+ const needle = wordsOf(name);
50
+ return runOfWords(wordsOf(model.id), needle) || (model.label !== undefined && runOfWords(wordsOf(model.label), needle));
51
+ };
52
+
53
+ export const gatesModel = (gates: WindowGates, model: ModelRef): boolean =>
54
+ gates === "all" ? true : gates === "none" ? false : gates.models.some((name) => nameMatches(name, model));
55
+
56
+ // The pools that gate a model, or, with no model named, every pool that gates anything at all.
57
+ export const gatingWindows = (usage: AccountUsage | undefined, model?: ModelRef): readonly UsageWindow[] =>
58
+ (usage?.windows ?? []).filter((window) => (model === undefined ? window.gates !== "none" : gatesModel(window.gates, model)));
59
+
60
+ const fullest = (windows: readonly UsageWindow[]): UsageWindow | undefined =>
61
+ windows.reduce<UsageWindow | undefined>((worst, window) => (worst === undefined || window.utilization > worst.utilization ? window : worst), undefined);
62
+
63
+ /* THE POOL THAT WILL GATE THE NEXT TURN: the fullest of the ones this model spends. A single headroom number
64
+ * can only ever be this one, the account is as constrained as its tightest allowance, whichever that happens
65
+ * to be today. Undefined when nothing gates the model (or nothing was measured), which every reader keeps
66
+ * distinct from a measured 0%. */
67
+ export const bindingWindow = (usage: AccountUsage | undefined, model?: ModelRef): UsageWindow | undefined => fullest(gatingWindows(usage, model));
68
+
69
+ /* THE POOL A PLAN METERS THIS MODEL BY ON ITS OWN, when it does: the model-scoped window whose name is the most
70
+ * specific match. Distinct from bindingWindow, which answers "what stops the next turn" and may well be the
71
+ * all-models weekly; this answers "does the plan keep a separate allowance for this tier", which is what a
72
+ * sentence naming the allowance wants ("Opus 82% used").
73
+ *
74
+ * AMBIGUITY ANSWERS NOTHING. Two pools matching one model at the same specificity (a plan metering "Opus" and
75
+ * "Claude Opus" separately, or the same name twice) means we cannot say which allowance a turn spends, so the
76
+ * more specific one wins and a tie returns undefined: no sentence beats a sentence naming the wrong pool. */
77
+ export const scopedWindow = (usage: AccountUsage | undefined, model: ModelRef): UsageWindow | undefined => {
78
+ const matched = (usage?.windows ?? [])
79
+ .flatMap((window) => {
80
+ if (window.gates === "all" || window.gates === "none") {
81
+ return [];
82
+ }
83
+ const specificity = Math.max(0, ...window.gates.models.filter((name) => nameMatches(name, model)).map((name) => wordsOf(name).length));
84
+ return specificity === 0 ? [] : [{ window, specificity }];
85
+ })
86
+ .toSorted((left, right) => right.specificity - left.specificity);
87
+ const best = matched[0];
88
+ if (best === undefined || matched[1]?.specificity === best.specificity) {
89
+ return undefined;
90
+ }
91
+ return best.window;
92
+ };
@@ -0,0 +1,130 @@
1
+ import { PROVIDER_BRAND_PATHS } from "@intentic/constants";
2
+ import { describe, expect, test } from "vitest";
3
+ import { accessFor, capabilitiesOf, harnessChoosable, PROVIDER_ACCESS, PROVIDER_VENDOR, PROVIDERS, providerLabel } from "./agent-catalog.js";
4
+ import { ACCESS_COST, cliProxyIdOf, KEY_PROVIDERS, keyEndpointOf, NATIVE_PROVIDERS, PROVIDER_SPECS, providerSpec } from "./provider-specs.js";
5
+ import { NativeProviderParamSchema } from "./schemas/agent.js";
6
+ import { TranslatorAccountsSchema } from "./schemas/plan-limits.js";
7
+ import { KeyedProviderSchema } from "./schemas/provider-subscriptions.js";
8
+
9
+ /* THE SPEC TABLE'S OWN GUARD, and it walks the TABLE rather than a list of names, which is the property that
10
+ * makes it worth having: a provider added tomorrow is covered the day it is added, and one removed takes its
11
+ * assertions with it.
12
+ *
13
+ * What it is for. Ten surfaces used to keep their own enumeration of the same providers; six were
14
+ * `Record<NativeProvider, …>` and could not be short a row, and the other four were arrays and if-chains that
15
+ * silently could. Those four are derived now, and this is what says so: for every provider in the table, every
16
+ * derived list contains it, and every derived answer is one a surface can actually render. */
17
+
18
+ describe("every provider in the table", () => {
19
+ test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id, spec })))("$id is described completely", ({ id, spec }) => {
20
+ // The strings a surface prints. Empty is the failure worth naming: each of these lands somewhere a
21
+ // person reads, and a blank one renders as a row with no name rather than as an error anybody notices.
22
+ for (const [field, value] of Object.entries({
23
+ label: spec.label,
24
+ vendor: spec.vendor,
25
+ accountLabel: spec.accountLabel,
26
+ destination: spec.destination,
27
+ requirement: spec.access.requirement,
28
+ runs: spec.access.runs,
29
+ })) {
30
+ expect(value.trim(), `${id}.${field}`).not.toBe("");
31
+ }
32
+ // A brand mark, not the fallback glyph. `brand` is typed against these keys so this cannot fail without
33
+ // someone reaching for a cast, which is exactly when a test earns its place.
34
+ expect(PROVIDER_BRAND_PATHS[spec.brand], `${id} has no brand mark`).toEqual(expect.any(String));
35
+ expect(ACCESS_COST[spec.access.kind], `${id} has an unpriced access kind`).toBeTypeOf("number");
36
+ });
37
+
38
+ test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id, spec })))("$id declares a reachable credential", ({ id, spec }) => {
39
+ if (spec.auth.kind === "translator") {
40
+ // The proxy's own name for it, which is what the daemon addresses its management API by. Reading
41
+ // back undefined here is the `…/model-definitions/undefined` request that used to be possible.
42
+ expect(cliProxyIdOf(id), `${id} is routed but names no CLIProxyAPI provider`).toBe(spec.auth.cliProxy);
43
+ expect(KeyedProviderSchema.options, `${id} is routed but missing from KeyedProvider`).toContain(id);
44
+ expect(Object.keys(TranslatorAccountsSchema.shape), `${id} has no slot in TranslatorAccounts`).toContain(id);
45
+ return;
46
+ }
47
+ if (spec.auth.kind === "key") {
48
+ const endpoint = keyEndpointOf(id);
49
+ expect(endpoint, `${id} is keyed but names no endpoint`).toMatchObject({ kind: "key" });
50
+ expect(KEY_PROVIDERS, `${id} is keyed but missing from KEY_PROVIDERS`).toContain(id);
51
+ /* THE TURN URL AND THE CATALOG URL ARE DIFFERENT SURFACES OF ONE VENDOR, and both halves of that
52
+ * sentence are asserted: same host (a pair that drifted apart would send the catalog to one vendor
53
+ * and the turn to another), and the turn's base carries NO version segment, because the harness
54
+ * appends `/v1/messages` itself and a doubled one is a 404 mid-conversation. */
55
+ expect(new URL(endpoint!.anthropicBase).host, `${id}'s catalog and turn hosts differ`).toBe(new URL(endpoint!.catalogBase).host);
56
+ expect(endpoint!.anthropicBase, `${id}'s turn base carries a version segment the harness would double`).not.toMatch(/\/v\d+$/);
57
+ expect(new URL(endpoint!.console).protocol, `${id}'s key console is not a link`).toBe("https:");
58
+ return;
59
+ }
60
+ // An oauth provider is served by a handshake this daemon runs; it is in neither routed list.
61
+ expect(KeyedProviderSchema.options, `${id} is not routed but appears in KeyedProvider`).not.toContain(id);
62
+ expect(KEY_PROVIDERS, `${id} is not keyed but appears in KEY_PROVIDERS`).not.toContain(id);
63
+ });
64
+
65
+ test.each(PROVIDER_SPECS.map((spec) => ({ id: spec.id })))("$id is in every derived list", ({ id }) => {
66
+ expect(NATIVE_PROVIDERS).toContain(id);
67
+ expect(
68
+ PROVIDERS.map((option) => option.value),
69
+ "the picker's list",
70
+ ).toContain(id);
71
+ expect(Object.keys(PROVIDER_ACCESS), "the access table").toContain(id);
72
+ expect(Object.keys(PROVIDER_VENDOR), "the vendor table").toContain(id);
73
+ // The catalog route's param schema is closed over the same vocabulary, so a provider the daemon holds a
74
+ // catalog for but the schema does not name is a 400 on its own models route.
75
+ expect(NativeProviderParamSchema.safeParse({ provider: id }).success, "the catalog route's param").toBe(true);
76
+ // The two lookups every surface goes through, rather than the tables behind them.
77
+ expect(providerSpec(id)?.id).toBe(id);
78
+ expect(accessFor(id)?.requirement).toEqual(expect.any(String));
79
+ expect(providerLabel(id)).not.toBe(id);
80
+ });
81
+ });
82
+
83
+ /* IDS ARE THE KEY EVERYTHING ELSE IS FILED UNDER, so a duplicate is not a cosmetic problem: the derived Records
84
+ * would silently keep the last row, and `providerSpec` the first. */
85
+ test("provider ids are unique", () => {
86
+ const ids = PROVIDER_SPECS.map((spec) => spec.id);
87
+ expect(new Set(ids).size).toBe(ids.length);
88
+ });
89
+
90
+ /* An id may contain neither a slash nor a colon, and both exclusions are load-bearing rather than tidy.
91
+ * `endpoint/<id>` uses the slash to namespace a capability-minted provider, and the picker's pinned selections
92
+ * are `${provider}:${model}` split on the FIRST colon (quick-model.ts), so an id carrying either would parse as
93
+ * something else entirely, silently. */
94
+ test("no provider id can be mistaken for an endpoint or a pinned selection", () => {
95
+ for (const spec of PROVIDER_SPECS) {
96
+ expect(spec.id, `${spec.id} would collide with the picker's key shapes`).toMatch(/^[a-z0-9-]+$/);
97
+ }
98
+ });
99
+
100
+ /* THE HARNESS AXIS IS A FACT ABOUT THE TABLE, not a list any surface keeps. A provider whose two runtimes are
101
+ * the same one has nothing to choose, and the chip must not be offered; one with two must offer it. Both
102
+ * directions matter: the version of this that lived in the web named two providers and would have offered
103
+ * Kimi, Meta and Z.ai a switch between two identical loops. */
104
+ test("the harness chip is offered exactly where the two harnesses run different loops", () => {
105
+ for (const spec of PROVIDER_SPECS) {
106
+ const native = capabilitiesOf(spec.id, "native");
107
+ const claudeCode = capabilitiesOf(spec.id, "claude-code");
108
+ expect(harnessChoosable(spec.id), `${spec.id}`).toBe(native.runtime !== claudeCode.runtime);
109
+ }
110
+ });
111
+
112
+ /* A KEYED PROVIDER HAS NO NATIVE RUNTIME, by construction rather than by coincidence: it is reached by pointing
113
+ * the Claude Code loop at the vendor's own Anthropic endpoint, and there is no second loop to point anywhere
114
+ * else. A spec row that claimed one would produce a picker chip offering a runtime nothing serves. */
115
+ test("a keyed provider runs the Claude Code loop on both harnesses", () => {
116
+ for (const provider of KEY_PROVIDERS) {
117
+ expect(capabilitiesOf(provider, "native").runtime, provider).toBe("claude-code");
118
+ expect(capabilitiesOf(provider, "claude-code").runtime, provider).toBe("claude-code");
119
+ }
120
+ });
121
+
122
+ // An id that names no provider must fall through every lookup rather than resolving to a neighbour's row, which
123
+ // is what makes ACP agents and endpoint capabilities safe to name with arbitrary strings.
124
+ test("an id that is not a provider resolves to nothing", () => {
125
+ for (const id of ["", "some-installed-agent", "endpoint/ollama", "META", "z.ai"]) {
126
+ expect(providerSpec(id), id).toBeUndefined();
127
+ expect(cliProxyIdOf(id), id).toBeUndefined();
128
+ expect(keyEndpointOf(id), id).toBeUndefined();
129
+ }
130
+ });