@intentic/sandbox-contract 1.245.0 → 1.247.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 (247) hide show
  1. package/README.md +17 -1
  2. package/dist/batch-runs.d.ts +2 -0
  3. package/dist/batch-runs.d.ts.map +1 -1
  4. package/dist/batch-runs.js +1 -0
  5. package/dist/batch-runs.js.map +1 -1
  6. package/dist/command-classes.d.ts +6 -3
  7. package/dist/command-classes.d.ts.map +1 -1
  8. package/dist/command-classes.js +43 -18
  9. package/dist/command-classes.js.map +1 -1
  10. package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
  11. package/dist/contracts/accounts.contract.d.ts.map +1 -0
  12. package/dist/contracts/accounts.contract.js +61 -0
  13. package/dist/contracts/accounts.contract.js.map +1 -0
  14. package/dist/contracts/agent.contract.d.ts +19 -0
  15. package/dist/contracts/agent.contract.d.ts.map +1 -1
  16. package/dist/contracts/agents.contract.d.ts +121 -0
  17. package/dist/contracts/agents.contract.d.ts.map +1 -1
  18. package/dist/contracts/agents.contract.js +4 -4
  19. package/dist/contracts/agents.contract.js.map +1 -1
  20. package/dist/contracts/ci.contract.d.ts +2 -0
  21. package/dist/contracts/ci.contract.d.ts.map +1 -1
  22. package/dist/contracts/host.contract.d.ts +35 -0
  23. package/dist/contracts/host.contract.d.ts.map +1 -1
  24. package/dist/contracts/host.contract.js +3 -2
  25. package/dist/contracts/host.contract.js.map +1 -1
  26. package/dist/contracts/personas.contract.d.ts +4 -2
  27. package/dist/contracts/personas.contract.d.ts.map +1 -1
  28. package/dist/contracts/runner.contract.d.ts +2 -2
  29. package/dist/contracts/settings.contract.d.ts +58 -20
  30. package/dist/contracts/settings.contract.d.ts.map +1 -1
  31. package/dist/contracts/system.contract.d.ts +80 -30
  32. package/dist/contracts/system.contract.d.ts.map +1 -1
  33. package/dist/contracts/system.contract.js +26 -17
  34. package/dist/contracts/system.contract.js.map +1 -1
  35. package/dist/contracts/usage.contract.d.ts +22 -0
  36. package/dist/contracts/usage.contract.d.ts.map +1 -1
  37. package/dist/contracts/usage.contract.js +19 -0
  38. package/dist/contracts/usage.contract.js.map +1 -1
  39. package/dist/definition.d.ts +20 -28
  40. package/dist/definition.d.ts.map +1 -1
  41. package/dist/documents.d.ts +0 -1
  42. package/dist/documents.d.ts.map +1 -1
  43. package/dist/documents.js +1 -2
  44. package/dist/documents.js.map +1 -1
  45. package/dist/embed.d.ts +23 -0
  46. package/dist/embed.d.ts.map +1 -0
  47. package/dist/embed.js +84 -0
  48. package/dist/embed.js.map +1 -0
  49. package/dist/events.d.ts +21 -0
  50. package/dist/events.d.ts.map +1 -1
  51. package/dist/events.js +5 -2
  52. package/dist/events.js.map +1 -1
  53. package/dist/fast-tier.js +1 -1
  54. package/dist/fast-tier.js.map +1 -1
  55. package/dist/history-state.d.ts.map +1 -1
  56. package/dist/history-state.js +2 -0
  57. package/dist/history-state.js.map +1 -1
  58. package/dist/index.d.ts +453 -306
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +7 -15
  61. package/dist/index.js.map +1 -1
  62. package/dist/model-pins.d.ts +17 -0
  63. package/dist/model-pins.d.ts.map +1 -0
  64. package/dist/{quick-model.js → model-pins.js} +18 -11
  65. package/dist/model-pins.js.map +1 -0
  66. package/dist/model-roles.d.ts +144 -0
  67. package/dist/model-roles.d.ts.map +1 -0
  68. package/dist/model-roles.js +129 -0
  69. package/dist/model-roles.js.map +1 -0
  70. package/dist/peer-dial.d.ts +33 -0
  71. package/dist/peer-dial.d.ts.map +1 -0
  72. package/dist/peer-dial.js +79 -0
  73. package/dist/peer-dial.js.map +1 -0
  74. package/dist/peer-mcp-server.d.ts +36 -0
  75. package/dist/peer-mcp-server.d.ts.map +1 -0
  76. package/dist/peer-mcp-server.js +71 -0
  77. package/dist/peer-mcp-server.js.map +1 -0
  78. package/dist/provider-specs.d.ts +38 -20
  79. package/dist/provider-specs.d.ts.map +1 -1
  80. package/dist/provider-specs.js +39 -13
  81. package/dist/provider-specs.js.map +1 -1
  82. package/dist/runtime-state.d.ts +1 -1
  83. package/dist/runtime-state.js +1 -1
  84. package/dist/runtime-state.js.map +1 -1
  85. package/dist/safety-policy.d.ts +12 -3
  86. package/dist/safety-policy.d.ts.map +1 -1
  87. package/dist/safety-policy.js +30 -5
  88. package/dist/safety-policy.js.map +1 -1
  89. package/dist/schemas/agent.d.ts +27 -8
  90. package/dist/schemas/agent.d.ts.map +1 -1
  91. package/dist/schemas/agent.js +10 -4
  92. package/dist/schemas/agent.js.map +1 -1
  93. package/dist/schemas/agents.d.ts +42 -0
  94. package/dist/schemas/agents.d.ts.map +1 -1
  95. package/dist/schemas/agents.js +25 -4
  96. package/dist/schemas/agents.js.map +1 -1
  97. package/dist/schemas/automations.d.ts +11 -2
  98. package/dist/schemas/automations.d.ts.map +1 -1
  99. package/dist/schemas/automations.js +1 -1
  100. package/dist/schemas/automations.js.map +1 -1
  101. package/dist/schemas/ci.d.ts +6 -0
  102. package/dist/schemas/ci.d.ts.map +1 -1
  103. package/dist/schemas/ci.js +3 -2
  104. package/dist/schemas/ci.js.map +1 -1
  105. package/dist/schemas/context.d.ts +30 -0
  106. package/dist/schemas/context.d.ts.map +1 -0
  107. package/dist/schemas/context.js +34 -0
  108. package/dist/schemas/context.js.map +1 -0
  109. package/dist/schemas/{computers.d.ts → devices.d.ts} +154 -60
  110. package/dist/schemas/devices.d.ts.map +1 -0
  111. package/dist/schemas/devices.js +157 -0
  112. package/dist/schemas/devices.js.map +1 -0
  113. package/dist/schemas/hosts.d.ts +12 -0
  114. package/dist/schemas/hosts.d.ts.map +1 -1
  115. package/dist/schemas/hosts.js +1 -0
  116. package/dist/schemas/hosts.js.map +1 -1
  117. package/dist/schemas/issues.d.ts +0 -5
  118. package/dist/schemas/issues.d.ts.map +1 -1
  119. package/dist/schemas/issues.js +0 -1
  120. package/dist/schemas/issues.js.map +1 -1
  121. package/dist/schemas/personas.d.ts +5 -3
  122. package/dist/schemas/personas.d.ts.map +1 -1
  123. package/dist/schemas/personas.js +3 -2
  124. package/dist/schemas/personas.js.map +1 -1
  125. package/dist/schemas/plan-limits.d.ts +20 -0
  126. package/dist/schemas/plan-limits.d.ts.map +1 -1
  127. package/dist/schemas/plan-limits.js +21 -0
  128. package/dist/schemas/plan-limits.js.map +1 -1
  129. package/dist/schemas/provider-oauth.d.ts +48 -16
  130. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  131. package/dist/schemas/provider-oauth.js +22 -20
  132. package/dist/schemas/provider-oauth.js.map +1 -1
  133. package/dist/schemas/settings.d.ts +42 -16
  134. package/dist/schemas/settings.d.ts.map +1 -1
  135. package/dist/schemas/settings.js +22 -28
  136. package/dist/schemas/settings.js.map +1 -1
  137. package/dist/schemas/terminal.js +9 -9
  138. package/dist/schemas/terminal.js.map +1 -1
  139. package/dist/schemas/usage.d.ts +5 -2
  140. package/dist/schemas/usage.d.ts.map +1 -1
  141. package/dist/schemas/usage.js +5 -2
  142. package/dist/schemas/usage.js.map +1 -1
  143. package/dist/shell-regions.d.ts +4 -0
  144. package/dist/shell-regions.d.ts.map +1 -0
  145. package/dist/shell-regions.js +156 -0
  146. package/dist/shell-regions.js.map +1 -0
  147. package/dist/workspace-state.d.ts +8 -0
  148. package/dist/workspace-state.d.ts.map +1 -1
  149. package/dist/workspace-state.js +13 -5
  150. package/dist/workspace-state.js.map +1 -1
  151. package/package.json +37 -4
  152. package/src/agent-catalog.ts +2 -2
  153. package/src/arrival.ts +3 -3
  154. package/src/batch-runs.test.ts +10 -5
  155. package/src/batch-runs.ts +10 -3
  156. package/src/command-classes.test.ts +195 -71
  157. package/src/command-classes.ts +148 -46
  158. package/src/contracts/accounts.contract.ts +94 -0
  159. package/src/contracts/agents.contract.ts +4 -3
  160. package/src/contracts/exit.contract.ts +2 -2
  161. package/src/contracts/host.contract.ts +17 -5
  162. package/src/contracts/settings.contract.ts +1 -1
  163. package/src/contracts/system.contract.ts +43 -24
  164. package/src/contracts/usage.contract.ts +31 -0
  165. package/src/contracts/vpn.contract.ts +2 -2
  166. package/src/documents.test.ts +2 -1
  167. package/src/documents.ts +7 -11
  168. package/src/embed.test.ts +68 -0
  169. package/src/embed.ts +164 -0
  170. package/src/events.ts +31 -4
  171. package/src/fast-tier.test.ts +1 -1
  172. package/src/fast-tier.ts +5 -5
  173. package/src/history-state.ts +12 -3
  174. package/src/host-protocol.ts +2 -2
  175. package/src/index.ts +8 -16
  176. package/src/model-order.ts +1 -1
  177. package/src/{quick-model.test.ts → model-pins.test.ts} +73 -29
  178. package/src/model-pins.ts +183 -0
  179. package/src/model-roles.ts +224 -0
  180. package/src/peer-dial.test.ts +203 -0
  181. package/src/peer-dial.ts +163 -0
  182. package/src/peer-mcp-server.test.ts +104 -0
  183. package/src/peer-mcp-server.ts +144 -0
  184. package/src/plan-pools.ts +1 -1
  185. package/src/prompt-complexity.test.ts +1 -1
  186. package/src/prompt-complexity.ts +2 -2
  187. package/src/provider-specs.test.ts +45 -18
  188. package/src/provider-specs.ts +147 -67
  189. package/src/routes.test.ts +6 -3
  190. package/src/runner-protocol.ts +1 -1
  191. package/src/runtime-state.ts +2 -2
  192. package/src/safety-policy.test.ts +88 -0
  193. package/src/safety-policy.ts +84 -14
  194. package/src/schemas/agent.ts +83 -30
  195. package/src/schemas/agents.ts +67 -6
  196. package/src/schemas/automations.ts +6 -4
  197. package/src/schemas/capabilities.ts +4 -4
  198. package/src/schemas/ci.ts +23 -6
  199. package/src/schemas/context.ts +87 -0
  200. package/src/schemas/{computers.ts → devices.ts} +190 -107
  201. package/src/schemas/hosts.ts +5 -1
  202. package/src/schemas/issues.ts +0 -4
  203. package/src/schemas/personas.ts +8 -3
  204. package/src/schemas/plan-limits.ts +50 -0
  205. package/src/schemas/provider-oauth.ts +49 -52
  206. package/src/schemas/settings.ts +105 -140
  207. package/src/schemas/terminal.ts +12 -12
  208. package/src/schemas/usage.ts +62 -27
  209. package/src/schemas/version-seam.test.ts +0 -1
  210. package/src/shell-regions.ts +289 -0
  211. package/src/versions.ts +2 -2
  212. package/src/webext-links.ts +2 -2
  213. package/src/webext-protocol.ts +2 -2
  214. package/src/workspace-state.test.ts +48 -1
  215. package/src/workspace-state.ts +48 -11
  216. package/dist/agent-run-model.d.ts +0 -4
  217. package/dist/agent-run-model.d.ts.map +0 -1
  218. package/dist/agent-run-model.js +0 -13
  219. package/dist/agent-run-model.js.map +0 -1
  220. package/dist/contracts/claude.contract.d.ts +0 -91
  221. package/dist/contracts/claude.contract.d.ts.map +0 -1
  222. package/dist/contracts/claude.contract.js +0 -50
  223. package/dist/contracts/claude.contract.js.map +0 -1
  224. package/dist/contracts/cursor.contract.d.ts.map +0 -1
  225. package/dist/contracts/cursor.contract.js +0 -50
  226. package/dist/contracts/cursor.contract.js.map +0 -1
  227. package/dist/contracts/grok.contract.d.ts +0 -36
  228. package/dist/contracts/grok.contract.d.ts.map +0 -1
  229. package/dist/contracts/grok.contract.js +0 -31
  230. package/dist/contracts/grok.contract.js.map +0 -1
  231. package/dist/contracts/keys.contract.d.ts +0 -81
  232. package/dist/contracts/keys.contract.d.ts.map +0 -1
  233. package/dist/contracts/keys.contract.js +0 -51
  234. package/dist/contracts/keys.contract.js.map +0 -1
  235. package/dist/quick-model.d.ts +0 -15
  236. package/dist/quick-model.d.ts.map +0 -1
  237. package/dist/quick-model.js.map +0 -1
  238. package/dist/schemas/computers.d.ts.map +0 -1
  239. package/dist/schemas/computers.js +0 -135
  240. package/dist/schemas/computers.js.map +0 -1
  241. package/src/agent-run-model.test.ts +0 -76
  242. package/src/agent-run-model.ts +0 -65
  243. package/src/contracts/claude.contract.ts +0 -71
  244. package/src/contracts/cursor.contract.ts +0 -74
  245. package/src/contracts/grok.contract.ts +0 -41
  246. package/src/contracts/keys.contract.ts +0 -79
  247. package/src/quick-model.ts +0 -155
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  /* The handshake on /system/hosts/connect, the ONE message that is not oRPC.
4
4
  *
5
- * Everything a connected computer is asked lives in `hostContract` (contracts/host.contract.ts), spoken over
5
+ * Everything a connected device is asked lives in `hostContract` (contracts/host.contract.ts), spoken over
6
6
  * this socket by oRPC's websocket adapter: the machine hosts the server, the daemon holds the client. But a
7
7
  * socket has to prove whose it is before it can be given a typed client, and that proof cannot itself be an
8
8
  * oRPC call, the daemon has nothing to call yet, and would be attaching a link to a stranger.
@@ -12,7 +12,7 @@ import { z } from "zod";
12
12
  * wire is oRPC. Anything arriving before the link exists is either this frame or a closed socket. */
