@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,11 +2,12 @@ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { z } from "zod";
3
3
  import { SessionTranscriptSchema, SystemEventSchema } from "../events.js";
4
4
  import {
5
- MachineCommandInputSchema,
6
- MachineCommandResultSchema,
7
- MachineFlowLineSchema,
8
- MachineSandboxFlowInputSchema,
9
- } from "../schemas/computers.js";
5
+ DeviceAgentFlowInputSchema,
6
+ DeviceCommandInputSchema,
7
+ DeviceCommandResultSchema,
8
+ DeviceFlowLineSchema,
9
+ DeviceSandboxFlowInputSchema,
10
+ } from "../schemas/devices.js";
10
11
  import { PresenceReportSchema } from "../schemas/logs.js";
11
12
  import { OkSchema } from "../schemas/shared.js";
12
13
  import { DaemonSessionSchema, InfoSchema, ManifestProblemsSchema } from "../schemas/system.js";
@@ -160,52 +161,70 @@ export const systemContract = {
160
161
  .route({
161
162
  method: "GET",
162
163
  path: "/system/subagents",
163
- summary: "Helpers the agents have started",
164
+ summary: "Subagents the agents have started",
164
165
  description:
165
- "Every helper agent this sandbox's conversations have delegated work to, whichever tool started it, with what each one is doing.",
166
+ "Every subagent and child agent this sandbox's conversations have delegated work to, whichever tool started it, with what each one is doing.",
166
167
  })
167
168
  .output(SubagentsListSchema),
168
169
  subagentTranscript: oc
169
170
  .route({
170
171
  method: "GET",
171
172
  path: "/system/subagents/{id}/transcript",
172
- summary: "A helper agent's record",
173
+ summary: "A subagent's record",
173
174
  description:
174
- "The full record of one delegated helper, in the same shape as any other conversation. It comes live from the parent turn while the helper works, and from stored history once it has finished.",
175
+ "The full record of one delegated subagent, in the same shape as any other conversation. It comes live from the parent turn while it works, and from stored history once it has finished.",
175
176
  })
176
177
  .input(SubagentIdParamSchema)
177
178
  .output(SessionTranscriptSchema),
178
- /* Start, stop, restart, update, rebuild, roll back or remove a sandbox on one of the user's own computers,
179
- * the Computers view's buttons, relayed to the machine over the socket it holds open to us.
179
+ /* Start, stop, restart, update, rebuild, roll back or remove a sandbox on one of the user's own devices,
180
+ * the Devices view's buttons, relayed to the machine over the socket it holds open to us.
180
181
  *
181
182
  * Streamed because the slowest of these takes minutes, and it is the same stream whatever the op: one door
182
183
  * for one decision, so the view has one shape to render rather than one per duration. The daemon adds no
183
184
  * judgement, the machine enforces its own switches and its refusal arrives as the terminal `error` line,
184
185
  * in its own words, naming the control to flip. */
185
- manageMachineSandbox: oc
186
+ manageDeviceSandbox: oc
186
187
  .route({
187
188
  method: "POST",
188
- path: "/system/computers/{id}/sandboxes/{slug}",
189
- summary: "Drive a sandbox on one of your own computers",
189
+ path: "/system/devices/{id}/sandboxes/{slug}",
190
+ summary: "Drive a sandbox on one of your own devices",
190
191
  description:
191
- "Start, stop, restart, update, rebuild, roll back or remove a sandbox running on a machine you own, relayed over the connection that machine holds open. The answer is a stream because the slowest of these takes minutes, and it is the same stream whichever you ask for. The daemon adds no opinion: the machine enforces its own permissions and a refusal arrives as the last line, in the machine's words, naming the switch to flip.",
192
+ "Start, stop, restart, update, rebuild, roll back, reshape (its memory and CPU caps, privileged, GPU) or remove a sandbox running on a machine you own, relayed over the connection that machine holds open. The answer is a stream because the slowest of these takes minutes, and it is the same stream whichever you ask for. The daemon adds no opinion: the machine enforces its own permissions and a refusal arrives as the last line, in the machine's words, naming the switch to flip.",
192
193
  })
193
- .input(MachineSandboxFlowInputSchema)
194
- .output(eventIterator(MachineFlowLineSchema)),
195
- /* Run one of this product's own CLI actions on a connected computer, from a button rather than through an
194
+ .input(DeviceSandboxFlowInputSchema)
195
+ .output(eventIterator(DeviceFlowLineSchema)),
196
+ /* Run one of this product's own CLI actions on a connected device, from a button rather than through an
196
197
  * agent. A closed set of names, and the daemon builds the command line from the name (see the schema): the
197
198
  * browser never sends one, because the socket underneath also carries `run_command`.
198
199
  *
199
200
  * Not a stream, unlike the sandbox ops beside it: these are seconds-long CLI calls whose whole answer is the
200
201
  * sentence they print at the end, and a stream for that is a shape with nothing to put in it. */
201
- runMachineCommand: oc
202
+ runDeviceCommand: oc
202
203
  .route({
203
204
  method: "POST",
204
- path: "/system/computers/{id}/commands/{command}",
205
- summary: "Run one of your computer's own CLI actions",
205
+ path: "/system/devices/{id}/commands/{command}",
206
+ summary: "Run one of your device's own CLI actions",
206
207
  description:
207
- "Performs a named action on a machine you own by running its own intentic-machine command there — turning that computer's port mirroring off, say — over the connection it holds open. The set of actions is fixed and the command line is built here from the name, never sent by the caller. The machine enforces its own permissions and a refusal comes back as its own sentence, naming the switch to flip.",
208
+ "Performs a named action on a machine you own by running its own intentic-machine command there — turning that device's port mirroring off, say — over the connection it holds open. The set of actions is fixed and the command line is built here from the name, never sent by the caller. The machine enforces its own permissions and a refusal comes back as its own sentence, naming the switch to flip.",
208
209
  })
209
- .input(MachineCommandInputSchema)
210
- .output(MachineCommandResultSchema),
210
+ .input(DeviceCommandInputSchema)
211
+ .output(DeviceCommandResultSchema),
212
+ /* Update or restart the agent on one of the user's own devices — the remedy the Devices view used to print
213
+ * as a command to go and type, on the view built to replace that terminal.
214
+ *
215
+ * Streamed like the sandbox ops, and for a reason of its own: this is the one call whose transport the work
216
+ * destroys. Both ops stop the resident process holding the socket, so the stream ends WITHOUT a terminal
217
+ * frame in the ordinary, successful case, and callers must read that as "it started" rather than as a
218
+ * failure (DeviceAgentFlowSchema has the whole argument). The confirmation is the device's agent version
219
+ * moving on the next read of the fleet. */
220
+ runDeviceAgentFlow: oc
221
+ .route({
222
+ method: "POST",
223
+ path: "/system/devices/{id}/agent/{op}",
224
+ summary: "Update or restart the agent on one of your own devices",
225
+ description:
226
+ "Updates a machine you own to the current intentic-machine agent, or restarts the loop it is running, over the connection that machine holds open. The answer is a stream of the run's own output — and it normally stops mid-run, because the agent's loop is what carries this connection: the work is detached from it first, so it finishes regardless, and the device's reported version is what confirms it. Takes the machine's \"Run commands\" permission, the same one a command typed there would.",
227
+ })
228
+ .input(DeviceAgentFlowInputSchema)
229
+ .output(eventIterator(DeviceFlowLineSchema)),
211
230
  };
