@intentic/sandbox-contract 1.330.0 → 1.332.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 (337) hide show
  1. package/README.md +15 -0
  2. package/dist/chores/probes.d.ts.map +1 -1
  3. package/dist/chores/probes.js.map +1 -1
  4. package/dist/contracts/accounts.contract.d.ts +15 -0
  5. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  6. package/dist/contracts/agent.contract.d.ts +38 -0
  7. package/dist/contracts/agent.contract.d.ts.map +1 -1
  8. package/dist/contracts/agent.contract.js +14 -3
  9. package/dist/contracts/agent.contract.js.map +1 -1
  10. package/dist/contracts/agents.contract.d.ts +29 -0
  11. package/dist/contracts/agents.contract.d.ts.map +1 -1
  12. package/dist/contracts/automations.contract.d.ts +2 -0
  13. package/dist/contracts/automations.contract.d.ts.map +1 -1
  14. package/dist/contracts/device.contract.d.ts +7 -0
  15. package/dist/contracts/device.contract.d.ts.map +1 -1
  16. package/dist/contracts/git.contract.d.ts +2 -0
  17. package/dist/contracts/git.contract.d.ts.map +1 -1
  18. package/dist/contracts/logs.contract.d.ts +10 -0
  19. package/dist/contracts/logs.contract.d.ts.map +1 -1
  20. package/dist/contracts/needs.contract.d.ts +7 -0
  21. package/dist/contracts/needs.contract.d.ts.map +1 -1
  22. package/dist/contracts/offload.contract.d.ts +70 -0
  23. package/dist/contracts/offload.contract.d.ts.map +1 -1
  24. package/dist/contracts/offload.contract.js +10 -1
  25. package/dist/contracts/offload.contract.js.map +1 -1
  26. package/dist/contracts/privacy.contract.d.ts +14 -0
  27. package/dist/contracts/privacy.contract.d.ts.map +1 -1
  28. package/dist/contracts/privacy.contract.js +11 -1
  29. package/dist/contracts/privacy.contract.js.map +1 -1
  30. package/dist/contracts/providers.contract.d.ts +3 -0
  31. package/dist/contracts/providers.contract.d.ts.map +1 -1
  32. package/dist/contracts/runner.contract.d.ts +106 -2
  33. package/dist/contracts/runner.contract.d.ts.map +1 -1
  34. package/dist/contracts/sessions.contract.d.ts +1 -0
  35. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  36. package/dist/contracts/settings.contract.d.ts +74 -94
  37. package/dist/contracts/settings.contract.d.ts.map +1 -1
  38. package/dist/contracts/settings.contract.js +19 -1
  39. package/dist/contracts/settings.contract.js.map +1 -1
  40. package/dist/contracts/system.contract.d.ts +20 -0
  41. package/dist/contracts/system.contract.d.ts.map +1 -1
  42. package/dist/contracts/translator.contract.d.ts +8 -0
  43. package/dist/contracts/translator.contract.d.ts.map +1 -1
  44. package/dist/events/agent-events.d.ts +47 -0
  45. package/dist/events/agent-events.d.ts.map +1 -1
  46. package/dist/events/agent-events.js +6 -0
  47. package/dist/events/agent-events.js.map +1 -1
  48. package/dist/events/agent-words.d.ts.map +1 -1
  49. package/dist/events/agent-words.js +1 -1
  50. package/dist/events/agent-words.js.map +1 -1
  51. package/dist/events/resume.d.ts +3 -0
  52. package/dist/events/resume.d.ts.map +1 -1
  53. package/dist/events/resume.js +17 -2
  54. package/dist/events/resume.js.map +1 -1
  55. package/dist/events/sandbox-notice.d.ts +18 -0
  56. package/dist/events/sandbox-notice.d.ts.map +1 -1
  57. package/dist/events/sandbox-notice.js +27 -1
  58. package/dist/events/sandbox-notice.js.map +1 -1
  59. package/dist/events/system-events.d.ts +4 -0
  60. package/dist/events/system-events.d.ts.map +1 -1
  61. package/dist/events/transcript.d.ts +64 -0
  62. package/dist/events/transcript.d.ts.map +1 -1
  63. package/dist/events/transcript.js +17 -1
  64. package/dist/events/transcript.js.map +1 -1
  65. package/dist/index.d.ts +327 -124
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +5 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/models/agent-catalog.d.ts.map +1 -1
  70. package/dist/models/agent-catalog.js +8 -7
  71. package/dist/models/agent-catalog.js.map +1 -1
  72. package/dist/models/agent-runtimes.d.ts +3 -1
  73. package/dist/models/agent-runtimes.d.ts.map +1 -1
  74. package/dist/models/agent-runtimes.js +10 -0
  75. package/dist/models/agent-runtimes.js.map +1 -1
  76. package/dist/models/model-order.d.ts +1 -0
  77. package/dist/models/model-order.d.ts.map +1 -1
  78. package/dist/models/model-order.js +9 -0
  79. package/dist/models/model-order.js.map +1 -1
  80. package/dist/models/model-pins.d.ts.map +1 -1
  81. package/dist/models/model-pins.js.map +1 -1
  82. package/dist/models/model-roles.d.ts +32 -17
  83. package/dist/models/model-roles.d.ts.map +1 -1
  84. package/dist/models/model-roles.js +30 -26
  85. package/dist/models/model-roles.js.map +1 -1
  86. package/dist/models/model-setups.d.ts +46 -0
  87. package/dist/models/model-setups.d.ts.map +1 -0
  88. package/dist/models/model-setups.js +86 -0
  89. package/dist/models/model-setups.js.map +1 -0
  90. package/dist/models/plan-pools.d.ts +5 -1
  91. package/dist/models/plan-pools.d.ts.map +1 -1
  92. package/dist/models/plan-pools.js +41 -13
  93. package/dist/models/plan-pools.js.map +1 -1
  94. package/dist/models/provider-specs.d.ts +20 -0
  95. package/dist/models/provider-specs.d.ts.map +1 -1
  96. package/dist/models/provider-specs.js +13 -1
  97. package/dist/models/provider-specs.js.map +1 -1
  98. package/dist/policy/command-classes.d.ts.map +1 -1
  99. package/dist/policy/command-classes.js +26 -3
  100. package/dist/policy/command-classes.js.map +1 -1
  101. package/dist/policy/credential-material.d.ts +6 -0
  102. package/dist/policy/credential-material.d.ts.map +1 -1
  103. package/dist/policy/credential-material.js +37 -15
  104. package/dist/policy/credential-material.js.map +1 -1
  105. package/dist/policy/search-globs.d.ts +3 -0
  106. package/dist/policy/search-globs.d.ts.map +1 -1
  107. package/dist/policy/search-globs.js +118 -0
  108. package/dist/policy/search-globs.js.map +1 -1
  109. package/dist/policy/secret-hosts.d.ts +1 -0
  110. package/dist/policy/secret-hosts.d.ts.map +1 -1
  111. package/dist/policy/secret-hosts.js +1 -0
  112. package/dist/policy/secret-hosts.js.map +1 -1
  113. package/dist/policy/turned-away.d.ts.map +1 -1
  114. package/dist/policy/turned-away.js +1 -0
  115. package/dist/policy/turned-away.js.map +1 -1
  116. package/dist/protocol/doors/runner-protocol.d.ts +9 -0
  117. package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
  118. package/dist/protocol/doors/runner-protocol.js +5 -0
  119. package/dist/protocol/doors/runner-protocol.js.map +1 -1
  120. package/dist/protocol/pairing-redeem.d.ts +23 -0
  121. package/dist/protocol/pairing-redeem.d.ts.map +1 -0
  122. package/dist/protocol/pairing-redeem.js +44 -0
  123. package/dist/protocol/pairing-redeem.js.map +1 -0
  124. package/dist/protocol/peer-mcp-server.d.ts.map +1 -1
  125. package/dist/protocol/peer-mcp-server.js +1 -1
  126. package/dist/protocol/peer-mcp-server.js.map +1 -1
  127. package/dist/protocol/raw/raw-json-routes.d.ts +16 -0
  128. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  129. package/dist/protocol/raw/raw-json-routes.js +2 -0
  130. package/dist/protocol/raw/raw-json-routes.js.map +1 -1
  131. package/dist/protocol/raw/raw-routes.d.ts +8 -0
  132. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  133. package/dist/protocol/raw/raw-routes.js +2 -0
  134. package/dist/protocol/raw/raw-routes.js.map +1 -1
  135. package/dist/protocol/sse.d.ts +20 -1
  136. package/dist/protocol/sse.d.ts.map +1 -1
  137. package/dist/protocol/sse.js +112 -17
  138. package/dist/protocol/sse.js.map +1 -1
  139. package/dist/schemas/agent.d.ts +35 -0
  140. package/dist/schemas/agent.d.ts.map +1 -1
  141. package/dist/schemas/agent.js +12 -1
  142. package/dist/schemas/agent.js.map +1 -1
  143. package/dist/schemas/automations.d.ts +6 -0
  144. package/dist/schemas/automations.d.ts.map +1 -1
  145. package/dist/schemas/automations.js +3 -1
  146. package/dist/schemas/automations.js.map +1 -1
  147. package/dist/schemas/devices.d.ts +17 -0
  148. package/dist/schemas/devices.d.ts.map +1 -1
  149. package/dist/schemas/devices.js +2 -1
  150. package/dist/schemas/devices.js.map +1 -1
  151. package/dist/schemas/engines.d.ts +8 -1
  152. package/dist/schemas/engines.d.ts.map +1 -1
  153. package/dist/schemas/engines.js +1 -1
  154. package/dist/schemas/engines.js.map +1 -1
  155. package/dist/schemas/environment.d.ts +30 -0
  156. package/dist/schemas/environment.d.ts.map +1 -1
  157. package/dist/schemas/environment.js +13 -0
  158. package/dist/schemas/environment.js.map +1 -1
  159. package/dist/schemas/git/git-scope.d.ts +20 -0
  160. package/dist/schemas/git/git-scope.d.ts.map +1 -0
  161. package/dist/schemas/git/git-scope.js +37 -0
  162. package/dist/schemas/git/git-scope.js.map +1 -0
  163. package/dist/schemas/git/git.d.ts +1 -0
  164. package/dist/schemas/git/git.d.ts.map +1 -1
  165. package/dist/schemas/git/git.js +9 -1
  166. package/dist/schemas/git/git.js.map +1 -1
  167. package/dist/schemas/hosts.d.ts +42 -0
  168. package/dist/schemas/hosts.d.ts.map +1 -1
  169. package/dist/schemas/hosts.js +3 -0
  170. package/dist/schemas/hosts.js.map +1 -1
  171. package/dist/schemas/keep-warm.d.ts +1 -0
  172. package/dist/schemas/keep-warm.d.ts.map +1 -1
  173. package/dist/schemas/keep-warm.js +1 -1
  174. package/dist/schemas/keep-warm.js.map +1 -1
  175. package/dist/schemas/logs.d.ts +21 -0
  176. package/dist/schemas/logs.d.ts.map +1 -1
  177. package/dist/schemas/logs.js +19 -0
  178. package/dist/schemas/logs.js.map +1 -1
  179. package/dist/schemas/metrics.d.ts +12 -0
  180. package/dist/schemas/metrics.d.ts.map +1 -1
  181. package/dist/schemas/metrics.js +26 -8
  182. package/dist/schemas/metrics.js.map +1 -1
  183. package/dist/schemas/needs.d.ts +19 -0
  184. package/dist/schemas/needs.d.ts.map +1 -1
  185. package/dist/schemas/needs.js +17 -3
  186. package/dist/schemas/needs.js.map +1 -1
  187. package/dist/schemas/offload.d.ts +154 -0
  188. package/dist/schemas/offload.d.ts.map +1 -1
  189. package/dist/schemas/offload.js +34 -0
  190. package/dist/schemas/offload.js.map +1 -1
  191. package/dist/schemas/privacy.d.ts +19 -0
  192. package/dist/schemas/privacy.d.ts.map +1 -1
  193. package/dist/schemas/privacy.js +17 -2
  194. package/dist/schemas/privacy.js.map +1 -1
  195. package/dist/schemas/project-bring-back.d.ts +172 -0
  196. package/dist/schemas/project-bring-back.d.ts.map +1 -0
  197. package/dist/schemas/project-bring-back.js +39 -0
  198. package/dist/schemas/project-bring-back.js.map +1 -0
  199. package/dist/schemas/providers/handoff.d.ts +77 -0
  200. package/dist/schemas/providers/handoff.d.ts.map +1 -0
  201. package/dist/schemas/providers/handoff.js +85 -0
  202. package/dist/schemas/providers/handoff.js.map +1 -0
  203. package/dist/schemas/providers/plan-limits.d.ts +34 -0
  204. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  205. package/dist/schemas/providers/plan-limits.js +13 -2
  206. package/dist/schemas/providers/plan-limits.js.map +1 -1
  207. package/dist/schemas/providers/provider-oauth.d.ts +8 -0
  208. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  209. package/dist/schemas/providers/usage.d.ts +3 -1
  210. package/dist/schemas/providers/usage.d.ts.map +1 -1
  211. package/dist/schemas/providers/usage.js +7 -2
  212. package/dist/schemas/providers/usage.js.map +1 -1
  213. package/dist/schemas/run-targets.d.ts.map +1 -1
  214. package/dist/schemas/run-targets.js +5 -2
  215. package/dist/schemas/run-targets.js.map +1 -1
  216. package/dist/schemas/settings-history.d.ts +6 -1
  217. package/dist/schemas/settings-history.d.ts.map +1 -1
  218. package/dist/schemas/settings-history.js +25 -1
  219. package/dist/schemas/settings-history.js.map +1 -1
  220. package/dist/schemas/settings.d.ts +69 -100
  221. package/dist/schemas/settings.d.ts.map +1 -1
  222. package/dist/schemas/settings.js +41 -24
  223. package/dist/schemas/settings.js.map +1 -1
  224. package/dist/schemas/short-codes.d.ts +31 -0
  225. package/dist/schemas/short-codes.d.ts.map +1 -0
  226. package/dist/schemas/short-codes.js +28 -0
  227. package/dist/schemas/short-codes.js.map +1 -0
  228. package/dist/schemas/system.d.ts +130 -0
  229. package/dist/schemas/system.d.ts.map +1 -1
  230. package/dist/schemas/system.js +37 -1
  231. package/dist/schemas/system.js.map +1 -1
  232. package/dist/state/definition.d.ts +64 -40
  233. package/dist/state/definition.d.ts.map +1 -1
  234. package/dist/state/history-state.d.ts.map +1 -1
  235. package/dist/state/history-state.js +1 -0
  236. package/dist/state/history-state.js.map +1 -1
  237. package/dist/state/runtime-state.d.ts +3 -0
  238. package/dist/state/runtime-state.d.ts.map +1 -1
  239. package/dist/state/runtime-state.js +1 -0
  240. package/dist/state/runtime-state.js.map +1 -1
  241. package/dist/state/versions.d.ts +6 -0
  242. package/dist/state/versions.d.ts.map +1 -1
  243. package/dist/state/versions.js +47 -0
  244. package/dist/state/versions.js.map +1 -1
  245. package/dist/text/embed.d.ts +2 -1
  246. package/dist/text/embed.d.ts.map +1 -1
  247. package/dist/text/embed.js +10 -5
  248. package/dist/text/embed.js.map +1 -1
  249. package/dist/text/title.d.ts.map +1 -1
  250. package/dist/text/title.js +4 -7
  251. package/dist/text/title.js.map +1 -1
  252. package/dist/text/transcript-fold.d.ts +2 -1
  253. package/dist/text/transcript-fold.d.ts.map +1 -1
  254. package/dist/text/transcript-fold.js +21 -6
  255. package/dist/text/transcript-fold.js.map +1 -1
  256. package/package.json +28 -6
  257. package/src/chores/probes.ts +3 -1
  258. package/src/contracts/agent.contract.ts +24 -3
  259. package/src/contracts/offload.contract.ts +21 -1
  260. package/src/contracts/privacy.contract.ts +13 -0
  261. package/src/contracts/settings.contract.ts +23 -0
  262. package/src/events/agent-events.ts +13 -0
  263. package/src/events/agent-words.ts +2 -3
  264. package/src/events/resume.test.ts +14 -2
  265. package/src/events/resume.ts +32 -3
  266. package/src/events/sandbox-notice.ts +31 -1
  267. package/src/events/transcript.ts +27 -2
  268. package/src/index.ts +5 -0
  269. package/src/models/agent-catalog.test.ts +4 -3
  270. package/src/models/agent-catalog.ts +10 -7
  271. package/src/models/agent-runtimes.ts +30 -1
  272. package/src/models/capability-ledger.test.ts +4 -0
  273. package/src/models/model-order.ts +13 -0
  274. package/src/models/model-pins.ts +5 -4
  275. package/src/models/model-roles.test.ts +9 -31
  276. package/src/models/model-roles.ts +42 -49
  277. package/src/models/model-setups.test.ts +100 -0
  278. package/src/models/model-setups.ts +151 -0
  279. package/src/models/plan-pools.test.ts +85 -0
  280. package/src/models/plan-pools.ts +76 -17
  281. package/src/models/provider-specs.ts +20 -2
  282. package/src/policy/command-classes.ts +34 -3
  283. package/src/policy/credential-material.ts +58 -16
  284. package/src/policy/search-globs.ts +135 -0
  285. package/src/policy/secret-hosts.ts +7 -0
  286. package/src/policy/tests/command-classes.test.ts +35 -0
  287. package/src/policy/tests/credential-material.test.ts +20 -0
  288. package/src/policy/tests/search-globs.test.ts +46 -1
  289. package/src/policy/tests/secret-hosts.test.ts +17 -1
  290. package/src/policy/turned-away.ts +2 -0
  291. package/src/protocol/doors/runner-protocol.ts +14 -1
  292. package/src/protocol/pairing-redeem.test.ts +86 -0
  293. package/src/protocol/pairing-redeem.ts +79 -0
  294. package/src/protocol/peer-mcp-server.ts +1 -2
  295. package/src/protocol/raw/raw-json-routes.ts +3 -0
  296. package/src/protocol/raw/raw-routes.ts +5 -0
  297. package/src/protocol/sse.test.ts +50 -1
  298. package/src/protocol/sse.ts +172 -26
  299. package/src/schemas/agent.ts +23 -2
  300. package/src/schemas/automations.ts +6 -2
  301. package/src/schemas/daemon-health.test.ts +28 -0
  302. package/src/schemas/devices.ts +11 -2
  303. package/src/schemas/engines.ts +1 -1
  304. package/src/schemas/environment.ts +25 -0
  305. package/src/schemas/git/git-scope.test.ts +91 -0
  306. package/src/schemas/git/git-scope.ts +71 -0
  307. package/src/schemas/git/git.ts +15 -1
  308. package/src/schemas/hosts.ts +7 -0
  309. package/src/schemas/keep-warm.ts +2 -1
  310. package/src/schemas/logs.ts +25 -0
  311. package/src/schemas/metrics.test.ts +80 -0
  312. package/src/schemas/metrics.ts +31 -9
  313. package/src/schemas/needs.ts +29 -3
  314. package/src/schemas/offload.ts +76 -0
  315. package/src/schemas/privacy.ts +27 -3
  316. package/src/schemas/project-bring-back.test.ts +15 -0
  317. package/src/schemas/project-bring-back.ts +78 -0
  318. package/src/schemas/providers/handoff.test.ts +42 -0
  319. package/src/schemas/providers/handoff.ts +128 -0
  320. package/src/schemas/providers/plan-limits.ts +23 -2
  321. package/src/schemas/providers/usage.ts +13 -7
  322. package/src/schemas/run-targets.ts +5 -2
  323. package/src/schemas/settings-history.ts +43 -1
  324. package/src/schemas/settings.test.ts +84 -6
  325. package/src/schemas/settings.ts +74 -44
  326. package/src/schemas/short-codes.ts +48 -0
  327. package/src/schemas/system.ts +63 -1
  328. package/src/state/history-state.ts +4 -0
  329. package/src/state/runtime-state.test.ts +4 -0
  330. package/src/state/runtime-state.ts +4 -0
  331. package/src/state/versions.test.ts +30 -1
  332. package/src/state/versions.ts +68 -0
  333. package/src/text/embed.test.ts +14 -0
  334. package/src/text/embed.ts +19 -10
  335. package/src/text/title.ts +5 -9
  336. package/src/text/transcript-fold.test.ts +61 -0
  337. package/src/text/transcript-fold.ts +31 -6