13
13
 
14
14
  // The MCP protocol revision the machine's tool server implements. Shared because the daemon answers the
15
- // handshake ITSELF when the machine is asleep (hosts/host.routes.ts), two spellings of this would mean an
15
+ // handshake ITSELF when the machine is asleep (the daemon's peer bridge), two spellings of this would mean an
16
16
  // offline machine negotiating a different protocol than the same machine awake.
17
17
  export const MCP_PROTOCOL_VERSION = "2025-06-18";
18
18
 
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ContractRoute } from "./routes.js";
2
2
  import { contractRoutes, requestPathFor, routeForProcedure, routeNameForRequest, routeShapes } from "./routes.js";
3
+ import { accountsContract } from "./contracts/accounts.contract.js";
3
4
  import { activityContract } from "./contracts/activity.contract.js";
4
5
  import { agentContract } from "./contracts/agent.contract.js";
5
6
  import { agentsContract } from "./contracts/agents.contract.js";
@@ -8,14 +9,11 @@ import { automationsContract } from "./contracts/automations.contract.js";
8
9
  import { capabilitiesContract } from "./contracts/capabilities.contract.js";
9
10
  import { choresContract } from "./contracts/chores.contract.js";
10
11
  import { ciContract } from "./contracts/ci.contract.js";