@@ -1,5 +1,6 @@
1
1
  import { oc } from "@orpc/contract";
2
2
  import { z } from "zod";
3
+ import { LimitResetClaimSchema, LimitResetStatusSchema } from "../schemas/plan-limits.js";
3
4
  import { DayWindowQuerySchema, UsageRollupSchema } from "../schemas/usage.js";
4
5
 
5
6
  // The one control over the headroom readings a person has: measure again, now, every connection. `force`
@@ -38,4 +39,34 @@ export const usageContract = {
38
39
  })
39
40
  .input(RefreshPlanLimitsSchema)
40
41
  .output(z.object({ ok: z.literal(true) })),
42
+ /* THE WAY PAST A SPENT SESSION WINDOW THAT ISN'T WAITING (LimitResetStatusSchema has what it costs).
43
+ *
44
+ * ASKED, NOT POLLED, and that is the whole reason it is a route of its own rather than another field on the
45
+ * account list. The provider only evaluates it when told the account is at the wall, so asking is a claim
46
+ * about the account's state; asking on every headroom sweep, for every connection, would be that claim made
47
+ * continuously and falsely. It is asked at the one moment it is true — a refused turn, with its strip on
48
+ * screen — and the answer is worth nothing a moment later, so nothing caches it.
49
+ *
50
+ * Answers `available: false` rather than failing for an account with no such mechanism, so a caller can ask
51
+ * about any account it holds without first knowing which provider grants one. */
52
+ limitReset: oc
53
+ .route({
54
+ method: "GET",
55
+ path: "/usage/limit-reset/{account}",
56
+ summary: "Whether this account's session window can be reopened now",
57
+ description:
58
+ "Asks the provider whether it will reopen this account's spent session window immediately, which some plans grant once a week. Only worth asking about an account that has actually been refused: the answer is the provider's judgement at this moment, it is not cached, and an account with no such grant answers plainly that it has none.",
59
+ })
60
+ .input(z.object({ account: z.string().min(1).describe("Which account.") }))
61
+ .output(LimitResetStatusSchema),
62
+ claimLimitReset: oc
63
+ .route({
64
+ method: "POST",
65
+ path: "/usage/limit-reset/{account}/claim",
66
+ summary: "Reopen this account's session window now",
67
+ description:
68
+ "Spends one of the account's weekly resets to reopen its session window immediately. The weekly allowance is untouched and still binds. Answers with what the provider actually did: only `reset` changed anything, and it is the cue to send the refused turn again.",
69
+ })
70
+ .input(z.object({ account: z.string().min(1).describe("Which account.") }))
71
+ .output(LimitResetClaimSchema),
41
72
  };