@@ -0,0 +1,100 @@
1
+ import type { ModelPin } from "../schemas/agent.js";
2
+ import type { ModelSource } from "./model-pins.js";
3
+ import { endpointProvider } from "./agent-catalog.js";
4
+ import type { ModelRole } from "./model-roles.js";
5
+ import { ladderIsEmpty, presetPins, roleLadder, rolePins, roleSetupId, setupsFromRoleLists } from "./model-setups.js";
6
+
7
+ // What a job runs on is read here by the daemon and the browser alike, so the rules are pinned once: where a job starts,
8
+ // what a preset is worked out from, and what a written setup overrides.
9
+
10
+ const opus: ModelPin = { provider: `claude`, model: `claude-opus-5-5`, effort: `high` };
11
+ const haiku: ModelPin = { provider: `claude`, model: `claude-haiku-4-5` };
12
+
13
+ const CLAUDE: ModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5-20251001`, `claude-opus-4-8`, `claude-opus-5`, `claude-sonnet-5`] };
14
+ const CODEX: ModelSource = { provider: `codex`, ready: true, models: [`gpt-5.2-codex`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5-mini`] };
15
+ const GEMINI: ModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
16
+
17
+ const NOTHING_SET = { modelSetups: [], roleSetups: {} };
18
+
19
+ describe(`a preset worked out from what is connected`, () => {
20
+ test(`takes each connected provider's newest model on the preset's rung, in the order they come`, () => {
21
+ expect(presetPins(`high`, [CLAUDE, CODEX, GEMINI]).map((pin) => pin.model)).toEqual([`claude-opus-5`, `gpt-5.6-sol`, `gemini-3-pro`]);
22
+ expect(presetPins(`medium`, [CLAUDE, CODEX, GEMINI]).map((pin) => pin.model)).toEqual([`claude-sonnet-5`, `gpt-5.6-terra`, `gemini-3-flash`]);
23
+ expect(presetPins(`low`, [CLAUDE, CODEX, GEMINI]).map((pin) => pin.model)).toEqual([`claude-haiku-4-5-20251001`, `gpt-5.6-luna`, `gemini-3-flash-lite`]);
24
+ });
25
+
26
+ test(`leaves out a provider that is not connected, a local endpoint, and anything past three`, () => {
27
+ const local: ModelSource = { provider: endpointProvider(`ollama`), ready: true, models: [`qwen-coder`] };
28
+ const extra: ModelSource = { provider: `grok`, ready: true, models: [`grok-5-heavy`] };
29
+ expect(presetPins(`high`, [{ ...CLAUDE, ready: false }, local, CODEX, GEMINI, extra]).map((pin) => pin.provider)).toEqual([`codex`, `gemini`, `grok`]);
30
+ expect(presetPins(`high`, [CLAUDE, CODEX, GEMINI, extra])).toHaveLength(3);
31
+ });
32
+
33
+ test(`takes a provider's own model over one it serves from another maker`, () => {
34
+ const google: ModelSource = { provider: `gemini`, ready: true, models: [`claude-opus-4-6-thinking`, `gemini-3-pro`, `gemini-3-flash`] };
35
+ expect(presetPins(`high`, [google])).toEqual([{ provider: `gemini`, model: `gemini-3-pro` }]);
36
+ // With none of its own on the rung, the borrowed one still serves.
37
+ expect(presetPins(`high`, [{ ...google, models: [`claude-opus-4-6-thinking`, `gemini-3-flash`, `gemini-3-flash-lite`] }])).toEqual([
38
+ { provider: `gemini`, model: `claude-opus-4-6-thinking` },
39
+ ]);
40
+ });
41
+
42
+ test(`is empty while nothing is connected`, () => {
43
+ expect(presetPins(`low`, [])).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe(`which setup a job runs on`, () => {
48
+ test(`a job nobody placed runs on nothing, however much is connected`, () => {
49
+ expect(roleSetupId(NOTHING_SET, `commit-message`)).toBeUndefined();
50
+ expect(ladderIsEmpty(roleLadder(NOTHING_SET, `safety-judge`))).toBe(true);
51
+ expect(rolePins(NOTHING_SET, `pipeline-fix`, [CLAUDE, CODEX, GEMINI])).toEqual([]);
52
+ });
53
+
54
+ test(`a job put on a preset nobody edited runs what is connected, and follows it`, () => {
55
+ const settings = { modelSetups: [], roleSetups: { "commit-message": `low` } };
56
+ expect(roleLadder(settings, `commit-message`)).toEqual({ kind: `preset`, preset: `low` });
57
+ expect(rolePins(settings, `commit-message`, [CLAUDE])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5-20251001` }]);
58
+ });
59
+
60
+ test(`a written preset is run as written, and stops following what is connected`, () => {
61
+ const settings = { modelSetups: [{ id: `low`, pins: [opus] }], roleSetups: { "commit-message": `low` } };
62
+ expect(rolePins(settings, `commit-message`, [CLAUDE, CODEX])).toEqual([opus]);
63
+ });
64
+
65
+ test(`a preset that was only renamed still follows what is connected`, () => {
66
+ const settings = { modelSetups: [{ id: `low`, name: `Cheap` }], roleSetups: { "commit-message": `low` } };
67
+ expect(roleLadder(settings, `commit-message`)).toEqual({ kind: `preset`, preset: `low` });
68
+ });
69
+
70
+ test(`a job put on a setup of the owner's runs that setup's models`, () => {
71
+ const settings = { modelSetups: [{ id: `setup-1`, name: `Reviews`, pins: [opus, haiku] }], roleSetups: { "commit-message": `setup-1` } };
72
+ expect(rolePins(settings, `commit-message`, [CLAUDE])).toEqual([opus, haiku]);
73
+ });
74
+
75
+ test(`a job put on a setup that is gone is on none`, () => {
76
+ const settings = { modelSetups: [], roleSetups: { "commit-message": `setup-9` } };
77
+ expect(roleSetupId(settings, `commit-message`)).toBeUndefined();
78
+ });
79
+
80
+ test(`a setup of the owner's with no models is empty, never a preset`, () => {
81
+ const settings = { modelSetups: [{ id: `setup-1`, name: `Later` }], roleSetups: { "pipeline-fix": `setup-1` } };
82
+ expect(ladderIsEmpty(roleLadder(settings, `pipeline-fix`))).toBe(true);
83
+ });
84
+ });
85
+
86
+ test(`per-job lists become setups every job runs exactly as before on`, () => {
87
+ const cases = [
88
+ [`commit-message`, [haiku]],
89
+ [`session-title`, [haiku]],
90
+ [`pipeline-fix`, [opus]],
91
+ [`safety-judge`, []],
92
+ ] as const satisfies readonly (readonly [ModelRole, readonly ModelPin[]])[];
93
+ const settings = setupsFromRoleLists(Object.fromEntries(cases));
94
+
95
+ for (const [role, pins] of cases) {
96
+ expect(rolePins(settings, role, [CLAUDE, CODEX])).toEqual(pins);
97
+ }
98
+ // A job that never had a list, or had an empty one, ran on nothing and is on none.
99
+ expect([settings.roleSetups[`loop-iteration`], settings.roleSetups[`safety-judge`]]).toEqual([undefined, undefined]);
100
+ });
@@ -0,0 +1,151 @@
1
+ import { z } from "zod";
2
+ import { type ModelPin, ModelPinSchema } from "../schemas/agent.js";
3
+ import { isEndpointProvider } from "./agent-catalog.js";
4
+ import { compareModelIds, modelRungOf } from "./model-order.js";
5
+ import type { ModelSource } from "./model-pins.js";
6
+ import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRole, PRESET_SETUPS, type PresetSetup } from "./model-roles.js";
7
+
8
+ // A setup is a named, ordered list of models that any number of jobs run on: edited once, used by every job put there.
9
+ // Every sandbox has three, Low, Medium and High, worked out from the accounts it has connected until the owner edits
10
+ // one; the owner adds any others. A job runs on nothing until someone puts it on a setup: nothing spends an account, and
11
+ // no judge starts holding commands, because an account was connected. Each job's catalog row suggests a preset
12
+ // (model-roles.ts `setup`) for when it is placed. Every reader resolves a job through here, daemon and browser alike, so
13
+ // the settings page shows what will run.
14
+
15
+ export const MODEL_SETUP_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
16
+
17
+ export const ModelSetupSchema = z.object({
18
+ id: z.string().regex(MODEL_SETUP_ID).describe("Which setup this is. `low`, `medium` and `high` are the three every sandbox has; any other is one you made."),
19
+ name: z.string().trim().min(1).max(40).optional().describe("What it is called on the settings page. Left out, a preset keeps its own name."),
20
+ pins: z
21
+ .array(ModelPinSchema)
22
+ .max(10)
23
+ .optional()
24
+ .describe(
25
+ "The models it runs on, tried in order, so one spent account does not take its jobs down. Left out on a preset, it is worked out from the accounts this sandbox has connected and follows them as they change; left out on one you made, it has none.",
26
+ ),
27
+ });
28
+ export type ModelSetup = z.infer<typeof ModelSetupSchema>;
29
+
30
+ export const isPresetSetup = (id: string): id is PresetSetup => PRESET_SETUPS.some((preset) => preset === id);
31
+
32
+ // The settings a job's models are read from; a slice, so a caller holding only these two can resolve.
33
+ export interface SetupSettings {
34
+ readonly modelSetups: readonly ModelSetup[];
35
+ readonly roleSetups: Partial<Record<ModelRole, string>>;
36
+ }
37
+
38
+ const SUGGESTED = new Map<ModelRole, PresetSetup>(MODEL_ROLES.map((role) => [role.id, role.setup]));
39
+
40
+ /** The preset suggested for a job when it is placed. */
41
+ export const suggestedSetup = (role: ModelRole): PresetSetup => SUGGESTED.get(role) ?? `medium`;
42
+
43
+ // First entry wins: a hand-edited file holding one id twice means the earlier, as everywhere else a list is read.
44
+ export const storedSetup = (settings: Pick<SetupSettings, "modelSetups">, id: string): ModelSetup | undefined =>
45
+ settings.modelSetups.find((setup) => setup.id === id);
46
+
47
+ /** The setup a job runs on, or undefined while it is on none (never placed, or placed on a setup that is gone). */
48
+ export const roleSetupId = (settings: SetupSettings, role: ModelRole): string | undefined => {
49
+ const assigned = settings.roleSetups[role];
50
+ return assigned !== undefined && (isPresetSetup(assigned) || storedSetup(settings, assigned) !== undefined) ? assigned : undefined;
51
+ };
52
+
53
+ // What a setup runs on before anything is connected or resolved: a written list, or a preset still to be worked out.
54
+ // A snapshot of this is what a turn carries, so it is judged by one setup however its accounts change mid-turn.
55
+ export type SetupLadder = { readonly kind: `pins`; readonly pins: readonly ModelPin[] } | { readonly kind: `preset`; readonly preset: PresetSetup };
56
+
57
+ const NO_MODELS: SetupLadder = { kind: `pins`, pins: [] };
58
+
59
+ export const setupLadder = (settings: Pick<SetupSettings, "modelSetups">, id: string | undefined): SetupLadder => {
60
+ if (id === undefined) {
61
+ return NO_MODELS;
62
+ }
63
+ const pins = storedSetup(settings, id)?.pins;
64
+ if (pins !== undefined) {
65
+ return { kind: `pins`, pins };
66
+ }
67
+ return isPresetSetup(id) ? { kind: `preset`, preset: id } : NO_MODELS;
68
+ };
69
+
70
+ export const roleLadder = (settings: SetupSettings, role: ModelRole): SetupLadder => setupLadder(settings, roleSetupId(settings, role));
71
+
72
+ /** True only for a ladder that can never name a model, whatever is connected: a written list left empty. */
73
+ export const ladderIsEmpty = (ladder: SetupLadder): boolean => ladder.kind === `pins` && ladder.pins.length === 0;
74
+
75
+ const PRESET_RUNG = { high: 0, medium: 1, low: 2 } as const satisfies Record<PresetSetup, number>;
76
+
77
+ // Enough to step over one spent account and then another; a preset longer than this would mostly be read, not tried.
78
+ const PRESET_LENGTH = 3;
79
+
80
+ // The first segment most of a catalog's ids share (`gemini`, `gpt`, `claude`): the provider's own family, as opposed to
81
+ // a model it also serves from someone else, which Google's catalog does with Claude's.
82
+ const ownPrefix = (models: readonly string[]): string | undefined => {
83
+ const counts = new Map<string, number>();
84
+ for (const model of models) {
85
+ const prefix = model.split(/[-_]/)[0] ?? model;
86
+ counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
87
+ }
88
+ return [...counts].toSorted(([, a], [, b]) => b - a)[0]?.[0];
89
+ };
90
+
91
+ // A provider's pick for a rung: its own newest there, or a borrowed one only when it has none of its own.
92
+ const newestOnRung = (models: readonly string[], rung: number): string | undefined => {
93
+ const own = ownPrefix(models);
94
+ const onRung = models.filter((model) => modelRungOf(model) === rung).toSorted(compareModelIds);
95
+ return onRung.find((model) => model.split(/[-_]/)[0] === own) ?? onRung[0];
96
+ };
97
+
98
+ /**
99
+ * A preset's models, from what is connected: each connected provider's newest model of its own on the preset's rung, in
100
+ * the order the sources come in, at most three. `models` must hold only models that can run a turn, since a preset feeds whole
101
+ * sessions as well as one-shot jobs. Local endpoints are left out: what someone installed for themselves is theirs to
102
+ * put on a setup, not a suggestion.
103
+ */
104
+ export const presetPins = (preset: PresetSetup, sources: readonly ModelSource[]): readonly ModelPin[] =>
105
+ sources
106
+ .filter((source) => source.ready && !isEndpointProvider(source.provider))
107
+ .flatMap((source) => {
108
+ const newest = newestOnRung(source.models, PRESET_RUNG[preset]);
109
+ return newest === undefined ? [] : [{ provider: source.provider, model: newest }];
110
+ })
111
+ .slice(0, PRESET_LENGTH);
112
+
113
+ export const ladderPins = (ladder: SetupLadder, sources: readonly ModelSource[]): readonly ModelPin[] =>
114
+ ladder.kind === `pins` ? ladder.pins : presetPins(ladder.preset, sources);
115
+
116
+ /** The models a job runs on, in try order, before readiness filters them (model-pins.ts `readyChain`). */
117
+ export const rolePins = (settings: SetupSettings, role: ModelRole, sources: readonly ModelSource[]): readonly ModelPin[] =>
118
+ ladderPins(roleLadder(settings, role), sources);
119
+
120
+ /**
121
+ * Setups for a set of per-job lists, the shape settings had before setups (2026-10-12): jobs holding the same list share
122
+ * one setup, a list most of whose jobs are suggested one preset becomes that preset while it is free, and the rest are
123
+ * new setups. A job with no list, or an empty one, ran on nothing and is left on none. Exported for the settings
124
+ * history and for tests that want a job on exact models.
125
+ */
126
+ export const setupsFromRoleLists = (lists: Partial<Record<ModelRole, readonly ModelPin[]>>): SetupSettings & { modelSetups: ModelSetup[] } => {
127
+ const modelSetups: ModelSetup[] = [];
128
+ const roleSetups: Partial<Record<ModelRole, string>> = {};
129
+ const groups = new Map<string, ModelRole[]>();
130
+ for (const role of MODEL_ROLE_IDS) {
131
+ const list = lists[role];
132
+ if (list !== undefined && list.length > 0) {
133
+ const key = JSON.stringify(list);
134
+ groups.set(key, [...(groups.get(key) ?? []), role]);
135
+ }
136
+ }
137
+ let made = 0;
138
+ for (const roles of groups.values()) {
139
+ const pins = lists[roles[0]!] ?? [];
140
+ // The preset most of these jobs are suggested; a tie goes to the first job's, the table's own order.
141
+ const votes = roles.map(suggestedSetup);
142
+ const preset = votes.toSorted((a, b) => votes.filter((vote) => vote === b).length - votes.filter((vote) => vote === a).length)[0]!;
143
+ const free = storedSetup({ modelSetups }, preset) === undefined;
144
+ const id = free ? preset : `setup-${++made}`;
145
+ modelSetups.push(free ? { id, pins: [...pins] } : { id, name: `Setup ${made}`, pins: [...pins] });
146
+ for (const role of roles) {
147
+ roleSetups[role] = id;
148
+ }
149
+ }
150
+ return { modelSetups, roleSetups };
151
+ };
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  bindingWindow,
3
+ currentWindows,
3
4
  gatesModel,
4
5
  gatingWindows,
5
6
  headroomState,
@@ -116,6 +117,38 @@ test("a reading is live until its reset, or, with none published, for one window
116
117
  expect(windowLive(window({ kind: "claude:tangelo" }), NOW - 400 * HOUR, NOW)).toBe(true);
117
118
  });
118
119
 
120
+ test("a pool whose window closed since its reading stays on the account, at the floor and marked lapsed, not dropped", () => {
121
+ const NOW = 1_700_000_000_000;
122
+ const HOUR = 3_600_000;
123
+ // The case that lost the five-hour row: re-reads failing for hours, the weekly pools still current.
124
+ const stale: AccountUsage = {
125
+ measuredAt: NOW - 8 * HOUR,
126
+ windows: [
127
+ window({ kind: "five_hour", utilization: 63, resetsAt: (NOW - 5 * HOUR) / 1_000, rounded: true }),
128
+ window({ kind: "seven_day", utilization: 42, resetsAt: NOW / 1_000 + 100 * 3_600 }),
129
+ window({ kind: "model:Fable", label: "Fable", utilization: 0, resetsAt: NOW / 1_000 + 100 * 3_600, gates: { models: ["Fable"] } }),
130
+ ],
131
+ };
132
+ const current = currentWindows(stale, NOW);
133
+ expect(current.map((pool) => pool.kind)).toEqual(["five_hour", "seven_day", "model:Fable"]);
134
+ expect(current[0]).toEqual({ kind: "five_hour", utilization: 0, gates: "all", lapsedAt: (NOW - 5 * HOUR) / 1_000 });
135
+ expect(current.slice(1)).toEqual(stale.windows.slice(1));
136
+ // The floor binds nothing: the weekly pool is still the one that gates the next turn.
137
+ expect(bindingWindow({ ...stale, windows: current })?.kind).toBe("seven_day");
138
+ // Applying it again changes nothing, so a surface may run it over rows the daemon already ran it over.
139
+ expect(currentWindows({ ...stale, windows: current }, NOW)).toEqual(current);
140
+
141
+ // An idle pool published with no reset lapses at the end of its own length.
142
+ const idle: AccountUsage = {
143
+ measuredAt: NOW - 6 * HOUR,
144
+ windows: [window({ kind: "five_hour", utilization: 0 }), window({ kind: "seven_day", utilization: 5, resetsAt: NOW / 1_000 + 3_600 })],
145
+ };
146
+ expect(currentWindows(idle, NOW)[0]?.lapsedAt).toBe((NOW - HOUR) / 1_000);
147
+
148
+ // A reading with nothing current left says nothing about now: the account reads as unmeasured.
149
+ expect(currentWindows({ measuredAt: NOW - 8 * HOUR, windows: stale.windows.slice(0, 1) }, NOW)).toEqual([]);
150
+ });
151
+
119
152
  // Serviceability: the one verdict on whether an account can serve a turn.
120
153
 
121
154
  const at = (measuredAt: number, ...windows: UsageWindow[]): AccountUsage => ({ windows, measuredAt });
@@ -132,6 +165,22 @@ test("spent is the contract's one line: 99.5% still has room, 100% is spent unti
132
165
  expect(headroomState(CLAUDE, { id: "claude-opus-4-6" })).toEqual({ kind: "spent" });
133
166
  });
134
167
 
168
+ // Anthropic reports whole percents: an account read at 100 kept serving turns for hours, so that reading is the line,
169
+ // not proof. Only the provider's refusal makes such a pool spent.
170
+ test("a pool read at 100 in whole percents is at the line with no room, until a refusal proves it empty", () => {
171
+ const week = window({ kind: "seven_day", utilization: 100, resetsAt: 900, rounded: true });
172
+ const atLine = usage(window({ kind: "five_hour", utilization: 75, rounded: true }), week);
173
+ expect(headroomState(atLine)).toEqual({ kind: "ready", room: 0 });
174
+ expect(headroomState(atLine, { id: "claude-opus-5-5" })).toEqual({ kind: "ready", room: 0 });
175
+ // A precise 100 is still spent.
176
+ expect(headroomState(usage(window({ kind: "seven_day", utilization: 100, resetsAt: 900 })))).toEqual({ kind: "spent", reopensAt: 900 });
177
+ // The provider refused it: spent until the pool reopens, and a reading still at the line does not answer that.
178
+ const facts = { account: "a", usage: { ...atLine, measuredAt: 2_000 } };
179
+ const refused = refusal({ kind: "limit", account: "a" });
180
+ expect(refusalVerdict(refused, [facts])).toBe("standing");
181
+ expect(serviceStates([facts], refused).get("a")).toEqual({ kind: "spent", reopensAt: 900 });
182
+ });
183
+
135
184
  test("with no model named, an account is spent only when no model can run on it", () => {
136
185
  // A full Opus slice leaves every other model the room of the fullest pool still open.
137
186
  expect(headroomState(CLAUDE)).toEqual({ kind: "ready", room: 70 });
@@ -232,6 +281,31 @@ test("a provider's accounts are judged together: a standing refusal lands only o
232
281
  expect(answered.get("a")).toEqual({ kind: "ready", room: 60 });
233
282
  });
234
283
 
284
+ // The provider's last refusal moves on to whichever account says no next; an account's own refusal on file stays true of
285
+ // it. A pool Anthropic reads at a rounded 100 is only proven empty by that refusal.
286
+ test("each account answers to the newest refusal on file that still stands over it", () => {
287
+ const line = at(5_000, window({ kind: "seven_day", utilization: 100, rounded: true, resetsAt: 20 }));
288
+ // Read before its sign-in was refused, so nothing has answered that refusal yet.
289
+ const room = at(2_000, window({ kind: "seven_day", utilization: 30 }));
290
+ const own = refusal({ kind: "limit", account: "a", resetsAt: 20, at: 1_000 });
291
+ const elsewhere = refusal({ kind: "auth", account: "b", at: 3_000 });
292
+ const accounts = [
293
+ { account: "a", usage: line },
294
+ { account: "b", usage: room },
295
+ ];
296
+ expect(Object.fromEntries(serviceStates(accounts, [elsewhere, own], undefined, 9_999))).toEqual({
297
+ a: { kind: "spent", reopensAt: 20 },
298
+ b: { kind: "blocked", fix: "reconnect", reason: "no." },
299
+ });
300
+ // Only the provider's last one: the first account is back at the line, no room but not proven spent.
301
+ expect(serviceStates(accounts, elsewhere, undefined, 9_999).get("a")).toEqual({ kind: "ready", room: 0 });
302
+ // Past the instant the provider named, its refusal is over and the reading speaks alone.
303
+ expect(serviceStates(accounts, [elsewhere, own], undefined, 20_000).get("a")).toEqual({ kind: "ready", room: 0 });
304
+ // A newer refusal on the same account outranks the older one.
305
+ const revoked = refusal({ kind: "auth", account: "a", at: 6_000 });
306
+ expect(serviceStates(accounts, [own, revoked], undefined, 9_999).get("a")).toEqual({ kind: "blocked", fix: "reconnect", reason: "no." });
307
+ });
308
+
235
309
  test("an unnamed turn takes the most room, then an unmeasured account, then a spent one, and a blocked one only when that is all", () => {
236
310
  const entry = (id: string, state: AccountState) => ({ id, state });
237
311
  const blocked: AccountState = { kind: "blocked", fix: "admin", reason: "no seat" };
@@ -243,3 +317,14 @@ test("an unnamed turn takes the most room, then an unmeasured account, then a sp
243
317
  expect(roomiestAccount([entry("seatless", blocked), entry("unread", { kind: "unknown" })])).toBeUndefined();
244
318
  expect(roomiestAccount([entry("low", { kind: "ready", room: 5 }), entry("high", { kind: "ready", room: 60 })])?.id).toBe("high");
245
319
  });
320
+
321
+ test("an account at the line is tried after an unmeasured one and before a spent one, and never a move's destination", () => {
322
+ const entry = (id: string, state: AccountState) => ({ id, state });
323
+ const atLine: AccountState = { kind: "ready", room: 0 };
324
+ expect(preferredAccount([entry("line", atLine), entry("low", { kind: "ready", room: 2 })])?.id).toBe("low");
325
+ expect(preferredAccount([entry("line", atLine), entry("unread", { kind: "unknown" })])?.id).toBe("unread");
326
+ expect(preferredAccount([entry("spent", { kind: "spent" }), entry("line", atLine)])?.id).toBe("line");
327
+ // A move lands only on proven room: two accounts at the line would otherwise hand a refused turn back and forth.
328
+ expect(roomiestAccount([entry("line", atLine), entry("spent", { kind: "spent" })])).toBeUndefined();
329
+ expect(roomiestAccount([entry("line", atLine), entry("low", { kind: "ready", room: 2 })])?.id).toBe("low");
330
+ });
@@ -8,6 +8,18 @@ import type { AccountState, AccountUsage, ProviderRefusal, UsageWindow, WindowGa
8
8
  // anything short of it still has room.
9
9
  export const SPENT_UTILIZATION = 100;
10
10
 
11
+ // Whether a reading proves its pool empty. A pool read at the line by a provider that rounds to whole percents
12
+ // (`rounded`) does not: Anthropic reports 99.5% as 100 and keeps serving turns on it, so treating that as spent strands
13
+ // the last of an allowance (and turns a plain send into one scheduled for the reset). Such a pool is at the line, with
14
+ // no room to rank by; only the provider's refusal (pinnedFull) proves it empty.
15
+ export const provenFull = (window: UsageWindow): boolean => window.utilization >= SPENT_UTILIZATION && window.rounded !== true;
16
+
17
+ // A pool a standing limit refusal shows full: at the line, and proven so whatever the provider's rounding.
18
+ export const pinnedFull = (window: UsageWindow): UsageWindow => {
19
+ const { rounded: _rounded, ...pool } = window;
20
+ return { ...pool, utilization: Math.max(window.utilization, SPENT_UTILIZATION) };
21
+ };
22
+
11
23
  export interface ModelRef {
12
24
  // Wire model id.
13
25
  readonly id: string;
@@ -134,6 +146,28 @@ export const windowLive = (window: UsageWindow, measuredAt: number, now: number)
134
146
  return period === undefined || measuredAt + period.seconds * 1000 > now;
135
147
  };
136
148
 
149
+ // A pool whose reading belongs to a window that has since closed: still the account's pool, at the floor any new window
150
+ // starts from, and marked with when the old window closed so a surface can say it has not been re-read since.
151
+ const lapsedWindow = (window: UsageWindow, measuredAt: number): UsageWindow => {
152
+ const { resetsAt, rounded: _rounded, utilization: _utilization, ...pool } = window;
153
+ // Only reached for a window windowLive retired, so with no published reset its length is known.
154
+ const closedAt = resetsAt ?? Math.floor((measuredAt + (windowPeriod(window)?.seconds ?? 0) * 1000) / 1000);
155
+ return { ...pool, utilization: 0, lapsedAt: closedAt };
156
+ };
157
+
158
+ // What a reading says about each of the account's pools now. A window past its reset is kept rather than dropped, as a
159
+ // lapsed pool (lapsedWindow): dropping it made a reading whose re-reads were failing lose its five-hour row within hours
160
+ // while the weekly rows stayed, a partial card that reads as complete. The floor of 0 is what dropping it already meant
161
+ // to every verdict (an absent pool binds nothing). A reading with no window still current says nothing about now and
162
+ // answers none, so the account reads as unmeasured. Idempotent: a pool already lapsed stays as it is.
163
+ export const currentWindows = (usage: AccountUsage, now: number): UsageWindow[] => {
164
+ const live = (window: UsageWindow): boolean => window.lapsedAt === undefined && windowLive(window, usage.measuredAt, now);
165
+ if (!usage.windows.some(live)) {
166
+ return [];
167
+ }
168
+ return usage.windows.map((window) => (window.lapsedAt !== undefined || live(window) ? window : lapsedWindow(window, usage.measuredAt)));
169
+ };
170
+
137
171
  // Whether an account can serve a turn: the one rule every picker and surface reads (AccountState). The daemon runs it and
138
172
  // publishes the verdict on each account row; the editor runs it only on a row from a daemon older than that field.
139
173
 
@@ -210,15 +244,15 @@ export const headroomState = (usage: AccountUsage | undefined, model?: ModelRef)
210
244
  if (windows.length === 0) {
211
245
  return { kind: "unknown" };
212
246
  }
213
- const full = windows.filter((window) => window.utilization >= SPENT_UTILIZATION);
214
- const open = windows.filter((window) => window.utilization < SPENT_UTILIZATION);
247
+ const full = windows.filter(provenFull);
248
+ const open = windows.filter((window) => !provenFull(window));
215
249
  if (model !== undefined || full.some((window) => window.gates === "all") || open.length === 0) {
216
250
  const holding = model !== undefined ? full : full.some((window) => window.gates === "all") ? full.filter((window) => window.gates === "all") : full;
217
251
  if (holding.length > 0) {
218
252
  return spent(reopening(holding, model === undefined && !holding.some((window) => window.gates === "all")));
219
253
  }
220
254
  }
221
- return { kind: "ready", room: SPENT_UTILIZATION - Math.max(...open.map((window) => window.utilization)) };
255
+ return { kind: "ready", room: Math.max(0, SPENT_UTILIZATION - Math.max(...open.map((window) => window.utilization))) };
222
256
  };
223
257
 
224
258
  // A standing limit refusal reads its pool as full, since a polled reading freezes the moment a pool actually empties: the
@@ -231,7 +265,7 @@ const refusedPool = (usage: AccountUsage | undefined, refusal: ProviderRefusal,
231
265
  const sameModel = refusal.model === undefined || model === undefined || refusal.model === model.id;
232
266
  return sameModel ? spent(refusal.resetsAt) : headroomState(usage, model);
233
267
  }
234
- const pinned = usage.windows.map((window) => (window === binding ? { ...window, utilization: Math.max(window.utilization, SPENT_UTILIZATION) } : window));
268
+ const pinned = usage.windows.map((window) => (window === binding ? pinnedFull(window) : window));
235
269
  const state = headroomState({ ...usage, windows: pinned }, model);
236
270
  // The provider's own "try again at" is when it reopens, over whatever the polled pool last said.
237
271
  return state.kind === "spent" && refusal.resetsAt !== undefined ? spent(refusal.resetsAt) : state;
@@ -271,35 +305,60 @@ export const serviceState = (facts: ServiceFacts, refusal?: ProviderRefusal, mod
271
305
  return refusal?.kind === "limit" ? refusedPool(facts.usage, refusal, model) : headroomState(facts.usage, model);
272
306
  };
273
307
 
274
- /** Every account of one provider, judged together, since a refusal naming no account is answered by any of them. */
308
+ /** The refusals a provider's accounts are judged against: its last one, or every one on file (each account's own beside it). */
309
+ export type RefusalsOnFile = ProviderRefusal | readonly ProviderRefusal[] | undefined;
310
+
311
+ const refusalList = (refusals: RefusalsOnFile): readonly ProviderRefusal[] =>
312
+ refusals === undefined ? [] : "kind" in refusals ? [refusals] : refusals;
313
+
314
+ /**
315
+ * Every account of one provider, judged together, since a refusal naming no account is answered by any of them. Each
316
+ * account answers to the newest refusal that still stands over it: the provider's last one moves on to whichever account
317
+ * said no next, and an account's own refusal on file stays true of it until something answers it.
318
+ */
275
319
  export const serviceStates = (
276
320
  accounts: readonly ServiceFacts[],
277
- refusal: ProviderRefusal | undefined,
321
+ refusals: RefusalsOnFile,
278
322
  model?: ModelRef,
279
323
  now: number = Date.now(),
280
324
  ): ReadonlyMap<string, AccountState> => {
281
- const standing = refusal !== undefined && refusalVerdict(refusal, accounts, now) === "standing" ? refusal : undefined;
325
+ const standing = refusalList(refusals)
326
+ .filter((refusal) => refusalVerdict(refusal, accounts, now) === "standing")
327
+ .toSorted((left, right) => right.at - left.at);
282
328
  return new Map(
283
- accounts.map((facts) => [
284
- facts.account,
285
- serviceState(facts, standing !== undefined && (standing.account ?? facts.account) === facts.account ? standing : undefined, model, now),
286
- ]),
329
+ accounts.map((facts) => [facts.account, serviceState(facts, standing.find((refusal) => (refusal.account ?? facts.account) === facts.account), model, now)]),
287
330
  );
288
331
  };
289
332
 
290
- // Worst last: proven room, no reading either way, known spent, and nothing a turn can run on.
291
- const PREFERENCE: Record<AccountState["kind"], number> = { ready: 0, unknown: 1, spent: 2, blocked: 3 };
333
+ // Worst last: proven room, no reading either way, a pool at the line (it may hold a sliver, or nothing), known spent,
334
+ // and nothing a turn can run on.
335
+ const preference = (state: AccountState): number => {
336
+ switch (state.kind) {
337
+ case "ready":
338
+ return state.room > 0 ? 0 : 2;
339
+ case "unknown":
340
+ return 1;
341
+ case "spent":
342
+ return 3;
343
+ case "blocked":
344
+ return 4;
345
+ }
346
+ };
292
347
 
293
- /** The account an unnamed turn runs on: ready by most room, then unmeasured, then spent, blocked only when that is all there is. Ties keep the caller's order. */
348
+ /** The account an unnamed turn runs on: ready by most room, then unmeasured, then at the line, then spent, blocked only when that is all there is. Ties keep the caller's order. */
294
349
  export const preferredAccount = <T extends { readonly state: AccountState }>(entries: readonly T[]): T | undefined =>
295
350
  entries.reduce<T | undefined>((best, next) => {
296
351
  if (best === undefined) {
297
352
  return next;
298
353
  }
299
- const tier = PREFERENCE[next.state.kind] - PREFERENCE[best.state.kind];
354
+ const tier = preference(next.state) - preference(best.state);
300
355
  return tier < 0 || (tier === 0 && next.state.kind === "ready" && best.state.kind === "ready" && next.state.room > best.state.room) ? next : best;
301
356
  }, undefined);
302
357
 
303
- /** The roomiest account with proven room, or none: what a move off a refused account may land on. Ties keep the caller's order. */
358
+ /**
359
+ * The roomiest account with proven room, or none: what a move off a refused account may land on. Ties keep the caller's
360
+ * order. An account at the line is left out: a move there could be refused in turn, and with one refusal remembered per
361
+ * provider, two such accounts would hand the turn back and forth.
362
+ */
304
363
  export const roomiestAccount = <T extends { readonly state: AccountState }>(entries: readonly T[]): T | undefined =>
305
- preferredAccount(entries.filter((entry) => entry.state.kind === "ready"));
364
+ preferredAccount(entries.filter((entry) => entry.state.kind === "ready" && entry.state.room > 0));
@@ -1,5 +1,5 @@
1
1
  import type { ProviderBrand } from "@intentic/constants";
2
- import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, OPENCODE, OPENCODE_GEMINI } from "./agent-runtimes.js";
2
+ import { type AgentCapabilities, CLAUDE_CODE, CODEX, CURSOR, DEVIN, OPENCODE, OPENCODE_GEMINI } from "./agent-runtimes.js";
3
3
 
4
4
  // Every fact about a provider more than one surface needs, one row each, replacing lists that drifted out of sync.
5
5
  // `access` (cost) and `auth` (connection) stay separate: conflating them mis-describes Z.ai, a prepaid plan spent
@@ -150,6 +150,24 @@ export const PROVIDER_SPECS = [
150
150
  // Cursor ignores harness too: there is no route to it but its own SDK.
151
151
  runtimes: { native: CURSOR, claudeCode: CURSOR },
152
152
  },
153
+ {
154
+ // Cognition's Devin through its own CLI (`devin acp`); labelled for the account, since it vends many vendors'
155
+ // models (SWE, Claude, GPT, Gemini…) under one Devin plan.
156
+ id: "devin",
157
+ label: "Devin",
158
+ vendor: "Devin",
159
+ accountLabel: "Devin",
160
+ destination: "Devin",
161
+ brand: "devin",
162
+ // Free is a real tier here: a free Devin account runs turns, on the smaller list of models its plan offers.
163
+ access: { kind: "free", requirement: "Devin account", runs: "Devin's agent on the models your plan offers" },
164
+ // The daemon drives the CLI's own sign-in and holds the key it hands back, one per account.
165
+ auth: { kind: "oauth" },
166
+ // Devin publishes its quota only on its own pages and in its CLI's interactive view, nothing a key can read.
167
+ planLimits: false,
168
+ // The CLI is the only door, so harness is not a choice: both axes are Devin's own runtime.
169
+ runtimes: { native: DEVIN, claudeCode: DEVIN },
170
+ },
153
171
  // The two minted providers need no new runtime or translator hop: each has its own Anthropic Messages endpoint.
154
172
  {
155
173
  id: "meta",
@@ -223,7 +241,7 @@ export const PROVIDER_SPECS = [
223
241
  type Spec = (typeof PROVIDER_SPECS)[number];
224
242
 
225
243
  // The agent runtimes the daemon can serve; a native id is reserved, `endpoint/<id>` or any other value names an
226
- // installed capability instead. Derived from the table and a union of six names, so the wire vocabulary can't drift.
244
+ // installed capability instead. Derived from the table and a union of its ids, so the wire vocabulary can't drift.
227
245
  export type NativeProvider = Spec["id"];
228
246
  export const NATIVE_PROVIDERS: readonly NativeProvider[] = PROVIDER_SPECS.map((spec) => spec.id);
229
247
 
@@ -1,5 +1,6 @@
1
1
  import { HISTORY_ROOT, WORKSPACE_ROOT } from "@intentic/constants";
2
2
  import { type CommandClass, CommandClassSchema, type CommandLocus } from "../schemas/agent.js";
3
+ import { SECRET_REFERENCE } from "../schemas/needs.js";
3
4
  import { inertRegions, isLive } from "../text/shell-regions.js";
4
5
 
5
6
  // Classifies a shell command, shared by the sandbox gate and the machine agent's shell tool so they can't drift on what
@@ -72,7 +73,7 @@ const GIT_DESTRUCTIVE = [
72
73
  const GIT_BRANCH_SWITCH = [/\bgit\s+switch\b/, /\bgit\s+checkout\b(?![^|;&]*\s--\s)(?![^|;&]*\s\.(?:\s|$))/];
73
74
 
74
75
  // The credential is IN the command, not in a file; there is nothing for a filesystem to fact-check here.
75
- const SECRET_REFERENCES = [/\{\{secret:[A-Za-z0-9_./-]+\}\}/];
76
+ const SECRET_REFERENCES = [SECRET_REFERENCE];
76
77
 
77
78
  // A path that usually holds a credential, a guess about a FILE, so every entry here is subject to holdsSecret.
78
79
  // The directory in front of a filename, so a pattern spans the whole path, not just the file inside it.
@@ -404,6 +405,23 @@ const findAndXargsDeletes = (command: string): CommandSpan[] => [
404
405
  ...spansOf([XARGS_RM], command),
405
406
  ];
406
407
 
408
+ // The same tree delete in Windows' own shells, which is what a device's run_command runs there: PowerShell's
409
+ // Remove-Item (and its aliases) with -Recurse, which it answers without asking, and cmd's rd/rmdir /s and del /s. Only
410
+ // PowerShell's own names are read with a bare -r (any prefix of -Recurse, which PowerShell accepts), since `rm -r` is
411
+ // also POSIX rm, whose bare -r asks before anything write-protected and is left to the rm rule above. And .NET's
412
+ // Directory.Delete(path, true), the same delete from a script.
413
+ const POWERSHELL_RECURSE = String.raw`\s-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?(?::\$true)?(?![\w-])`;
414
+ const WINDOWS_RECURSIVE_DELETES = [
415
+ new RegExp(String.raw`(?<![\w-])(?:Remove-Item|ri|rd|rmdir|del|erase)\b[^|;&\n]*?${POWERSHELL_RECURSE}`, "i"),
416
+ new RegExp(String.raw`(?<![\w-])rm\b[^|;&\n]*?\s-recurse(?![\w-])`, "i"),
417
+ /(?<![\w-])(?:rd|rmdir|del|erase)\b[^|;&\n]*?\s\/s\b/i,
418
+ /\[(?:System\.)?IO\.Directory\]::Delete\s*\([^)]*,\s*\$true\s*\)/i,
419
+ ];
420
+ const windowsDeletes = (command: string): CommandSpan[] => spansOf(globally(WINDOWS_RECURSIVE_DELETES), command);
421
+
422
+ // A disk wiped by Windows' own tools: a volume formatted, a disk cleared, from PowerShell or cmd.
423
+ const WINDOWS_DISK_WIPES = [/(?<![\w-])(?:Format-Volume|Clear-Disk)\b/i, /(?<![\w-])format(?:\.com)?\s+[A-Za-z]:/i];
424
+
407
425
  // What cuts a checkout off from its repository's history: deleting its `.git` (for a worktree, a one-line pointer
408
426
  // whose loss strands every commit the checkout was reading), or `git init` over the folder it stands in, which makes
409
427
  // a standalone repository sharing nothing with the one it came from. A `git init <new-dir>` for a new project is not
@@ -500,8 +518,17 @@ const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandC
500
518
  "git.destructive": (command) => [...spansOf(GIT_DESTRUCTIVE_G, command), ...checkoutUnlinks(command)],
501
519
  // The only class that can answer "not here": in its own copy a conversation's checkout is its own to move.
502
520
  "git.branch-switch": (command, context) => (context.ownCheckout === true ? [] : spansOf(GIT_BRANCH_SWITCH_G, command)),
503
- "files.destructive": (command) => [...recursiveForceRms(command), ...recursiveDeletes(command), ...findAndXargsDeletes(command)],
504
- "system.destructive": (command, context) => [...spansOf(BLOCK_DEVICE_G, command), ...rootDeletes(command, context.locus)],
521
+ "files.destructive": (command) => [
522
+ ...recursiveForceRms(command),
523
+ ...recursiveDeletes(command),
524
+ ...findAndXargsDeletes(command),
525
+ ...windowsDeletes(command),
526
+ ],
527
+ "system.destructive": (command, context) => [
528
+ ...spansOf(BLOCK_DEVICE_G, command),
529
+ ...spansOf(globally(WINDOWS_DISK_WIPES), command),
530
+ ...rootDeletes(command, context.locus),
531
+ ],
505
532
  "container.state": (command) => spansOf(CONTAINER_STATE_G, command),
506
533
  "secrets.access": credentialReads,
507
534
  "package.publish": (command) => spansOf(PACKAGE_PUBLISH_G, command),
@@ -565,6 +592,9 @@ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly Comm
565
592
  { code: "rimraf(<path>)" },
566
593
  { code: "find <path> -delete", qualifier: "also -exec rm and -execdir rm" },
567
594
  { code: "xargs rm" },
595
+ { code: "Remove-Item -Recurse <path>", qualifier: "also its aliases ri, rd, rmdir, del, erase, and rm -Recurse" },
596
+ { code: "rd /s <path>", qualifier: "also rmdir /s and del /s" },
597
+ { code: "[IO.Directory]::Delete(<path>, $true)" },
568
598
  ],
569
599
  "system.destructive": [
570
600
  { code: "mkfs" },
@@ -574,6 +604,7 @@ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly Comm
574
604
  { code: "dd of=/dev/…" },
575
605
  { code: "shred /dev/…" },
576
606
  { code: "> /dev/sda" },
607
+ { code: "Format-Volume", qualifier: "also Clear-Disk, and format C:" },
577
608
  { code: "rm -rf /", qualifier: "also find / -delete; only when the target is a root, listed below" },
578
609
  ],
579
610
  "container.state": [