11
- import { claudeContract } from "./contracts/claude.contract.js";
12
- import { cursorContract } from "./contracts/cursor.contract.js";
13
12
  import { endpointsContract } from "./contracts/endpoints.contract.js";
14
13
  import { exitContract } from "./contracts/exit.contract.js";
15
14
  import { extensionsContract } from "./contracts/extensions.contract.js";
16
15
  import { personasContract } from "./contracts/personas.contract.js";
17
16
  import { gitContract } from "./contracts/git.contract.js";
18
- import { grokContract } from "./contracts/grok.contract.js";
19
17
  import { historyContract } from "./contracts/history.contract.js";
20
18
  import { intenticContract } from "./contracts/intentic.contract.js";
21
19
  import { inventoryContract } from "./contracts/inventory.contract.js";
@@ -26,7 +24,6 @@ import { panelsContract } from "./contracts/panels.contract.js";
26
24
  import { portsContract } from "./contracts/ports.contract.js";
27
25
  import { publicContract } from "./contracts/public.contract.js";
28
26
  import { prepushContract } from "./contracts/prepush.contract.js";
29
- import { keysContract } from "./contracts/keys.contract.js";
30
27
  import { providersContract } from "./contracts/providers.contract.js";
31
28
  import { pushContract } from "./contracts/push.contract.js";