@@ -5,14 +5,14 @@ import { ForticlientImportInputSchema, ForticlientImportSchema, VpnConnectInputS
5
5
 
6
6
  // The live VPN surface. A VPN is ADDED as a `vpn` capability (credentials, autoConnect, capabilities.contract);
7
7
  // it is DIALLED here. The split is deliberate: connecting is a runtime operation that both the operator (the
8
- // Sandbox ▸ Status card) and the agent (`vpn` on its PATH, which calls these very routes) perform many times
8
+ // VPN capability card) and the agent (`vpn` on its PATH, which calls these very routes) perform many times
9
9
  // over one stored connection, so it cannot be a capability re-add, and its result is richer than a
10
10
  // CapabilityStatus, which is why `list` returns VpnLinks instead of {state, detail}.
11
11
  //
12
12
  // Every route reads tunnel state back from the OS rather than from daemon memory, so the agent dropping a
13
13
  // tunnel from a shell and the UI dropping it are the same event, and a daemon restart observes the truth.
14
14
  export const vpnContract = {
15
- // Every configured VPN with its live link state, the Status card, the rail indicator, and `vpn list`.
15
+ // Every configured VPN with its live link state, the VPN card, the rail indicator, and `vpn list`.
16
16
  list: oc
17
17
  .route({
18
18
  method: "GET",
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { documentOf, documentTitle, isDocumentPath, isPlanDocumentPath, PLAN_DOCUMENTS_DIR } from "./documents.js";
2
+ import { documentOf, documentTitle, isDocumentPath, isPlanDocumentPath } from "./documents.js";
3
+ import { PLAN_DOCUMENTS_DIR } from "./workspace-state.js";
3
4
 
4
5
  const write = (path: string, newText: string, extra: { oldText?: string; truncated?: boolean } = {}) => [
5
6
  { type: "diff" as const, path, newText, ...extra },
package/src/documents.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { STATE_DIR } from "@intentic/constants";
2
1
  import type { CardDocument, ToolCallContent } from "./events.js";
3
2
  import { planParts } from "./title.js";
3
+ import { PLAN_DOCUMENTS_DIR } from "./workspace-state.js";
4
4
 
5
5
  /* A DOCUMENT A TURN WROTE FOR A PERSON TO READ, told apart from the files it changed for the machine.
6
6
  *
@@ -18,14 +18,6 @@ import { planParts } from "./title.js";
18
18
  * prose it is a mid-sentence slice with no heading, and the DIFF is what a reader wants from an edit anyway. So
19
19
  * a Write of a markdown file is a document, and an edit to one stays a diff. */
20
20
 
21
- /* WHERE THE CLI'S PLAN FILES LAND, workspace-relative.
22
- *
23
- * `~/.claude/plans` is a symlink onto this directory (sessions/session-store.ts links the SDK's conversation
24
- * state onto the workspace volume), which makes a plan file the one document with a HARNESS-OWNED address: no
25
- * guessing whether prose is a plan, no threshold on length, the path says so. That is why plan documents get
26
- * their own treatment while everything else rides the general markdown rule above. */
27
- export const PLAN_DOCUMENTS_DIR = `${STATE_DIR}/records/sessions/claude/plans`;
28
-
29
21
  // Prose, by extension. Deliberately short: a document is something the chat can RENDER, and markdown is what
30
22
  // the chat's prose pipeline speaks. A `.txt` report would render as an unstyled wall and reads better as the
31
23
  // plain text box it already gets.
@@ -36,8 +28,12 @@ export const isDocumentPath = (path: string): boolean => {
36
28
  return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
37
29
  };
38
30
 
39
- // Whether a path is one of the CLI's plan files. Matched on the directory rather than on the name, which is a
40
- // mint-fresh three-word phrase (`map-of-this-wiggly-spring.md`) carrying no signal at all.
31
+ /* Whether a path is one of the CLI's plan files, which is what earns a document the plan treatment here rather
32
+ * than the general markdown rule above: the harness owns that address, so there is no guessing whether prose is
33
+ * a plan and no threshold on length. Matched on the DIRECTORY rather than on the name, which is a mint-fresh
34
+ * three-word phrase (`map-of-this-wiggly-spring.md`) carrying no signal at all.
35
+ *
36
+ * The directory itself is declared in workspace-state.ts, beside the lock it is the one exception to. */
41
37
  export const isPlanDocumentPath = (path: string): boolean => path.startsWith(`${PLAN_DOCUMENTS_DIR}/`);
42
38
 
43
39
  /* What a document is CALLED. Its opening heading when it has one, which is the line the author wrote to name
@@ -0,0 +1,68 @@
1
+ import { webcrypto } from "node:crypto";
2
+ import { beforeAll, expect, test, vi } from "vitest";
3
+ import { EmbedError, embedFailure, embedUrl, fetchEmbedJson, solveProofOfWork } from "./embed.js";
4
+
5
+ /* The wire every embed speaks before it speaks its own, against a fake fetch and the real WebCrypto. */
6
+
7
+ // jsdom ships no SubtleCrypto; node's is the same WebCrypto the browser exposes, so the solver is exercised
8
+ // against the real digest rather than a stub.
9
+ beforeAll(() => {
10
+ Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
11
+ });
12
+
13
+ // The daemon verifies exactly this: hash the whole answer string and count the leading zero bits.
14
+ const leadingZeroBits = async (answer: string): Promise<number> => {
15
+ const digest = new Uint8Array(await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(answer)));
16
+ let bits = 0;
17
+ for (const byte of digest) {
18
+ if (byte !== 0) {
19
+ return bits + Math.clz32(byte) - 24;
20
+ }
21
+ bits += 8;
22
+ }
23
+ return bits;
24
+ };
25
+
26
+ test("a door's route is spelled the daemon's way, with the automation id encoded", () => {
27
+ expect(embedUrl({ base: "https://sandbox.example", automationId: "a b" }, "webchat", "config")).toBe("https://sandbox.example/webchat/a%20b/config");
28
+ });
29
+
30
+ test("a refusal carries the server's own sentence and status, and a bodyless one says what it can", async () => {
31
+ const said = await embedFailure(new Response(JSON.stringify({ error: "origin not allowed" }), { status: 403 }));
32
+ expect(said).toBeInstanceOf(EmbedError);
33
+ expect(said).toMatchObject({ message: "origin not allowed", status: 403 });
34
+ expect(await embedFailure(new Response("nope", { status: 502 }))).toMatchObject({ message: "request failed (502)", status: 502 });
35
+ });
36
+
37
+ test("a JSON fetch answers the body or throws the refusal", async () => {
38
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
39
+ expect(await fetchEmbedJson<{ ok: boolean }>("https://x/y")).toEqual({ ok: true });
40
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "rate limited" }), { status: 429 })));
41
+ await expect(fetchEmbedJson("https://x/y")).rejects.toMatchObject({ message: "rate limited", status: 429 });
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ /* The one test that pays for real work: 12 bits is ~900 awaited digests, which is what carries the solver past
46
+ * its 512-nonce batch and through the yield the page needs to keep painting. Every digest is a round trip to
47
+ * the platform's crypto, so what that costs is set by how loaded the machine is and never by this code: hence
48
+ * a budget that bounds a hang instead of the suite's 5s hang detector for in-memory work, which a busy runner
49
+ * beat. The other two prove things that need no work at all, and are cheap on purpose. */
50
+ test("the answer really clears the difficulty, and carries the salt back for the daemon to re-derive", async () => {
51
+ const answer = await solveProofOfWork({ salt: "abc123", difficulty: 12 }, "needs https");
52
+ expect(answer.startsWith("abc123:")).toBe(true);
53
+ expect(await leadingZeroBits(answer)).toBeGreaterThanOrEqual(12);
54
+ }, 20_000);
55
+
56
+ test("a different salt yields a different answer: a solution cannot be replayed across callers", async () => {
57
+ const [one, two] = await Promise.all([
58
+ solveProofOfWork({ salt: "salt-one", difficulty: 6 }, "needs https"),
59
+ solveProofOfWork({ salt: "salt-two", difficulty: 6 }, "needs https"),
60
+ ]);
61
+ expect(one).not.toBe(two);
62
+ });
63
+
64
+ test("an http:// page is told the truth, in the embed's own words, instead of hanging on a missing SubtleCrypto", async () => {
65
+ Object.defineProperty(globalThis, "crypto", { value: { randomUUID: webcrypto.randomUUID }, configurable: true });
66
+ await expect(solveProofOfWork({ salt: "abc", difficulty: 8 }, "This page must be served over HTTPS to start a chat.")).rejects.toThrow(/HTTPS to start a chat/);
67
+ Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
68
+ });
package/src/embed.ts ADDED
@@ -0,0 +1,164 @@
1
+ /* THE ZOD-FREE HALF OF AN EMBED'S WIRE: what every script a customer drops on their own page does before it
2
+ * does anything of its own. Two embeds exist, the Front Desk chat bubble (_sandbox/webchat-widget) and the bug
3
+ * reporter (_sandbox/issue-widget), and each used to carry its own copy of this: the same three requests
4
+ * against a public door, the same proof-of-work solver, the same localStorage-backed id, the same
5
+ * read-my-own-script-tag boot. The daemon's side of the same doors is one module too (automations/public-door.ts).
6
+ *
7
+ * THE RULE FOR WHAT MAY LIVE HERE: no imports, ever. This module is bundled INTO a page that belongs to someone
8
+ * else, so it must cost that page nothing it did not ask for; the contract's barrel would bring zod with it
9
+ * (webext-links.ts measured that at 1.1 MB for two strings). Types are declared here for the same reason. */
10
+
11
+ // Where an embed talks to: the daemon it came from, and the automation it is the public face of.
12
+ export interface EmbedEndpoint {
13
+ readonly base: string;
14
+ readonly automationId: string;
15
+ }
16
+
17
+ // What the server said when it refused. The daemon answers every refusal as {"error": "..."}, and that sentence
18
+ // is shown verbatim where a person can see it: "origin not allowed" tells a site owner exactly what to fix and
19
+ // anything invented here would not.
20
+ export class EmbedError extends Error {
21
+ constructor(
22
+ message: string,
23
+ readonly status: number,
24
+ ) {
25
+ super(message);
26
+ this.name = "EmbedError";
27
+ }
28
+ }
29
+
30
+ export const embedFailure = async (response: Response): Promise<EmbedError> => {
31
+ const body = (await response.json().catch(() => undefined)) as { error?: unknown } | undefined;
32
+ return new EmbedError(typeof body?.error === "string" ? body.error : `request failed (${response.status})`, response.status);
33
+ };
34
+
35
+ // One route of a public door: `<base>/<slug>/<automation>/<path>`, the daemon's own spelling.
36
+ export const embedUrl = ({ base, automationId }: EmbedEndpoint, slug: string, path: string): string =>
37
+ `${base}/${slug}/${encodeURIComponent(automationId)}/${path}`;
38
+
39
+ // One GET against a public door, answered as JSON or refused with the server's own sentence.
40
+ export const fetchEmbedJson = async <T>(url: string): Promise<T> => {
41
+ const response = await fetch(url);
42
+ if (!response.ok) {
43
+ throw await embedFailure(response);
44
+ }
45
+ return (await response.json()) as T;
46
+ };
47
+
48
+ /* ---- proof of work ----
49
+ *
50
+ * The daemon issues a challenge: find a nonce whose SHA-256 of `${salt}:${nonce}` begins with `difficulty` zero
51
+ * BITS. It costs the person a second or so and costs a script the same per identity it wants to burn, with no
52
+ * third-party account anywhere. The challenge is minted FOR one caller (the daemon signs the caller's own id
53
+ * into the salt), so a solution cannot be carried to another thread or another reporter; hence the id in the
54
+ * challenge request rather than a bare GET.
55
+ *
56
+ * Solved on the main thread in yielding batches rather than in a Worker: a Worker would have to come from a
57
+ * blob: URL, which a host page's Content-Security-Policy is entitled to forbid, and being unable to chat or send
58
+ * feedback because of the SITE's CSP is a worse failure than a busy second. */
59
+ export interface PowChallenge {
60
+ readonly salt: string;
61
+ readonly difficulty: number;
62
+ }
63
+
64
+ export const fetchEmbedChallenge = (endpoint: EmbedEndpoint, slug: string, param: string, id: string): Promise<PowChallenge> =>
65
+ fetchEmbedJson<PowChallenge>(`${embedUrl(endpoint, slug, "challenge")}?${param}=${encodeURIComponent(id)}`);
66
+
67
+ const BATCH = 512;
68
+
69
+ // Leading zero bits of a digest, up to `wanted`. Stops at the first non-zero byte, so a miss costs one byte.
70
+ const leadingZeroBits = (digest: Uint8Array, wanted: number): number => {
71
+ let bits = 0;
72
+ for (const byte of digest) {
73
+ if (byte !== 0) {
74
+ // Math.clz32 counts 32-bit leading zeros; the byte sits in the low 8, so 24 of them are structural.
75
+ return bits + Math.clz32(byte) - 24;
76
+ }
77
+ bits += 8;
78
+ if (bits >= wanted) {
79
+ return bits;
80
+ }
81
+ }
82
+ return bits;
83
+ };
84
+
85
+ /* Resolves to the ANSWER the daemon expects, `<salt>:<nonce>`, carrying back the salt it signed so it can
86
+ * re-derive the challenge it issued without having stored one. `insecure` is what to say on an http:// page:
87
+ * `crypto.subtle` is only available in a secure context, and the fix is the site's TLS, not anything the
88
+ * person can do, so the sentence names the thing they were trying to do. */
89
+ export const solveProofOfWork = async (challenge: PowChallenge, insecure: string, onProgress?: (attempts: number) => void): Promise<string> => {
90
+ if (crypto.subtle === undefined) {
91
+ throw new Error(insecure);
92
+ }
93
+ const encoder = new TextEncoder();
94
+ for (let nonce = 0; ; nonce += 1) {
95
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`${challenge.salt}:${nonce}`)));
96
+ if (leadingZeroBits(digest, challenge.difficulty) >= challenge.difficulty) {
97
+ return `${challenge.salt}:${nonce}`;
98
+ }
99
+ if (nonce % BATCH === BATCH - 1) {
100
+ onProgress?.(nonce + 1);
101
+ // Hand the main thread back so the page (and the embed's own "checking…" line) keeps painting.
102
+ await new Promise((resolve) => setTimeout(resolve, 0));
103
+ }
104
+ }
105
+ };
106
+
107
+ /* ---- what an embed keeps in the visitor's browser ----
108
+ *
109
+ * localStorage throws in Safari's private mode and anywhere the site blocks storage. An embed with no storage
110
+ * still works; it just mints a fresh id per page load, which costs only a slightly less useful thread or
111
+ * rate-limit key. Silence here is deliberate: an embed must never be the thing that puts an error in the
112
+ * console. */
113
+ export const readStored = (name: string): string | undefined => {
114
+ try {
115
+ return window.localStorage.getItem(name) ?? undefined;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ };
120
+
121
+ export const writeStored = (name: string, value: string): void => {
122
+ try {
123
+ window.localStorage.setItem(name, value);
124
+ } catch {
125
+ /* no storage: this page load keeps the value in memory and the next one mints another */
126
+ }
127
+ };
128
+
129
+ /* A per-browser id under `name`, minted once and kept. NOT identity and NOT a secret: anyone can mint one, and
130
+ * the daemon treats it as exactly what it is, the key that threads a visitor's messages into one conversation,
131
+ * or that a rate window counts against so one runaway tab cannot spend the whole day's budget. */
132
+ export const storedId = (name: string): string => {
133
+ const existing = readStored(name);
134
+ if (existing !== undefined && existing !== "") {
135
+ return existing;
136
+ }
137
+ const minted = crypto.randomUUID();
138
+ writeStored(name, minted);
139
+ return minted;
140
+ };
141
+
142
+ /* ---- the <script> tag an embed boots from ----
143
+ *
144
+ * <script src="https://sandbox-<id>.<zone>/<slug>/…js" data-automation="support" defer></script>
145
+ *
146
+ * Everything else is derived: the daemon to talk to is the ORIGIN THIS SCRIPT CAME FROM, which is the one thing
147
+ * a copy-pasted snippet can't get wrong. `data-base` overrides it for a site fronting the sandbox behind its
148
+ * own proxy, the only case where the two legitimately differ.
149
+ *
150
+ * `document.currentScript` is only valid while the script body is executing, so it is read at module scope by
151
+ * the caller rather than inside an async boot. The querySelector is the fallback for a bundler or tag manager
152
+ * that re-executes the entry in a context where currentScript is null. */
153
+ export const embedScript = (srcMatch: string): HTMLScriptElement | null =>
154
+ (document.currentScript as HTMLScriptElement | null) ?? document.querySelector<HTMLScriptElement>(`script[src*="${srcMatch}"]`);
155
+
156
+ // The endpoint a script tag names, or undefined when it carries no automation id, the one mistake worth a
157
+ // console line: without it the embed is silently absent and the site owner has nothing to go on.
158
+ export const embedEndpointOf = (script: HTMLScriptElement): EmbedEndpoint | undefined => {
159
+ const automationId = script.dataset["automation"];
160
+ if (automationId === undefined || automationId === "") {
161
+ return undefined;
162
+ }
163
+ return { automationId, base: (script.dataset["base"] ?? new URL(script.src, window.location.href).origin).replace(/\/$/, "") };
164
+ };
package/src/events.ts CHANGED
@@ -93,7 +93,7 @@ export const PermissionAskSchema = z.object({
93
93
  * the title (the judge read the owner's policy and the program, and its account of why this needs asking is
94
94
  * the only account there is), so this is left off rather than printing the same words twice. It carries the
95
95
  * sentence on the two cards whose title says something the sentence cannot: the hard rule's, which names the
96
- * consequence that stopped it, and a machine command's, which names the computer.
96
+ * consequence that stopped it, and a machine command's, which names the device.
97
97
  *
98
98
  * Written by the quick model from the program text and the policy, never by the agent being gated — a card
99
99
  * whose persuasive half was authored by the thing it is stopping argues for its own approval, and the turns
@@ -557,7 +557,7 @@ export const TranscriptToolSchema: z.ZodType<TranscriptTool> = z.lazy(() =>
557
557
  .array(TranscriptToolSchema)
558
558
  .optional()
559
559
  .describe(
560
- "Calls a delegated helper made, nested under the call that started it, so a reopened conversation redraws the delegation rather than collapsing it into one result.",
560
+ "Calls a delegated subagent made, nested under the call that started it, so a reopened conversation redraws the delegation rather than collapsing it into one result.",
561
561
  ),
562
562
  thinking: z.string().optional().describe("What the agent was reasoning about around this call."),
563
563
  subagent: TranscriptSubagentSchema.optional().describe(
@@ -772,7 +772,7 @@ export const TranscriptPatchSchema = z.discriminatedUnion("op", [
772
772
  op: z.literal("tool").describe("A tool card, whole: new, or the latest state of one already there, matched by id wherever it nests."),
773
773
  index: z.number().int().nonnegative(),
774
774
  tool: TranscriptToolSchema,
775
- parent: z.string().optional().describe("The card this one nests under, when it is a delegated helper's own call."),
775
+ parent: z.string().optional().describe("The card this one nests under, when it is a delegated subagent's own call."),
776
776
  }),
777
777
  ]);
778
778
  export type TranscriptPatch = z.infer<typeof TranscriptPatchSchema>;
@@ -857,6 +857,16 @@ export const AgentTranscriptSchema = SessionTranscriptSchema.extend({
857
857
  ending: TurnEndingSchema.optional().describe(
858
858
  "How the last turn ended, when it left work behind that one press finishes. Absent for a conversation whose last turn ended on its own, and for the failures that name something to repair first.",
859
859
  ),
860
+ /* WHERE THIS PAGE SITS IN THE CONVERSATION. `messages` is the tail, not the whole record: a conversation
861
+ * that ran all week used to be served entire to every tab that opened it and to every card the board warms
862
+ * behind it, which is megabytes over a tunnel to redraw a screenful.
863
+ *
864
+ * `from` is the position of the first message in the WHOLE record, which makes it two things at once: the
865
+ * offset every `rewindIndex` in this page is counted against, and the `before` that asks for the page above
866
+ * it. `more` says whether there is one, so a client can offer to go back without spending a round trip
867
+ * finding out. */
868
+ from: z.number().int().nonnegative().describe("Where the first message sits in the whole record, and the `before` that asks for the page above this one."),
869
+ more: z.boolean().describe("Whether older messages precede this page."),
860
870
  });
861
871
 
862
872
  /* WHAT A PUBLISHED CONVERSATION'S PAGE IS HANDED, the whole of it, baked into the page as one JSON block.
@@ -1269,7 +1279,7 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
1269
1279
  // was stopped, or died under the card), which is not a decision and must not replay as one.
1270
1280
  z.object({ kind: z.literal("resolved"), requestId: z.string(), reply: AgentReplySchema.optional() }),
1271
1281
  /* There was a `permission_note` frame here: a late sentence raced onto a command card that had already gone
1272
- * out, because the explanation was optional and the card must not wait for a quick-model rung that might
1282
+ * out, because the explanation was optional and the card must not wait for a one-shot helper rung that might
1273
1283
  * take tens of seconds. It is gone with the setting that made it optional. The judge now decides the
1274
1284
  * verdict, so the sentence is not a decoration arriving afterwards — it is the REASON THE CARD EXISTS, and
1275
1285
  * a card cannot be raised before it is known. Nothing races, and `PermissionAsk.explain` is populated at
@@ -1334,6 +1344,23 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
1334
1344
  "unknown-command",
1335
1345
  "grok-model-invalid",
1336
1346
  "codex-model-invalid",
1347
+ /* THE MODEL IS REAL, LISTED, AND NOT THIS PLAN'S TO RUN. A routed provider's catalog is the set
1348
+ * the vendor publishes, not the set the connected subscription pays for, so a picker row can be
1349
+ * a model the upstream refuses on sight ("Your current subscription does not have access to
1350
+ * kimi-for-coding-highspeed. Upgrade to higher-tier Kimi Code plans.").
1351
+ *
1352
+ * Its own code because every neighbour's recovery is wrong for it. It is not `provider-outage`,
1353
+ * though that is what it wore: the translator answers a refused model with a 503, the harness
1354
+ * reads a 5xx as an outage, and rides it out for the whole in-turn retry budget — two minutes of
1355
+ * a spinner for a request that was refused in five milliseconds and will be refused identically
1356
+ * forever, followed by an auto-resume schedule for a turn that cannot come back. It is not
1357
+ * `*-model-invalid` either: nothing is misspelled and reloading the catalog re-offers the same
1358
+ * row, because the vendor really does serve it — to somebody else.
1359
+ *
1360
+ * What changes the outcome is picking another model or buying the plan, so the sentence is the
1361
+ * upstream's own (it names the tier), the client holds the words, and the daemon files the model
1362
+ * as refused so the picker stops offering it (usage/model-refusals.ts). */
1363
+ "model-unavailable",
1337
1364
  /* THE MODEL CANNOT HOLD A TURN OF THIS AGENT LOOP, so the daemon refused before sending
1338
1365
  * (agent/context-budget.ts). Its own code because none of the neighbours describes it: nothing is
1339
1366
  * disconnected, nothing is spent, nothing comes back on a clock, and re-sending the same request
@@ -61,7 +61,7 @@ test("a pin on this provider wins over the catalog's own cheap end", () => {
61
61
  });
62
62
 
63
63
  test("takes a pinned id verbatim, so a model the static catalog has not caught up with is still pinnable", () => {
64
- // The same call resolveQuickModels makes, and for the same reason: the picker offers a custom-id escape
64
+ // The same call resolveRoleModels makes, and for the same reason: the picker offers a custom-id escape
65
65
  // hatch, and second-guessing the id here would run a different model than the settings row names.
66
66
  expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-haiku-9`] })).toBe(`claude-haiku-9`);
67
67
  });
package/src/fast-tier.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { compareCheapestFirst, isCheaperRung } from "./model-order.js";
2
- import { parsePinned } from "./quick-model.js";
2
+ import { parsePinned } from "./model-pins.js";
3
3
  import type { AgentProvider } from "./schemas/agent.js";
4
4
 
5
5
  /* WHICH MODEL A DOWNGRADED TURN ACTUALLY RUNS ON, the second half of automatic tier selection.
@@ -21,7 +21,7 @@ import type { AgentProvider } from "./schemas/agent.js";
21
21
  * follow-up cheap to answer. So a cross-provider pin here is dropped rather than honoured: saving a fraction of
22
22
  * a cent by starting the conversation over is not a saving.
23
23
  *
24
- * THE ORDER IS A LADDER, for the reason quick-model.ts is one, but a shorter one: the caller spends the head
24
+ * THE ORDER IS A LADDER, for the reason model-pins.ts is one, but a shorter one: the caller spends the head
25
25
  * and falls back to the user's own pick, rather than walking rungs. A downgrade that cannot be started is not
26
26
  * worth a second attempt when the honest answer (their model) is sitting right there. */
27
27
 
@@ -34,14 +34,14 @@ export interface FastTierInput {
34
34
  // That provider's catalog as the caller can see it. Empty is a real state (a catalog that has not loaded),
35
35
  // and it resolves to no downgrade rather than to a guess.
36
36
  readonly models: readonly string[];
37
- // settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (quickModelKey), or empty for
37
+ // settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (modelPinKey), or empty for
38
38
  // Auto. Entries naming another provider are dropped, not honoured.
39
39
  readonly pinned: readonly string[];
40
40
  }
41
41
 
42
42
  /* THE CHEAPER MODEL TO RUN THIS TURN ON, or undefined for "there isn't one, use their pick".
43
43
  *
44
- * A PIN IS TAKEN VERBATIM against the catalog, the same call resolveQuickModels makes and for the same reason:
44
+ * A PIN IS TAKEN VERBATIM against the catalog, the same call resolveRoleModels makes and for the same reason:
45
45
  * the model picker offers a custom-id escape hatch for a model the static catalog has not caught up with, and
46
46
  * second-guessing the id here would run a different model than the settings row names. It is still checked for
47
47
  * being CHEAPER, because that is not a fact about the catalog, it is a fact about the id, and a pin that is not
@@ -49,7 +49,7 @@ export interface FastTierInput {
49
49
  *
50
50
  * AUTO IS THE CHEAPEST ROW THE PROVIDER PUBLISHES, read through the same cheap-end order the quick model uses,
51
51
  * so the two features cannot disagree about which rung is the cheap one. Derived, never stored: connect an
52
- * account tomorrow and the answer improves by itself, exactly as quickModel's empty default does.
52
+ * account tomorrow and the answer improves by itself, exactly as a one-shot role's empty list does.
53
53
  *
54
54
  * NOTHING CHEAPER THAN THE PICK ⇒ UNDEFINED, and that is the common case worth being exact about rather than
55
55
  * the edge case: a user already working on the cheap rung has nowhere to be sent, and a user on a model whose
@@ -67,6 +67,12 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
67
67
  { path: "usage.jsonl", portability: "carry" },
68
68
  { path: "account-usage.json", portability: "carry" },
69
69
  { path: "provider-refusals.json", portability: "carry" },
70
+ /* Which MODELS this sandbox's credentials were refused (usage/model-refusals.ts), the per-model half of the
71
+ * entry above and carried for the same reason: a bundle is this owner's sandbox on another machine, signing
72
+ * the same subscriptions back in, so a plan that does not cover a model there does not cover it here
73
+ * either, and the picker should arrive already clean. Cheap to be wrong about in the one case it can be
74
+ * (the target signs in a bigger plan): every entry is forgotten a day after it was written. */
75
+ { path: "model-refusals.json", portability: "carry" },
70
76
  // Explicit first-time dependency setup requests. Carrying the worklist preserves the owner's decision when
71
77
  // an export interrupts the queue before its terminal starts; fulfilled entries remove themselves.
72
78
  { path: "dependency-requests.json", portability: "carry" },
@@ -146,15 +152,18 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
146
152
  { path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
147
153
  { path: "sync-pair-consumed.json", portability: "identity" },
148
154
  { path: "host-enrollments.json", portability: "identity" },
149
- /* A connected BROWSER's enrollment (webext/webext-store.ts). Identity for the hosts file's reason and one
155
+ /* A connected BROWSER's enrollment (webext/webext-peer.ts). Identity for the hosts file's reason and one
150
156
  * more of its own: the token admits a socket into somebody's signed-in browser, and that browser was paired
151
157
  * with THIS sandbox — carried into another one it would either be dead weight or, worse, a second sandbox
152
158
  * holding a live key to a browser its owner never connected it to. Re-pairing is a code and one click. */
153
159
  { path: "webext-enrollments.json", portability: "identity", note: "Pair your browser again from its card: the extension is still installed." },
154
- // The burn list for setup-time computer pairings. Identity, like sync's beside it, and for a sharper reason:
160
+ // The browser door's burn list: every peer door keeps one (peers/peer-store.ts), and a browser pairing
161
+ // redeemed here must not read as fresh in a sandbox this file travelled to. Identity, hosts' reason.
162
+ { path: "webext-pair-consumed.json", portability: "identity" },
163
+ // The burn list for setup-time device pairings. Identity, like sync's beside it, and for a sharper reason:
155
164
  // carrying it into another sandbox would mark that sandbox's own fresh pairing as already spent.
156
165
  { path: "host-pair-consumed.json", portability: "identity" },
157
- // A runner's enrollment names THIS sandbox as its parent (runners/runners-store.ts): in another sandbox the
166
+ // A runner's enrollment names THIS sandbox as its parent (runners/runner-peer.ts): in another sandbox the
158
167
  // digest would admit a socket whose runner still dials the old parent. Identity, both files, hosts' reasons.
159
168
  { path: "runner-enrollments.json", portability: "identity" },
160
169
  { path: "runner-pair-consumed.json", portability: "identity" },