32
29
  import { safetyContract } from "./contracts/safety.contract.js";
@@ -42,6 +39,7 @@ import { vpnContract } from "./contracts/vpn.contract.js";
42
39
  import { workflowsContract } from "./contracts/workflows.contract.js";
43
40
  import { workspaceContract } from "./contracts/workspace.contract.js";
44
41
 
42
+ export { accountsContract } from "./contracts/accounts.contract.js";
45
43
  export { activityContract } from "./contracts/activity.contract.js";
46
44
  export { agentContract } from "./contracts/agent.contract.js";
47
45
  export { agentsContract } from "./contracts/agents.contract.js";
@@ -50,17 +48,14 @@ export { automationsContract } from "./contracts/automations.contract.js";
50
48
  export { capabilitiesContract } from "./contracts/capabilities.contract.js";
51
49
  export { choresContract } from "./contracts/chores.contract.js";
52
50
  export { ciContract } from "./contracts/ci.contract.js";
53
- export { claudeContract } from "./contracts/claude.contract.js";
54
- export { cursorContract } from "./contracts/cursor.contract.js";
55
51
  export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
56
52
  export { exitContract } from "./contracts/exit.contract.js";
57
53
  export { extensionsContract } from "./contracts/extensions.contract.js";
58
54
  export { personasContract } from "./contracts/personas.contract.js";
59
55
  export { gitContract } from "./contracts/git.contract.js";
60
- export { grokContract } from "./contracts/grok.contract.js";
61
56
  export { historyContract } from "./contracts/history.contract.js";
62
57
  /* Deliberately NOT part of `sandboxContract` below: that map is the daemon's own HTTP surface, and this one is
63
- * spoken the other way round, over a connected computer's WebSocket, with the MACHINE implementing it. */
58
+ * spoken the other way round, over a connected device's WebSocket, with the MACHINE implementing it. */
64
59
  export { hostContract } from "./contracts/host.contract.js";
65
60
  // The same inversion again, one layer in: spoken over the socket a BROWSER EXTENSION opened, with the
66
61
  // extension implementing it (contracts/webext.contract.ts).
@@ -77,7 +72,6 @@ export { panelsContract } from "./contracts/panels.contract.js";
77
72
  export { portsContract } from "./contracts/ports.contract.js";
78
73
  export { publicContract } from "./contracts/public.contract.js";
79
74
  export { prepushContract } from "./contracts/prepush.contract.js";
80
- export { keysContract } from "./contracts/keys.contract.js";
81
75
  export { providersContract } from "./contracts/providers.contract.js";
82
76
  export { pushContract } from "./contracts/push.contract.js";
83
77
  export { safetyContract } from "./contracts/safety.contract.js";
@@ -119,7 +113,6 @@ export * from "./history-state.js";
119
113
  export * from "./agent-runtimes.js";
120
114
  export * from "./provider-specs.js";
121
115
  export * from "./agent-catalog.js";
122
- export * from "./agent-run-model.js";
123
116
  export * from "./capability-env.js";
124
117
  export * from "./command-classes.js";
125
118
  export * from "./command-run.js";
@@ -137,10 +130,11 @@ export * from "./listener-protocol.js";
137
130
  export * from "./hostnames.js";
138
131
  export * from "./overlay-lint.js";
139
132
  export * from "./model-order.js";
133
+ export * from "./model-pins.js";
134
+ export * from "./model-roles.js";
140
135
  export * from "./plan-pools.js";
141
136
  export * from "./path-refs.js";
142
137
  export * from "./prompt-complexity.js";
143
- export * from "./quick-model.js";
144
138
  export * from "./output-fields.js";
145
139
  export * from "./approvals-execution.js";
146
140
  /* The wire shapes, one module per subject area, mirroring `contracts/` above: every request and response the
@@ -159,7 +153,8 @@ export * from "./schemas/capabilities.js";
159
153
  export * from "./schemas/ci.js";
160
154
  export * from "./schemas/claude-gate.js";
161
155
  export * from "./schemas/codebase-health.js";
162
- export * from "./schemas/computers.js";
156
+ export * from "./schemas/context.js";
157
+ export * from "./schemas/devices.js";
163
158
  export * from "./schemas/engines.js";
164
159
  export * from "./schemas/environment.js";
165
160
  export * from "./schemas/exit.js";
@@ -212,6 +207,7 @@ export * from "./workflow-faults.js";
212
207
  // browser's typed oRPC client (ContractRouterClient<typeof sandboxContract>). The wire paths it declares are
213
208
  // mounted at the sandbox root, so /health and /workspace/raw (plain Hono routes) sit alongside it.
214
209
  export const sandboxContract = {
210
+ accounts: accountsContract,
215
211
  activity: activityContract,
216
212
  agent: agentContract,
217
213
  agents: agentsContract,
@@ -220,8 +216,6 @@ export const sandboxContract = {
220
216
  capabilities: capabilitiesContract,
221
217
  chores: choresContract,
222
218
  ci: ciContract,
223
- claude: claudeContract,
224
- cursor: cursorContract,
225
219
  endpoints: endpointsContract,
226
220
  extensions: extensionsContract,
227
221
  personas: personasContract,
@@ -232,7 +226,6 @@ export const sandboxContract = {
232
226
  skills: skillsContract,
233
227
  intentic: intenticContract,
234
228
  git: gitContract,
235
- grok: grokContract,
236
229
  history: historyContract,
237
230
  workspace: workspaceContract,
238
231
  inventory: inventoryContract,
@@ -243,7 +236,6 @@ export const sandboxContract = {
243
236
  ports: portsContract,
244
237
  public: publicContract,
245
238
  prepush: prepushContract,
246
- keys: keysContract,
247
239
  providers: providersContract,
248
240
  push: pushContract,
249
241
  secrets: secretsContract,
@@ -146,7 +146,7 @@ const releaseTierRankOf = (family: string): number => lastRankOf(family, RELEASE
146
146
  *
147
147
  * That is not a small mis-sort. Thinking is the difference between a commit message that is in the box before
148
148
  * the user has finished reading the file list and one that arrives half a minute later, measured at ~2s versus
149
- * ~27s on the same model and the same diff (agent/one-shot.ts, which disables thinking for the rungs where a
149
+ * ~27s on the same model and the same diff (claude/claude-one-shot.ts, which disables thinking for the rungs where a
150
150
  * request parameter can). For a routed rung there is no such parameter: the id IS the setting, so this ranking
151
151
  * is the only place the choice can be made.
152
152
  *
@@ -1,23 +1,39 @@
1
1
  import { expect, test } from "vitest";
2
- import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
3
-
4
- /* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once:
5
- * the daemon walks it, the browser names its head in the settings row, so what these tests pin is that a
6
- * sandbox's connections alone decide it, with no stored id to go stale, and that there is always a rung
7
- * underneath the first one whenever the sandbox has another account to reach for. */
2
+ import { type ModelSource, modelPinKey, parsePinned, resolveRoleModels } from "./model-pins.js";
3
+ import type { ModelPin } from "./schemas/agent.js";
4
+
5
+ /* Which models a job spends, and in which order. The rule answers two surfaces at once: the daemon walks it,
6
+ * the browser names its head in that job's settings row, so what these tests pin is that a sandbox's
7
+ * connections alone decide it, with no stored id to go stale, and that there is always a rung underneath the
8
+ * first one whenever the sandbox has another account to reach for.
9
+ *
10
+ * `commit-message` and `pipeline-fix` stand in for the two KINDS of role here (model-roles.ts): the first is a
11
+ * one-shot, whose empty list derives the Auto ladder, and the second is a whole session, whose empty list
12
+ * resolves to nothing so the caller's own composer pick answers. Everything else about the two is identical,
13
+ * which is why one resolver serves both. */
14
+
15
+ const HELPER = `commit-message` as const;
16
+ const RUN = `pipeline-fix` as const;
17
+
18
+ // A pin as the settings rows store one. The tests are about ORDER, so most of them name only the pair; the
19
+ // knobs an entry can carry ride through untouched and are asserted where that matters.
20
+ const pin = (key: string): ModelPin => {
21
+ const at = key.indexOf(`:`);
22
+ return { provider: key.slice(0, at), model: key.slice(at + 1) };
23
+ };
8
24
 
9
25
  // Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
10
- const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
11
- const GOOGLE: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
12
- const CODEX: QuickModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
13
- const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`] };
26
+ const CLAUDE: ModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
27
+ const GOOGLE: ModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
28
+ const CODEX: ModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
29
+ const KIMI: ModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`] };
14
30
 
15
- const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
31
+ const offline = (source: ModelSource): ModelSource => ({ ...source, ready: false });
16
32
 
17
33
  // The model that answers when nothing goes wrong: the head of the chain, which is what most of what follows is
18
34
  // about and what every surface naming the spend up front reads.
19
- const head = (sources: readonly QuickModelSource[], pinned: readonly string[]): QuickModelChoice | undefined =>
20
- resolveQuickModels(sources, pinned)[0];
35
+ const head = (sources: readonly ModelSource[], pinned: readonly string[]): ModelPin | undefined =>
36
+ resolveRoleModels(sources, pinned.map(pin), HELPER)[0];
21
37
 
22
38
  test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
23
39
  expect(head([CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
@@ -32,14 +48,14 @@ test("spends the FREE channel over the subscription when both offer the same run
32
48
  test("puts tier ahead of cost: a free frontier model is still the wrong tool for a commit message", () => {
33
49
  // Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
34
50
  // which is the exact outcome the feature exists to avoid.
35
- const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
51
+ const proOnly: ModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
36
52
 
37
53
  expect(head([CLAUDE, proOnly], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
38
54
  });
39
55
 
40
56
  test("uses stable provider order when two subscriptions offer the same tier", () => {
41
- const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
42
- const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
57
+ const kimiCheap: ModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
58
+ const claudeCheap: ModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
43
59
 
44
60
  expect(head([kimiCheap, claudeCheap], [])?.provider).toBe(`claude`);
45
61
  });
@@ -48,7 +64,7 @@ test("answers the same thing however the connected providers happen to be listed
48
64
  // The daemon assembles these from live stores and the browser from its own refs; neither order is a fact.
49
65
  const answers = [head([CLAUDE, GOOGLE, CODEX], []), head([CODEX, CLAUDE, GOOGLE], []), head([GOOGLE, CODEX, CLAUDE], [])];
50
66
 
51
- expect(new Set(answers.map((answer) => quickModelKey(answer!))).size).toBe(1);
67
+ expect(new Set(answers.map((answer) => modelPinKey(answer!))).size).toBe(1);
52
68
  });
53
69
 
54
70
  test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
@@ -66,10 +82,38 @@ test("falls back to Auto when the pinned provider is no longer connected", () =>
66
82
  });
67
83
  });
68
84
 
69
- test("ignores a malformed pin instead of running an empty model id", () => {
70
- for (const pinned of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
71
- expect(head([CLAUDE], [pinned])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
85
+ /* A MALFORMED KEY IS REFUSED WHERE KEYS STILL EXIST. A role's list holds PINS, whose two halves are separate
86
+ * fields the schema requires (ModelPinSchema), so "claude with an empty model" is no longer a shape the
87
+ * resolver can be handed — it is rejected at the settings boundary instead. What still travels as a key is
88
+ * `autoFastModels`, so the rule lives with the parser that reads one, and this is where it is pinned. */
89
+ test("refuses a malformed key rather than reading half a pin out of it", () => {
90
+ for (const key of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
91
+ expect(parsePinned(key)).toBeUndefined();
72
92
  }
93
+ expect(parsePinned(`claude:claude-haiku-4-5`)).toEqual({ provider: `claude`, model: `claude-haiku-4-5` });
94
+ });
95
+
96
+ /* THE TWO KINDS OF ROLE DIFFER ON EXACTLY ONE THING: what an empty list means. It is the whole reason the
97
+ * resolver takes a role rather than a boolean, and the reason the two used to be separate files. */
98
+ test("derives a ladder for an unpinned one-shot and nothing at all for an unpinned session", () => {
99
+ // A commit message exists to stay off the frontier tier, so working it out from what is connected is a good
100
+ // answer and gets better as accounts are added.
101
+ expect(resolveRoleModels([CLAUDE, GOOGLE], [], HELPER)).toEqual([
102
+ { provider: `gemini`, model: `gemini-3-flash-lite` },
103
+ { provider: `claude`, model: `claude-haiku-4-5-20251001` },
104
+ ]);
105
+ // A pipeline fix is a whole session billed whole, and nothing here can judge what one is worth — so the
106
+ // caller's own floor (the owner's composer pick) answers instead of a ladder this file invented.
107
+ expect(resolveRoleModels([CLAUDE, GOOGLE], [], RUN)).toEqual([]);
108
+ });
109
+
110
+ test("carries an entry's run settings through untouched, on either kind of role", () => {
111
+ // The resolver picks WHICH entry; how that entry runs is the entry's own business and rides along whole,
112
+ // because the turn (or the one-shot) is composed from all of it.
113
+ const configured: ModelPin = { provider: `claude`, model: `claude-opus-5`, effort: `max`, thinking: true, harness: `claude-code` };
114
+
115
+ expect(resolveRoleModels([CLAUDE], [configured], RUN)).toEqual([configured]);
116
+ expect(resolveRoleModels([CLAUDE], [configured], HELPER)).toEqual([configured]);
73
117
  });
74
118
 
75
119
  test("serves the newest of a catalog that publishes no cheap tier at all", () => {
@@ -79,12 +123,12 @@ test("serves the newest of a catalog that publishes no cheap tier at all", () =>
79
123
 
80
124
  test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
81
125
  expect(head([offline(CLAUDE), offline(GOOGLE)], [])).toBeUndefined();
82
- expect(resolveQuickModels([offline(CLAUDE), offline(GOOGLE)], [])).toEqual([]);
126
+ expect(resolveRoleModels([offline(CLAUDE), offline(GOOGLE)], [], HELPER)).toEqual([]);
83
127
  expect(head([], [`claude:claude-haiku-4-5`])).toBeUndefined();
84
128
  });
85
129
 
86
130
  test("skips a connected provider whose catalog has not loaded yet", () => {
87
- const unloaded: QuickModelSource = { provider: `grok`, ready: true, models: [] };
131
+ const unloaded: ModelSource = { provider: `grok`, ready: true, models: [] };
88
132
 
89
133
  expect(head([unloaded, CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
90
134
  expect(head([unloaded], [])).toBeUndefined();
@@ -95,7 +139,7 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
95
139
  * point of the list is that the click still lands on the next rung down. */
96
140
 
97
141
  test("keeps the pinned models in the order they were written", () => {
98
- expect(resolveQuickModels([CLAUDE, GOOGLE, CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5-20251001`])).toEqual([
142
+ expect(resolveRoleModels([CLAUDE, GOOGLE, CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5-20251001`].map(pin), HELPER)).toEqual([
99
143
  { provider: `codex`, model: `gpt-5.6` },
100
144
  { provider: `gemini`, model: `gemini-3-flash` },
101
145
  { provider: `claude`, model: `claude-haiku-4-5-20251001` },
@@ -103,7 +147,7 @@ test("keeps the pinned models in the order they were written", () => {
103
147
  });
104
148
 
105
149
  test("drops a pin whose provider went away and keeps the rest of the order intact", () => {
106
- expect(resolveQuickModels([CLAUDE, offline(GOOGLE), CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5`])).toEqual([
150
+ expect(resolveRoleModels([CLAUDE, offline(GOOGLE), CODEX], [`codex:gpt-5.6`, `gemini:gemini-3-flash`, `claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([
107
151
  { provider: `codex`, model: `gpt-5.6` },
108
152
  { provider: `claude`, model: `claude-haiku-4-5` },
109
153
  ]);
@@ -112,18 +156,18 @@ test("drops a pin whose provider went away and keeps the rest of the order intac
112
156
  test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
113
157
  // Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
114
158
  // spend: a pin exists precisely to keep a helper off the accounts it does not name.
115
- expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
159
+ expect(resolveRoleModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
116
160
  });
117
161
 
118
162
  test("names each model once, however many times the list repeats it", () => {
119
163
  // The list is edited by hand; a duplicate would spend a second attempt proving the same account is out.
120
- expect(resolveQuickModels([CLAUDE], [`claude:claude-haiku-4-5`, `claude:claude-haiku-4-5`])).toEqual([
164
+ expect(resolveRoleModels([CLAUDE], [`claude:claude-haiku-4-5`, `claude:claude-haiku-4-5`].map(pin), HELPER)).toEqual([
121
165
  { provider: `claude`, model: `claude-haiku-4-5` },
122
166
  ]);
123
167
  });
124
168
 
125
169
  test("Auto is a ladder too, every connected provider's cheap rung, best first", () => {
126
- expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
170
+ expect(resolveRoleModels([CLAUDE, GOOGLE, KIMI], [], HELPER)).toEqual([
127
171
  { provider: `gemini`, model: `gemini-3-flash-lite` },
128
172
  { provider: `claude`, model: `claude-haiku-4-5-20251001` },
129
173
  { provider: `kimi`, model: `kimi-k3` },
@@ -133,14 +177,14 @@ test("Auto is a ladder too, every connected provider's cheap rung, best first",
133
177
  /* A MODEL ENDPOINT the user configured is a provider like any other here, and the reason it has to be is the
134
178
  * settings row: its options are built from the same picker catalog, so a pin naming one that this resolver
135
179
  * dropped would print one model's name in the settings row and spend a different account entirely. */
136
- const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
180
+ const OLLAMA: ModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
137
181
 
138
182
  test("honours a pin on a configured endpoint: the whole id, not the half before its slash", () => {
139
183
  expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
140
184
  // And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
141
185
  // itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
142
186
  // as "endpoint" and the model as "ollama:qwen3-coder": a pin that silently resolves to nothing.
143
- expect(quickModelKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
187
+ expect(modelPinKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
144
188
  });
145
189
 
146
190
  test("leaves Auto to the providers whose price is known, rather than reaching for someone's own server", () => {
@@ -0,0 +1,183 @@
1
+ import { accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js";
2
+ import { ACCESS_COST } from "./provider-specs.js";
3
+ import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
4
+ import { type ModelRole, modelRole } from "./model-roles.js";
5
+ import type { AgentProvider, ModelPin } from "./schemas/agent.js";
6
+
7
+ /* WHICH MODELS A ROLE MAY RUN, IN THE ORDER TO TRY THEM. One resolver over every list in
8
+ * settings.modelRoles, and the browser and the daemon both read it.
9
+ *
10
+ * IT IS AN ORDER, NOT A MODEL, and that is the shape of every list this file answers for. A single pick is a
11
+ * single point of failure: the account it names spends its allowance on the chat all morning, and the role
12
+ * fails on a limit for the rest of the day while three other connected providers sit idle. So a setting is a
13
+ * LIST read top to bottom, this hands back the whole ladder, and the caller walks it until one answers. Nothing
14
+ * here decides WHICH failures are worth stepping over — only the runner has made the call and seen it fail —
15
+ * this side says what the running order is.
16
+ *
17
+ * THE RULE LIVES IN THE CONTRACT because both sides need the same answer for different jobs: the daemon runs
18
+ * the model, and the browser has to NAME it, in the settings row's "Auto: …" line, before anything has run. Two
19
+ * implementations would drift precisely where it matters most, since a row promising Haiku while the daemon
20
+ * bills Opus is worse than no row at all.
21
+ *
22
+ * WHAT AN EMPTY LIST MEANS IS THE ROLE'S OWN ANSWER (model-roles.ts): a `helper` role derives the Auto ladder
23
+ * from whatever is connected, a `run` role resolves to nothing and lets the caller's floor answer. That fork
24
+ * used to be two near-identical files; it is one line here because it was always one difference. */
25
+
26
+ /* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
27
+ *
28
+ * ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
29
+ * own model, so there is no rung to point it at. `endpoint/<id>` providers ARE, and have to be: their models
30
+ * appear in the same picker the settings rows build their options from, so a pin naming one has to hold rather
31
+ * than fall silently back to Auto and spend an account the user was deliberately steering away from. */
32
+ export interface ModelSource {
33
+ // AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
34
+ // ranking degrades gracefully for one, costOf falls to the metered rung and an id with no tier word is
35
+ // UNRANKED, which is genuine last place, so an endpoint effectively only wins Auto when nothing else is
36
+ // connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
37
+ // costs is not a fact this repo can know, so it is not one Auto should be asserting.
38
+ readonly provider: AgentProvider;
39
+ // The same connection predicate every other surface gates on (access.ts web-side, the daemon's own account
40
+ // stores daemon-side). A catalog is never empty by construction, so "has rows" says nothing about "can send".
41
+ readonly ready: boolean;
42
+ readonly models: readonly string[];
43
+ }
44
+
45
+ export interface ModelChoice {
46
+ readonly provider: AgentProvider;
47
+ readonly model: string;
48
+ }
49
+
50
+ // A (provider, model) pair on the wire: `${provider}:${modelId}`, the same key shape the model picker mints for
51
+ // its entries (PickerEntry.key). Every role list stores PINS rather than these keys — an entry says how it runs
52
+ // as well as which model it is — but the key is still how two entries are compared, how a role list dedupes,
53
+ // and how `autoFastModels` (which pins no knobs, see its own note in settings.ts) is stored.
54
+ export const modelPinKey = (choice: ModelChoice): string => `${choice.provider}:${choice.model}`;
55
+
56
+ /* Split on the FIRST colon only: a provider id never contains one and a model id might. Exported because
57
+ * `autoFastModels` stores these keys, and because a session composed from a pin travels as one
58
+ * (composeSession). */
59
+ export const parsePinned = (pinned: string): ModelChoice | undefined => {
60
+ const separator = pinned.indexOf(`:`);
61
+ if (separator <= 0 || separator === pinned.length - 1) {
62
+ return undefined;
63
+ }
64
+ return { provider: pinned.slice(0, separator), model: pinned.slice(separator + 1) };
65
+ };
66
+
67
+ /* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
68
+ * has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
69
+ * defensive branch). Beside parsePinned because the two are always wanted together, by any surface that has to
70
+ * name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
71
+ * share no other code with each other. */
72
+ export const pinnedModelLabel = (choice: ModelChoice): string =>
73
+ modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
74
+
75
+ // The cheapest row a provider publishes, its whole catalog read from the cheap end. Undefined for a catalog
76
+ // that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
77
+ // asked it.
78
+ const cheapestOf = (source: ModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
79
+
80
+ // Where a provider's cheapest row sits on the shared tier scale, and therefore how well it answers the question
81
+ // Auto asks. UNRANKED (-1) is a genuine last place: it means the id carries no tier word we know, so the row is
82
+ // the provider's base line rather than its budget one.
83
+ const tierOf = (model: string): number => tierRankOf(familyOf(model));
84
+
85
+ // PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read, the property
86
+ // compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
87
+ // list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
88
+ const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
89
+
90
+ /* WHAT AN ENDPOINT COSTS, one rung past every provider's, and the reason it is a number here rather than a
91
+ * member of AccessKind. That axis describes the providers this repo ships, and every one of them is unlocked by
92
+ * signing in to something the user already holds, so none of them is metered per call. An endpoint is the
93
+ * opposite: whatever gateway somebody pointed us at, whose bill this repo cannot see. Reading it as dearer than
94
+ * anything on the table is the conservative answer, and it is what keeps Auto from reaching for a paid gateway
95
+ * on its own initiative. */
96
+ const METERED_COST = Math.max(...Object.values(ACCESS_COST)) + 1;
97
+
98
+ // How much a call on this provider costs at the margin. Every native provider declares an access kind; an
99
+ // endpoint declares none, and takes the metered rung above.
100
+ const costOf = (provider: AgentProvider): number => {
101
+ const access = accessFor(provider);
102
+ return access === undefined ? METERED_COST : ACCESS_COST[access.kind];
103
+ };
104
+
105
+ /* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner. The floor under
106
+ * every `helper` role, and under nothing else.
107
+ *
108
+ * Ranked on TIER FIRST, then cost. That order is the point: a helper's Auto exists to not be the frontier
109
+ * model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
110
+ * Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
111
+ * is not paying per token for, and against the one they are.
112
+ *
113
+ * NOTE WHAT AUTO IS AND IS NOT AN ARGUMENT FOR. It is the answer for an owner who has said nothing, not a claim
114
+ * that cheap is right: an owner who pins Opus to commit messages is not being talked out of it, which is the
115
+ * whole reason these lists are per role. Auto is what a row says while it is empty.
116
+ *
117
+ * The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
118
+ * SECOND answer, and a sandbox with three accounts connected should not lose its commit messages for six hours
119
+ * because one of them is spent. */
120
+ export const autoLadder = (sources: readonly ModelSource[]): readonly ModelPin[] =>
121
+ sources
122
+ .filter((source) => source.ready)
123
+ .flatMap((source) => {
124
+ const model = cheapestOf(source);
125
+ return model === undefined ? [] : [{ provider: source.provider, model }];
126
+ })
127
+ .toSorted(
128
+ (left, right) =>
129
+ tierOf(right.model) - tierOf(left.model) ||
130
+ costOf(left.provider) - costOf(right.provider) ||
131
+ providerOrder(left.provider) - providerOrder(right.provider),
132
+ );
133
+
134
+ /* WHICH MODELS THIS ROLE MAY RUN, IN THE ORDER TO TRY THEM, given what this sandbox has connected.
135
+ * `pinned` is the stored setting: settings.modelRoles[role], an ordered list of pins, empty for the role's own
136
+ * floor.
137
+ *
138
+ * A pin only holds while its provider is READY: an account the user disconnected would otherwise sit at the
139
+ * head of the chain failing on a credential error, when the sandbox can plainly still answer. Dropping it is
140
+ * the same move the composer already makes when a live catalog stops offering the selected model. It stays on
141
+ * SCREEN, greyed — the settings row renders the stored list, not this one — because a setting that vanished
142
+ * from view would look like the app had eaten it.
143
+ *
144
+ * THE PINNED LIST IS THE WHOLE ANSWER whenever any of it survives that filter. The floor is NOT appended
145
+ * underneath, and that is deliberate: a user who writes down three models has said which accounts this job may
146
+ * spend, and quietly reaching for a fourth when all three are out is exactly the "spend an account they were
147
+ * steering away from" failure a pin exists to prevent. When NONE of the pins is connected any more the list has
148
+ * stopped saying anything about this sandbox, so the floor takes over rather than leaving a dead button.
149
+ *
150
+ * THE WHOLE PIN SURVIVES, not the pair inside it: an entry's effort, thinking, speed and harness are what the
151
+ * work is composed from, so a resolver handing back a bare (provider, model) would silently run the head of the
152
+ * list at the provider's defaults. Nothing here reads or judges those fields, which is the point of carrying
153
+ * them whole.
154
+ *
155
+ * Empty out means the role has nothing it can reach. For a `helper` that is a sandbox with nothing connected at
156
+ * all, and the caller renders a control that says so rather than a live button that fails on click; for a `run`
157
+ * it is the ordinary state of an unpinned role, and the caller's own floor answers. */
158
+ export const resolveRoleModels = (sources: readonly ModelSource[], pinned: readonly ModelPin[], role: ModelRole): readonly ModelPin[] => {
159
+ const ready = new Set(sources.filter((source) => source.ready).map((source) => source.provider));
160
+ // Taken verbatim, unvalidated against the catalog on purpose: the picker offers a custom-id escape hatch for
161
+ // a model a catalog hasn't caught up with, and second-guessing the user's own id here would silently run a
162
+ // different model than the settings row names.
163
+ const requested = pinned.filter((pin) => ready.has(pin.provider));
164
+ /* The same model twice would spend two attempts proving one account is out — a real state, since the list is
165
+ * hand-edited and Auto's ladder can rank a provider the user has also pinned.
166
+ *
167
+ * THE FIRST OF A PAIR WINS, WHOLE. Two entries can name one model and differ in their knobs (the same Sonnet
168
+ * at Max and again at Low, written while reordering the list), and the one the user reads first is the one
169
+ * they meant; keeping the earlier position with the later entry's effort would run a tier that appears
170
+ * nowhere the pin does. */
171
+ const chain: ModelPin[] = [];
172
+ for (const pin of requested) {
173
+ if (!chain.some((held) => modelPinKey(held) === modelPinKey(pin))) {
174
+ chain.push(pin);
175
+ }
176
+ }
177
+ if (chain.length > 0) {
178
+ return chain;
179
+ }
180
+ // The floor, which the role declares. An id outside the table has no floor to fall to, and answering with
181
+ // the cheapest connected model for it would be this file inventing a job.
182
+ return modelRole(role)?.kind === `helper` ? autoLadder(sources) : [];
183
+ };