@intentic/sandbox-contract 1.176.3 → 1.209.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 (334) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-catalog.d.ts +15 -2
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +50 -1
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -0
  7. package/dist/agent-run-model.d.ts.map +1 -0
  8. package/dist/agent-run-model.js +10 -0
  9. package/dist/agent-run-model.js.map +1 -0
  10. package/dist/capability-env.d.ts +2 -0
  11. package/dist/capability-env.d.ts.map +1 -0
  12. package/dist/capability-env.js +2 -0
  13. package/dist/capability-env.js.map +1 -0
  14. package/dist/chores/chores.d.ts +9 -0
  15. package/dist/chores/chores.d.ts.map +1 -1
  16. package/dist/chores/chores.js +257 -18
  17. package/dist/chores/chores.js.map +1 -1
  18. package/dist/chores/digest.js +0 -0
  19. package/dist/chores/digest.js.map +1 -1
  20. package/dist/chores/extension-update.d.ts +9 -0
  21. package/dist/chores/extension-update.d.ts.map +1 -0
  22. package/dist/chores/extension-update.js +15 -0
  23. package/dist/chores/extension-update.js.map +1 -0
  24. package/dist/chores/fix-deps.d.ts +10 -0
  25. package/dist/chores/fix-deps.d.ts.map +1 -0
  26. package/dist/chores/fix-deps.js +18 -0
  27. package/dist/chores/fix-deps.js.map +1 -0
  28. package/dist/chores/index.d.ts +7 -2
  29. package/dist/chores/index.d.ts.map +1 -1
  30. package/dist/chores/index.js +4 -1
  31. package/dist/chores/index.js.map +1 -1
  32. package/dist/chores/probes.d.ts.map +1 -1
  33. package/dist/chores/probes.js +127 -3
  34. package/dist/chores/probes.js.map +1 -1
  35. package/dist/chores/stack.d.ts +27 -0
  36. package/dist/chores/stack.d.ts.map +1 -0
  37. package/dist/chores/stack.js +121 -0
  38. package/dist/chores/stack.js.map +1 -0
  39. package/dist/chores/verdict.d.ts +2 -1
  40. package/dist/chores/verdict.d.ts.map +1 -1
  41. package/dist/chores/verdict.js +53 -6
  42. package/dist/chores/verdict.js.map +1 -1
  43. package/dist/contract-lock.d.ts +3 -0
  44. package/dist/contract-lock.d.ts.map +1 -0
  45. package/dist/contract-lock.js +35 -0
  46. package/dist/contract-lock.js.map +1 -0
  47. package/dist/contracts/activity.contract.d.ts +10 -0
  48. package/dist/contracts/activity.contract.d.ts.map +1 -1
  49. package/dist/contracts/agent.contract.d.ts +230 -20
  50. package/dist/contracts/agent.contract.d.ts.map +1 -1
  51. package/dist/contracts/agent.contract.js +2 -1
  52. package/dist/contracts/agent.contract.js.map +1 -1
  53. package/dist/contracts/agents.contract.d.ts +847 -1
  54. package/dist/contracts/agents.contract.d.ts.map +1 -1
  55. package/dist/contracts/agents.contract.js +3 -1
  56. package/dist/contracts/agents.contract.js.map +1 -1
  57. package/dist/contracts/automations.contract.d.ts +89 -0
  58. package/dist/contracts/automations.contract.d.ts.map +1 -1
  59. package/dist/contracts/automations.contract.js +3 -1
  60. package/dist/contracts/automations.contract.js.map +1 -1
  61. package/dist/contracts/capabilities.contract.d.ts +108 -32
  62. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  63. package/dist/contracts/capabilities.contract.js +5 -1
  64. package/dist/contracts/capabilities.contract.js.map +1 -1
  65. package/dist/contracts/chores.contract.d.ts +43 -0
  66. package/dist/contracts/chores.contract.d.ts.map +1 -1
  67. package/dist/contracts/ci.contract.d.ts +5 -0
  68. package/dist/contracts/ci.contract.d.ts.map +1 -1
  69. package/dist/contracts/ci.contract.js +2 -2
  70. package/dist/contracts/ci.contract.js.map +1 -1
  71. package/dist/contracts/claude.contract.d.ts +3 -14
  72. package/dist/contracts/claude.contract.d.ts.map +1 -1
  73. package/dist/contracts/claude.contract.js +2 -3
  74. package/dist/contracts/claude.contract.js.map +1 -1
  75. package/dist/contracts/drafts.contract.d.ts +4 -0
  76. package/dist/contracts/drafts.contract.d.ts.map +1 -1
  77. package/dist/contracts/endpoints.contract.d.ts +28 -12
  78. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  79. package/dist/contracts/endpoints.contract.js +9 -0
  80. package/dist/contracts/endpoints.contract.js.map +1 -1
  81. package/dist/contracts/extensions.contract.d.ts +399 -69
  82. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  83. package/dist/contracts/extensions.contract.js +15 -1
  84. package/dist/contracts/extensions.contract.js.map +1 -1
  85. package/dist/contracts/git.contract.d.ts +209 -11
  86. package/dist/contracts/git.contract.d.ts.map +1 -1
  87. package/dist/contracts/git.contract.js +12 -2
  88. package/dist/contracts/git.contract.js.map +1 -1
  89. package/dist/contracts/grok.contract.d.ts +0 -13
  90. package/dist/contracts/grok.contract.d.ts.map +1 -1
  91. package/dist/contracts/grok.contract.js +1 -2
  92. package/dist/contracts/grok.contract.js.map +1 -1
  93. package/dist/contracts/host.contract.d.ts +39 -0
  94. package/dist/contracts/host.contract.d.ts.map +1 -1
  95. package/dist/contracts/host.contract.js +3 -2
  96. package/dist/contracts/host.contract.js.map +1 -1
  97. package/dist/contracts/loops.contract.d.ts +343 -0
  98. package/dist/contracts/loops.contract.d.ts.map +1 -0
  99. package/dist/contracts/loops.contract.js +11 -0
  100. package/dist/contracts/loops.contract.js.map +1 -0
  101. package/dist/contracts/panels.contract.d.ts +6 -0
  102. package/dist/contracts/panels.contract.d.ts.map +1 -1
  103. package/dist/contracts/personas.contract.d.ts +112 -0
  104. package/dist/contracts/personas.contract.d.ts.map +1 -0
  105. package/dist/contracts/personas.contract.js +13 -0
  106. package/dist/contracts/personas.contract.js.map +1 -0
  107. package/dist/contracts/ports.contract.d.ts +1 -0
  108. package/dist/contracts/ports.contract.d.ts.map +1 -1
  109. package/dist/contracts/prepush.contract.d.ts +1 -0
  110. package/dist/contracts/prepush.contract.d.ts.map +1 -1
  111. package/dist/contracts/prepush.contract.js.map +1 -1
  112. package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
  113. package/dist/contracts/providers.contract.d.ts.map +1 -0
  114. package/dist/contracts/providers.contract.js +6 -0
  115. package/dist/contracts/providers.contract.js.map +1 -0
  116. package/dist/contracts/public.contract.d.ts +24 -0
  117. package/dist/contracts/public.contract.d.ts.map +1 -0
  118. package/dist/contracts/public.contract.js +8 -0
  119. package/dist/contracts/public.contract.js.map +1 -0
  120. package/dist/contracts/push.contract.d.ts +3 -3
  121. package/dist/contracts/secrets.contract.d.ts +9 -0
  122. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  123. package/dist/contracts/sessions.contract.d.ts +16 -1
  124. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  125. package/dist/contracts/sessions.contract.js +1 -1
  126. package/dist/contracts/sessions.contract.js.map +1 -1
  127. package/dist/contracts/settings.contract.d.ts +352 -58
  128. package/dist/contracts/settings.contract.d.ts.map +1 -1
  129. package/dist/contracts/settings.contract.js +2 -1
  130. package/dist/contracts/settings.contract.js.map +1 -1
  131. package/dist/contracts/share.contract.d.ts +55 -0
  132. package/dist/contracts/share.contract.d.ts.map +1 -0
  133. package/dist/contracts/share.contract.js +9 -0
  134. package/dist/contracts/share.contract.js.map +1 -0
  135. package/dist/contracts/skills.contract.d.ts +41 -0
  136. package/dist/contracts/skills.contract.d.ts.map +1 -0
  137. package/dist/contracts/skills.contract.js +9 -0
  138. package/dist/contracts/skills.contract.js.map +1 -0
  139. package/dist/contracts/system.contract.d.ts +204 -8
  140. package/dist/contracts/system.contract.d.ts.map +1 -1
  141. package/dist/contracts/system.contract.js +10 -2
  142. package/dist/contracts/system.contract.js.map +1 -1
  143. package/dist/contracts/workflows.contract.d.ts +575 -0
  144. package/dist/contracts/workflows.contract.d.ts.map +1 -0
  145. package/dist/contracts/workflows.contract.js +13 -0
  146. package/dist/contracts/workflows.contract.js.map +1 -0
  147. package/dist/contracts/workspace.contract.d.ts +18 -4
  148. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  149. package/dist/contracts/workspace.contract.js +6 -3
  150. package/dist/contracts/workspace.contract.js.map +1 -1
  151. package/dist/conversation-ids.d.ts +2 -0
  152. package/dist/conversation-ids.d.ts.map +1 -0
  153. package/dist/conversation-ids.js +113 -0
  154. package/dist/conversation-ids.js.map +1 -0
  155. package/dist/events.d.ts +523 -17
  156. package/dist/events.d.ts.map +1 -1
  157. package/dist/events.js +106 -13
  158. package/dist/events.js.map +1 -1
  159. package/dist/history-state.d.ts.map +1 -1
  160. package/dist/history-state.js +10 -0
  161. package/dist/history-state.js.map +1 -1
  162. package/dist/hostnames.d.ts +4 -0
  163. package/dist/hostnames.d.ts.map +1 -1
  164. package/dist/hostnames.js +7 -2
  165. package/dist/hostnames.js.map +1 -1
  166. package/dist/index.d.ts +3791 -478
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +39 -17
  169. package/dist/index.js.map +1 -1
  170. package/dist/listener-protocol.d.ts +77 -0
  171. package/dist/listener-protocol.d.ts.map +1 -0
  172. package/dist/listener-protocol.js +33 -0
  173. package/dist/listener-protocol.js.map +1 -0
  174. package/dist/model-order.d.ts +1 -0
  175. package/dist/model-order.d.ts.map +1 -1
  176. package/dist/model-order.js +16 -0
  177. package/dist/model-order.js.map +1 -1
  178. package/dist/output-fields.d.ts +30 -0
  179. package/dist/output-fields.d.ts.map +1 -0
  180. package/dist/output-fields.js +57 -0
  181. package/dist/output-fields.js.map +1 -0
  182. package/dist/path-refs.d.ts.map +1 -1
  183. package/dist/path-refs.js.map +1 -1
  184. package/dist/publish-drafts.d.ts +7 -0
  185. package/dist/publish-drafts.d.ts.map +1 -0
  186. package/dist/publish-drafts.js +20 -0
  187. package/dist/publish-drafts.js.map +1 -0
  188. package/dist/quick-model.d.ts +2 -1
  189. package/dist/quick-model.d.ts.map +1 -1
  190. package/dist/quick-model.js +18 -14
  191. package/dist/quick-model.js.map +1 -1
  192. package/dist/routes.d.ts +3 -0
  193. package/dist/routes.d.ts.map +1 -1
  194. package/dist/routes.js +63 -0
  195. package/dist/routes.js.map +1 -1
  196. package/dist/runtime-state.d.ts +33 -0
  197. package/dist/runtime-state.d.ts.map +1 -0
  198. package/dist/runtime-state.js +14 -0
  199. package/dist/runtime-state.js.map +1 -0
  200. package/dist/schemas.d.ts +4886 -851
  201. package/dist/schemas.d.ts.map +1 -1
  202. package/dist/schemas.js +993 -192
  203. package/dist/schemas.js.map +1 -1
  204. package/dist/search-globs.d.ts +6 -0
  205. package/dist/search-globs.d.ts.map +1 -0
  206. package/dist/search-globs.js +39 -0
  207. package/dist/search-globs.js.map +1 -0
  208. package/dist/share-paths.d.ts +8 -0
  209. package/dist/share-paths.d.ts.map +1 -0
  210. package/dist/share-paths.js +21 -0
  211. package/dist/share-paths.js.map +1 -0
  212. package/dist/title.d.ts.map +1 -1
  213. package/dist/title.js +1 -1
  214. package/dist/title.js.map +1 -1
  215. package/dist/tunnel-ids.d.ts +1 -0
  216. package/dist/tunnel-ids.d.ts.map +1 -1
  217. package/dist/tunnel-ids.js +1 -0
  218. package/dist/tunnel-ids.js.map +1 -1
  219. package/dist/versions.d.ts +4 -0
  220. package/dist/versions.d.ts.map +1 -0
  221. package/dist/versions.js +15 -0
  222. package/dist/versions.js.map +1 -0
  223. package/dist/workflow-faults.d.ts +4 -0
  224. package/dist/workflow-faults.d.ts.map +1 -0
  225. package/dist/workflow-faults.js +115 -0
  226. package/dist/workflow-faults.js.map +1 -0
  227. package/dist/workspace-state.d.ts +280 -1
  228. package/dist/workspace-state.d.ts.map +1 -1
  229. package/dist/workspace-state.js +163 -21
  230. package/dist/workspace-state.js.map +1 -1
  231. package/package.json +30 -6
  232. package/src/agent-catalog.test.ts +147 -7
  233. package/src/agent-catalog.ts +233 -21
  234. package/src/agent-run-model.test.ts +60 -0
  235. package/src/agent-run-model.ts +51 -0
  236. package/src/capability-env.ts +10 -0
  237. package/src/capability-ledger.test.ts +110 -0
  238. package/src/chores/chores.test.ts +32 -0
  239. package/src/chores/chores.ts +456 -32
  240. package/src/chores/digest.ts +0 -0
  241. package/src/chores/extension-update.ts +37 -0
  242. package/src/chores/fix-deps.ts +40 -0
  243. package/src/chores/index.ts +7 -2
  244. package/src/chores/probes.test.ts +155 -3
  245. package/src/chores/probes.ts +200 -4
  246. package/src/chores/stack.test.ts +126 -0
  247. package/src/chores/stack.ts +244 -0
  248. package/src/chores/verdict.test.ts +357 -19
  249. package/src/chores/verdict.ts +95 -21
  250. package/src/contract-lock.test.ts +17 -0
  251. package/src/contract-lock.ts +66 -0
  252. package/src/contracts/agent.contract.ts +6 -0
  253. package/src/contracts/agents.contract.ts +12 -0
  254. package/src/contracts/automations.contract.ts +17 -1
  255. package/src/contracts/capabilities.contract.ts +22 -0
  256. package/src/contracts/ci.contract.ts +10 -2
  257. package/src/contracts/claude.contract.ts +8 -4
  258. package/src/contracts/endpoints.contract.ts +21 -0
  259. package/src/contracts/extensions.contract.ts +42 -0
  260. package/src/contracts/git.contract.ts +33 -6
  261. package/src/contracts/grok.contract.ts +4 -3
  262. package/src/contracts/host.contract.ts +13 -2
  263. package/src/contracts/loops.contract.ts +62 -0
  264. package/src/contracts/personas.contract.ts +56 -0
  265. package/src/contracts/prepush.contract.ts +4 -3
  266. package/src/contracts/providers.contract.ts +23 -0
  267. package/src/contracts/public.contract.ts +14 -0
  268. package/src/contracts/sessions.contract.ts +5 -1
  269. package/src/contracts/settings.contract.ts +5 -0
  270. package/src/contracts/share.contract.ts +20 -0
  271. package/src/contracts/skills.contract.ts +24 -0
  272. package/src/contracts/system.contract.ts +27 -3
  273. package/src/contracts/workflows.contract.ts +78 -0
  274. package/src/contracts/workspace.contract.ts +10 -2
  275. package/src/conversation-ids.test.ts +25 -0
  276. package/src/conversation-ids.ts +160 -0
  277. package/src/events.test.ts +20 -1
  278. package/src/events.ts +404 -43
  279. package/src/history-state.ts +30 -1
  280. package/src/hostnames.ts +20 -6
  281. package/src/index.ts +58 -17
  282. package/src/listener-protocol.ts +96 -0
  283. package/src/model-order.test.ts +32 -1
  284. package/src/model-order.ts +56 -3
  285. package/src/output-fields.ts +111 -0
  286. package/src/path-refs.test.ts +4 -4
  287. package/src/path-refs.ts +5 -5
  288. package/src/publish-drafts.ts +59 -0
  289. package/src/quick-model.test.ts +70 -27
  290. package/src/quick-model.ts +76 -40
  291. package/src/routes.test.ts +91 -3
  292. package/src/routes.ts +135 -0
  293. package/src/runtime-state.test.ts +45 -0
  294. package/src/runtime-state.ts +124 -0
  295. package/src/schemas.test.ts +16 -29
  296. package/src/schemas.ts +3475 -552
  297. package/src/search-globs.test.ts +46 -0
  298. package/src/search-globs.ts +76 -0
  299. package/src/share-paths.test.ts +48 -0
  300. package/src/share-paths.ts +68 -0
  301. package/src/state-portability.ts +8 -8
  302. package/src/title.test.ts +20 -1
  303. package/src/title.ts +12 -3
  304. package/src/tunnel-ids.ts +15 -1
  305. package/src/versions.test.ts +39 -0
  306. package/src/versions.ts +48 -0
  307. package/src/workflow-faults.test.ts +127 -0
  308. package/src/workflow-faults.ts +188 -0
  309. package/src/workspace-state.test.ts +169 -15
  310. package/src/workspace-state.ts +427 -35
  311. package/dist/contracts/codex.contract.d.ts.map +0 -1
  312. package/dist/contracts/codex.contract.js +0 -6
  313. package/dist/contracts/codex.contract.js.map +0 -1
  314. package/dist/contracts/gemini.contract.d.ts +0 -16
  315. package/dist/contracts/gemini.contract.d.ts.map +0 -1
  316. package/dist/contracts/gemini.contract.js +0 -6
  317. package/dist/contracts/gemini.contract.js.map +0 -1
  318. package/dist/contracts/kimi.contract.d.ts +0 -16
  319. package/dist/contracts/kimi.contract.d.ts.map +0 -1
  320. package/dist/contracts/kimi.contract.js +0 -6
  321. package/dist/contracts/kimi.contract.js.map +0 -1
  322. package/dist/contracts/komodo.contract.d.ts +0 -125
  323. package/dist/contracts/komodo.contract.d.ts.map +0 -1
  324. package/dist/contracts/komodo.contract.js +0 -14
  325. package/dist/contracts/komodo.contract.js.map +0 -1
  326. package/dist/contracts/memory.contract.d.ts +0 -34
  327. package/dist/contracts/memory.contract.d.ts.map +0 -1
  328. package/dist/contracts/memory.contract.js +0 -9
  329. package/dist/contracts/memory.contract.js.map +0 -1
  330. package/src/contracts/codex.contract.ts +0 -10
  331. package/src/contracts/gemini.contract.ts +0 -11
  332. package/src/contracts/kimi.contract.ts +0 -9
  333. package/src/contracts/komodo.contract.ts +0 -36
  334. package/src/contracts/memory.contract.ts +0 -13
@@ -3,7 +3,7 @@ import { isTestPath, rankRefCandidates, referenceTails } from "./path-refs.js";
3
3
 
4
4
  describe("referenceTails", () => {
5
5
  test("offers the reference itself first, then shorter tails of it", () => {
6
- expect(referenceTails("_apps/web/src/foo.ts", "/work")).toEqual(["_apps/web/src/foo.ts", "web/src/foo.ts", "src/foo.ts"]);
6
+ expect(referenceTails("_editor/web/src/foo.ts", "/work")).toEqual(["_editor/web/src/foo.ts", "web/src/foo.ts", "src/foo.ts"]);
7
7
  });
8
8
 
9
9
  test("anchors an absolute path under the workspace root", () => {
@@ -45,12 +45,12 @@ test("isTestPath: test files, fixture dirs and runner configs — never product
45
45
  for (const path of [
46
46
  `src/agents/land.test.ts`,
47
47
  `src/pages/Foo.spec.tsx`,
48
- `_apps/cli/src/cli.e2e.test.ts`,
48
+ `_deploy/cli/src/cli.e2e.test.ts`,
49
49
  `src/e2e-harness.ts`,
50
50
  `pkg/__tests__/helper.ts`,
51
- `_libs/iq-recall/src/__fixtures__/transcripts/a.jsonl`,
51
+ `_search/iq-recall/src/__fixtures__/transcripts/a.jsonl`,
52
52
  `vitest.config.ts`,
53
- `_apps/web/vitest.workspace.config.mts`,
53
+ `_editor/web/vitest.workspace.config.mts`,
54
54
  `playwright.config.ts`,
55
55
  ]) {
56
56
  expect(isTestPath(path), path).toBe(true);
package/src/path-refs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* Which file a NAMED reference means. A path written in prose is only loosely anchored to the workspace: an
2
- * agent that has been working in `_apps/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
3
- * isolated worktree prints `/history/worktrees/<id>/_apps/web/src/foo.ts` — neither is the workspace-relative
2
+ * agent that has been working in `_editor/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
3
+ * isolated worktree prints `/history/worktrees/<id>/_editor/web/src/foo.ts` — neither is the workspace-relative
4
4
  * path the file routes speak, but both END in it.
5
5
  *
6
6
  * So a reference is resolved by matching progressively shorter TAILS of it against the real tree. The rules
@@ -53,7 +53,7 @@ export const rankRefCandidates = (tail: string, paths: readonly string[]): reado
53
53
  * `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one —
54
54
  * it tells a reviewer not to look. */
55
55
  const TEST_DIRS = new Set([`__tests__`, `__fixtures__`, `__mocks__`, `__snapshots__`]);
56
- const TEST_FILE = /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[^/.]+|[^/]*\.e2e\.[^/]+|e2e-harness\.[^/]+|(?:vitest|jest|playwright)(?:\.[\w-]+)*\.config\.[^/]+)$/;
56
+ const TEST_FILE =
57
+ /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[^/.]+|[^/]*\.e2e\.[^/]+|e2e-harness\.[^/]+|(?:vitest|jest|playwright)(?:\.[\w-]+)*\.config\.[^/]+)$/;
57
58
 
58
- export const isTestPath = (path: string): boolean =>
59
- TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
59
+ export const isTestPath = (path: string): boolean => TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
@@ -0,0 +1,59 @@
1
+ /* HOW AN APPROVED POST GETS SENT — the shared half, written once because the daemon acts on it, the app draws
2
+ * it, and the two have to agree on the same seconds.
3
+ *
4
+ * THERE IS NO PUBLISHER AUTOMATION ANY MORE, and its absence is the design. Publishing used to be a scheduled
5
+ * automation: a cron waking every few minutes, running a shell guard over the drafts directory, almost always
6
+ * finding nothing — a job whose entire job was to ask "yet?" forever. It also made the approve button
7
+ * conditional on a row in a list nobody had asked for: delete the automation and approvals silently went
8
+ * nowhere, with the button still there and still saying yes. The daemon owns publishing now
9
+ * (drafts-publisher.ts). It knows the moment a draft comes due because it is the process that wrote the draft,
10
+ * so it sleeps until exactly then and costs nothing in between.
11
+ *
12
+ * APPROVAL IS NOT "SEND NOW", IT IS "SEND UNLESS I STOP YOU". A post is public and permanent the instant it
13
+ * lands, and the gap between realising and clicking is about two seconds — so an approved draft carrying no
14
+ * date of its own is dated HOLD into the future, and the queue counts it down in the open. The hold is stored
15
+ * as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it survives a restart,
16
+ * it reads as one number in the same place a scheduled post's date is already read, and calling it off is the
17
+ * "put it back in review" click that was there before any of this.
18
+ *
19
+ * A MINUTE, because that is the whole width of the decision: long enough to catch the wrong word you see the
20
+ * moment the row stops being a form, short enough that approving something still feels like sending it. */
21
+ export const APPROVAL_HOLD_MS = 60_000;
22
+
23
+ /* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
24
+ * the daemon can make itself — no model, no browser, no turn — and it either got a 200 or it did not. A
25
+ * connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
26
+ * page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
27
+ * precisely the work an agent turn exists to absorb.
28
+ *
29
+ * So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
30
+ * here rather than guessed at a call site — because being wrong in the optimistic direction means a post that
31
+ * silently never goes out. A platform absent from this set is published by an agent turn, which always works
32
+ * and merely costs more. */
33
+ export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
34
+
35
+ /* WHAT THE PUBLISH TURN IS TOLD, for the drafts no API can carry. It NAMES the drafts rather than saying "go
36
+ * and look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
37
+ * with it — sending something the owner pulled back a second ago, or skipping something it judged not ready.
38
+ * The turn's job is the part only it can do, which is working the platform's own UI.
39
+ *
40
+ * It still writes the outcome back into the file, because the file is where the queue reads it, and a post
41
+ * that went out without saying so is a post the owner sends twice. */
42
+ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readonly platform: string }[]): string =>
43
+ [
44
+ `Publish these approved post drafts, which are due now. They live in .intentic/drafts/, one JSON file each:`,
45
+ ``,
46
+ ...drafts.map((draft) => `- ${draft.id}.json (${draft.platform})`),
47
+ ``,
48
+ `Take them ONE AT A TIME, and for each:`,
49
+ `1. Read the file. Set "status":"posting" BEFORE you act, so a turn that dies here cannot double-post.`,
50
+ `2. Post exactly its "content" — with its "title", "target" and "media" — using that platform's skill.`,
51
+ ` A "target" that is a URL means this draft is a REPLY to whatever is at it: open that exact URL and`,
52
+ ` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
53
+ ` nest under that comment rather than becoming a new top-level comment on the thread.`,
54
+ `3. Set "status":"posted" plus "postedAt" (epoch ms) and, when the platform gives you one, "postedUrl".`,
55
+ ` If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the owner`,
56
+ ` can act on, then move to the next draft instead of retrying in a loop.`,
57
+ ``,
58
+ `Never rewrite the content — the owner approved these exact words. Never touch a draft not listed above.`,
59
+ ].join(`\n`);
@@ -1,9 +1,10 @@
1
1
  import { expect, test } from "vitest";
2
- import { type QuickModelSource, quickModelKey, resolveQuickModel } from "./quick-model.js";
2
+ import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
3
3
 
4
- /* Which model a one-click helper spends. The rule answers two surfaces at once — the daemon runs it, the
5
- * browser names it in the sparkle's tooltip — so what these tests pin is that a sandbox's connections alone
6
- * decide it, with no stored id to go stale. */
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. */
7
8
 
8
9
  // Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
9
10
  const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
@@ -13,14 +14,19 @@ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k
13
14
 
14
15
  const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
15
16
 
17
+ // The model that answers when nothing goes wrong — the head of the chain, which is what most of what follows is
18
+ // 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];
21
+
16
22
  test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
17
- expect(resolveQuickModel([CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
23
+ expect(head([CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
18
24
  });
19
25
 
20
26
  test("spends the FREE channel over the subscription when both offer the same rung", () => {
21
27
  // Both publish a cheap-tier row, so nothing separates them on capability — and one of them costs the user
22
- // nothing while the other eats headroom they watch. Clicking sparkle should not quietly bill the Claude plan.
23
- expect(resolveQuickModel([CLAUDE, GOOGLE], ``)).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
28
+ // nothing while the other eats headroom they watch. A background helper should not quietly bill the Claude plan.
29
+ expect(head([CLAUDE, GOOGLE], [])).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
24
30
  });
25
31
 
26
32
  test("puts tier ahead of cost — a free frontier model is still the wrong tool for a commit message", () => {
@@ -28,37 +34,33 @@ test("puts tier ahead of cost — a free frontier model is still the wrong tool
28
34
  // which is the exact outcome the feature exists to avoid.
29
35
  const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
30
36
 
31
- expect(resolveQuickModel([CLAUDE, proOnly], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
37
+ expect(head([CLAUDE, proOnly], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
32
38
  });
33
39
 
34
40
  test("uses stable provider order when two subscriptions offer the same tier", () => {
35
41
  const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
36
42
  const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
37
43
 
38
- expect(resolveQuickModel([kimiCheap, claudeCheap], ``)?.provider).toBe(`claude`);
44
+ expect(head([kimiCheap, claudeCheap], [])?.provider).toBe(`claude`);
39
45
  });
40
46
 
41
47
  test("answers the same thing however the connected providers happen to be listed", () => {
42
48
  // The daemon assembles these from live stores and the browser from its own refs; neither order is a fact.
43
- const answers = [
44
- resolveQuickModel([CLAUDE, GOOGLE, CODEX], ``),
45
- resolveQuickModel([CODEX, CLAUDE, GOOGLE], ``),
46
- resolveQuickModel([GOOGLE, CODEX, CLAUDE], ``),
47
- ];
49
+ const answers = [head([CLAUDE, GOOGLE, CODEX], []), head([CODEX, CLAUDE, GOOGLE], []), head([GOOGLE, CODEX, CLAUDE], [])];
48
50
 
49
51
  expect(new Set(answers.map((answer) => quickModelKey(answer!))).size).toBe(1);
50
52
  });
51
53
 
52
54
  test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
53
- expect(resolveQuickModel([CLAUDE, GOOGLE], `claude:claude-opus-5`)).toEqual({ provider: `claude`, model: `claude-opus-5` });
55
+ expect(head([CLAUDE, GOOGLE], [`claude:claude-opus-5`])).toEqual({ provider: `claude`, model: `claude-opus-5` });
54
56
  // The picker's custom-id escape hatch reaches here too: a catalog can lag a release, and running something
55
57
  // other than what the settings row names would be the worse failure.
56
- expect(resolveQuickModel([CLAUDE], `claude:claude-haiku-9`)).toEqual({ provider: `claude`, model: `claude-haiku-9` });
58
+ expect(head([CLAUDE], [`claude:claude-haiku-9`])).toEqual({ provider: `claude`, model: `claude-haiku-9` });
57
59
  });
58
60
 
59
61
  test("falls back to Auto when the pinned provider is no longer connected", () => {
60
62
  // Rather than failing every click with a credential error while the sandbox can plainly still answer.
61
- expect(resolveQuickModel([offline(CLAUDE), GOOGLE], `claude:claude-haiku-4-5-20251001`)).toEqual({
63
+ expect(head([offline(CLAUDE), GOOGLE], [`claude:claude-haiku-4-5-20251001`])).toEqual({
62
64
  provider: `gemini`,
63
65
  model: `gemini-3-flash-lite`,
64
66
  });
@@ -66,34 +68,75 @@ test("falls back to Auto when the pinned provider is no longer connected", () =>
66
68
 
67
69
  test("ignores a malformed pin instead of running an empty model id", () => {
68
70
  for (const pinned of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
69
- expect(resolveQuickModel([CLAUDE], pinned)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
71
+ expect(head([CLAUDE], [pinned])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
70
72
  }
71
73
  });
72
74
 
73
75
  test("serves the newest of a catalog that publishes no cheap tier at all", () => {
74
76
  // Kimi names no tier word anywhere. There is no cheaper rung to find, so the newest row is the honest answer.
75
- expect(resolveQuickModel([KIMI], ``)).toEqual({ provider: `kimi`, model: `kimi-k3` });
77
+ expect(head([KIMI], [])).toEqual({ provider: `kimi`, model: `kimi-k3` });
76
78
  });
77
79
 
78
80
  test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
79
- expect(resolveQuickModel([offline(CLAUDE), offline(GOOGLE)], ``)).toBeUndefined();
80
- expect(resolveQuickModel([], `claude:claude-haiku-4-5`)).toBeUndefined();
81
+ expect(head([offline(CLAUDE), offline(GOOGLE)], [])).toBeUndefined();
82
+ expect(resolveQuickModels([offline(CLAUDE), offline(GOOGLE)], [])).toEqual([]);
83
+ expect(head([], [`claude:claude-haiku-4-5`])).toBeUndefined();
81
84
  });
82
85
 
83
86
  test("skips a connected provider whose catalog has not loaded yet", () => {
84
87
  const unloaded: QuickModelSource = { provider: `grok`, ready: true, models: [] };
85
88
 
86
- expect(resolveQuickModel([unloaded, CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
87
- expect(resolveQuickModel([unloaded], ``)).toBeUndefined();
89
+ expect(head([unloaded, CLAUDE], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
90
+ expect(head([unloaded], [])).toBeUndefined();
91
+ });
92
+
93
+ /* THE CHAIN — what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
94
+ * case, not the exotic one: the account a helper shares with the chat runs out mid-afternoon, and the whole
95
+ * point of the list is that the click still lands on the next rung down. */
96
+
97
+ 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([
99
+ { provider: `codex`, model: `gpt-5.6` },
100
+ { provider: `gemini`, model: `gemini-3-flash` },
101
+ { provider: `claude`, model: `claude-haiku-4-5-20251001` },
102
+ ]);
103
+ });
104
+
105
+ 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([
107
+ { provider: `codex`, model: `gpt-5.6` },
108
+ { provider: `claude`, model: `claude-haiku-4-5` },
109
+ ]);
110
+ });
111
+
112
+ test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
113
+ // Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
114
+ // 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` }]);
116
+ });
117
+
118
+ test("names each model once, however many times the list repeats it", () => {
119
+ // 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([
121
+ { provider: `claude`, model: `claude-haiku-4-5` },
122
+ ]);
123
+ });
124
+
125
+ test("Auto is a ladder too — every connected provider's cheap rung, best first", () => {
126
+ expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
127
+ { provider: `gemini`, model: `gemini-3-flash-lite` },
128
+ { provider: `claude`, model: `claude-haiku-4-5-20251001` },
129
+ { provider: `kimi`, model: `kimi-k3` },
130
+ ]);
88
131
  });
89
132
 
90
133
  /* A MODEL ENDPOINT the user configured is a provider like any other here, and the reason it has to be is the
91
134
  * settings row: its options are built from the same picker catalog, so a pin naming one that this resolver
92
- * dropped would print one model's name under the sparkle and spend a different account entirely. */
135
+ * dropped would print one model's name in the settings row and spend a different account entirely. */
93
136
  const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
94
137
 
95
138
  test("honours a pin on a configured endpoint — the whole id, not the half before its slash", () => {
96
- expect(resolveQuickModel([CLAUDE, OLLAMA], `endpoint/ollama:qwen3-coder`)).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
139
+ expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
97
140
  // And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
98
141
  // itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
99
142
  // as "endpoint" and the model as "ollama:qwen3-coder" — a pin that silently resolves to nothing.
@@ -104,12 +147,12 @@ test("leaves Auto to the providers whose price is known, rather than reaching fo
104
147
  // Claude publishes a Haiku-class row; the endpoint's ids carry no tier word at all, so they are UNRANKED and
105
148
  // lose on tier. What a turn on a user's own model API costs is not a fact this repo holds, and Auto should
106
149
  // not be asserting one.
107
- expect(resolveQuickModel([CLAUDE, OLLAMA], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
150
+ expect(head([CLAUDE, OLLAMA], [])).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
108
151
  });
109
152
 
110
153
  test("still answers from an endpoint when it is the only thing configured", () => {
111
154
  // No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
112
155
  // Kimi above — the point here is that a sandbox whose only model API is its owner's still gets an answer
113
156
  // rather than the disabled "nothing connected" button.
114
- expect(resolveQuickModel([offline(CLAUDE), OLLAMA], ``)).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
157
+ expect(head([offline(CLAUDE), OLLAMA], [])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
115
158
  });
@@ -1,19 +1,27 @@
1
- import { ACCESS_COST, accessFor, PROVIDERS } from "./agent-catalog.js";
1
+ import { ACCESS_COST, accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js";
2
2
  import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
3
3
  import type { AgentProvider } from "./schemas.js";
4
4
 
5
- /* THE QUICK MODEL — the cheap, fast model a one-click helper spends instead of the frontier model the chat runs
6
- * on. Today that is the commit box's autofill; anything else of that shape (a branch name, a PR description)
7
- * reads the same answer, which is the reason this is a `quickModel` setting rather than a commit-message one.
5
+ /* THE QUICK MODEL — the cheap, fast model a small automatic job spends instead of the frontier model the chat
6
+ * runs on. Today that is the commit message written when an agent's work lands; anything else of that shape (a
7
+ * branch name, a PR description) reads the same answer, which is the reason this is a `quickModel` setting
8
+ * rather than a commit-message one.
9
+ *
10
+ * IT IS AN ORDER, NOT A MODEL, and that is the whole shape of this file. A single pick is a single point of
11
+ * failure: the account it names spends its allowance on the chat all morning, and every job for the rest of the
12
+ * day fails on a limit while three other connected providers sit idle. So the setting is a LIST
13
+ * read top to bottom, the resolver hands back the whole ladder, and the daemon walks it until one answers.
14
+ * Nothing here decides WHICH failures are worth stepping over — that is the daemon's, since only it has run
15
+ * the call — this side only says what the running order is.
8
16
  *
9
17
  * The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
10
- * the model, and the browser has to NAME it — in the sparkle's tooltip and in the settings row's "Auto (…)"
11
- * label — before anything has been run. Two implementations would drift precisely where it matters most, since
12
- * a tooltip promising Haiku while the daemon bills Opus is worse than no tooltip.
18
+ * the model, and the browser has to NAME it — in the settings row's "Auto (…)" label — before anything has been
19
+ * run. Two implementations would drift precisely where it matters most, since a label promising Haiku while the
20
+ * daemon bills Opus is worse than no label.
13
21
  *
14
- * The default is DERIVED, NEVER STORED. `quickModel` ships as "" and that empty string means "work it out from
15
- * whatever is connected right now", so connecting a Google account tomorrow improves the default by itself and
16
- * disconnecting the pinned provider degrades to Auto instead of to a dead button. Same instinct as the rest of
22
+ * The default is DERIVED, NEVER STORED. `quickModel` ships EMPTY and that means "work it out from whatever is
23
+ * connected right now", so connecting a Google account tomorrow improves the default by itself and
24
+ * disconnecting a pinned provider degrades to Auto instead of to a dead button. Same instinct as the rest of
17
25
  * this repo's model handling: model-order.ts derives tier and recency from the id and curates nothing, and the
18
26
  * web's defaultModelFor reads the live catalog rather than naming an id that a release will falsify. */
19
27
 
@@ -42,12 +50,13 @@ export interface QuickModelChoice {
42
50
  }
43
51
 
44
52
  // A pinned selection on the wire: `${provider}:${modelId}`, the same key shape the model picker already mints
45
- // for its entries (PickerEntry.key). Empty ⇒ Auto.
53
+ // for its entries (PickerEntry.key). An empty LIST of these ⇒ Auto.
46
54
  export const quickModelKey = (choice: QuickModelChoice): string => `${choice.provider}:${choice.model}`;
47
55
 
48
56
  // Split on the FIRST colon only: a provider id never contains one and a model id might. Exported because the
49
- // key shape is shared: `prepushFixModel` pins the suggested fix session's model the same way, and the dialog
50
- // that seeds from it has to read one back.
57
+ // key shape is shared: `agentRunModels` pins what a surface-started run opens with the same keys in the same
58
+ // order (agent-run-model.ts), and both the daemon (filling an unattended turn) and the dialogs that seed from
59
+ // it have to read one back.
51
60
  export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
52
61
  const separator = pinned.indexOf(`:`);
53
62
  if (separator <= 0 || separator === pinned.length - 1) {
@@ -56,6 +65,14 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
56
65
  return { provider: pinned.slice(0, separator), model: pinned.slice(separator + 1) };
57
66
  };
58
67
 
68
+ /* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
69
+ * has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
70
+ * defensive branch). Beside parsePinned because the two are always wanted together — by any surface that has to
71
+ * name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
72
+ * share no other code with each other. */
73
+ export const pinnedModelLabel = (choice: QuickModelChoice): string =>
74
+ modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
75
+
59
76
  // The cheapest row a provider publishes — its whole catalog read from the cheap end. Undefined for a catalog
60
77
  // that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
61
78
  // asked it.
@@ -79,37 +96,56 @@ const costOf = (provider: AgentProvider): number => {
79
96
  return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
80
97
  };
81
98
 
82
- /* Which model a quick helper runs, given what this sandbox has connected. `pinned` is the stored setting.
99
+ /* AUTO every connected provider's cheapest row, best-first, as a ladder rather than a winner.
83
100
  *
84
- * A pin only holds while its provider is READY: an account the user disconnected would otherwise leave the
85
- * button failing on every click with a credential error, when the sandbox can plainly still answer. Auto taking
86
- * over is the same move the composer already makes when a live catalog stops offering the selected model.
101
+ * Ranked on TIER FIRST, then cost. That order is the point of the feature: the helper exists to not be the
102
+ * frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
103
+ * Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
104
+ * is not paying per token for — and against the one they are.
87
105
  *
88
- * Auto ranks candidates on TIER FIRST, then cost. That order is the point of the feature: the helper exists to
89
- * not be the frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a
90
- * subscription Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the
91
- * channel the user is not paying per token for — and against the one they are.
92
- *
93
- * Undefined when nothing is connected: the caller renders a disabled control that says so, rather than a live
94
- * button that fails on click. */
95
- export const resolveQuickModel = (sources: readonly QuickModelSource[], pinned: string): QuickModelChoice | undefined => {
96
- const requested = parsePinned(pinned);
97
- if (requested !== undefined && sources.some((source) => source.provider === requested.provider && source.ready)) {
98
- // Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
99
- // escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
100
- // would silently run a different model than the settings row names.
101
- return requested;
102
- }
103
- const candidates = sources
106
+ * The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
107
+ * SECOND answer and a sandbox with three accounts connected should not lose its commit messages for six hours
108
+ * because one of them is spent. */
109
+ const autoLadder = (sources: readonly QuickModelSource[]): readonly QuickModelChoice[] =>
110
+ sources
104
111
  .filter((source) => source.ready)
105
112
  .flatMap((source) => {
106
113
  const model = cheapestOf(source);
107
114
  return model === undefined ? [] : [{ provider: source.provider, model }];
108
- });
109
- return candidates.toSorted(
110
- (left, right) =>
111
- tierOf(right.model) - tierOf(left.model) ||
112
- costOf(left.provider) - costOf(right.provider) ||
113
- providerOrder(left.provider) - providerOrder(right.provider),
114
- )[0];
115
+ })
116
+ .toSorted(
117
+ (left, right) =>
118
+ tierOf(right.model) - tierOf(left.model) ||
119
+ costOf(left.provider) - costOf(right.provider) ||
120
+ providerOrder(left.provider) - providerOrder(right.provider),
121
+ );
122
+
123
+ /* WHICH MODELS A QUICK HELPER MAY RUN, IN THE ORDER IT SHOULD TRY THEM, given what this sandbox has connected.
124
+ * `pinned` is the stored setting: an ordered list of `${provider}:${model}` keys, empty for Auto.
125
+ *
126
+ * A pin only holds while its provider is READY: an account the user disconnected would otherwise sit at the
127
+ * head of the chain failing on a credential error, when the sandbox can plainly still answer. Dropping it is
128
+ * the same move the composer already makes when a live catalog stops offering the selected model.
129
+ *
130
+ * THE PINNED LIST IS THE WHOLE ANSWER whenever any of it survives that filter. Auto does NOT get appended
131
+ * underneath, and that is deliberate: a user who writes down three models has said which accounts this feature
132
+ * may spend, and quietly reaching for a fourth when all three are out is exactly the "spend an account they
133
+ * were steering away from" failure a pin exists to prevent. When NONE of the pins is connected any more the
134
+ * list has stopped saying anything about this sandbox, so Auto takes over rather than leaving a dead button.
135
+ *
136
+ * Empty when nothing is connected: the caller renders a control that says so, rather than a live button that
137
+ * fails on click. */
138
+ export const resolveQuickModels = (sources: readonly QuickModelSource[], pinned: readonly string[]): readonly QuickModelChoice[] => {
139
+ const ready = new Set(sources.filter((source) => source.ready).map((source) => source.provider));
140
+ const requested = pinned.flatMap((key) => {
141
+ // Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
142
+ // escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
143
+ // would silently run a different model than the settings row names.
144
+ const choice = parsePinned(key);
145
+ return choice === undefined || !ready.has(choice.provider) ? [] : [choice];
146
+ });
147
+ // The same model twice would spend two attempts proving the same account is out — a real state, since the
148
+ // list is edited by hand and Auto's ladder can rank a provider the user has also pinned.
149
+ const chain = [...new Map(requested.map((choice) => [quickModelKey(choice), choice])).values()];
150
+ return chain.length > 0 ? chain : autoLadder(sources);
115
151
  };
@@ -1,7 +1,8 @@
1
- import { oc } from "@orpc/contract";
1
+ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { describe, expect, it } from "vitest";
3
- import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
4
- import { contractRoutes, routeNameForRequest } from "./routes.js";
3
+ import { z } from "zod";
4
+ import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
5
+ import { contractRoutes, routeNameForRequest, routeShapes } from "./routes.js";
5
6
 
6
7
  const fixture = {
7
8
  vpn: {
@@ -13,6 +14,9 @@ const fixture = {
13
14
  },
14
15
  };
15
16
 
17
+ // A contract whose one route carries real schemas, so a shape can actually change between "builds".
18
+ const shaped = (output: z.ZodType) => ({ vpn: { list: oc.route({ method: "GET", path: "/vpn" }).output(output) } });
19
+
16
20
  describe(`contractRoutes`, () => {
17
21
  it(`names every procedure <group>.<route>, sorted`, () => {
18
22
  expect(contractRoutes(fixture).map((route) => route.name)).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
@@ -65,7 +69,91 @@ describe(`routeNameForRequest`, () => {
65
69
  });
66
70
  });
67
71
 
72
+ describe(`routeShapes`, () => {
73
+ it(`gives the same fingerprint for the same shape, twice`, () => {
74
+ expect(routeShapes(shaped(z.object({ a: z.string() })))).toEqual(routeShapes(shaped(z.object({ a: z.string() }))));
75
+ });
76
+
77
+ it(`changes the fingerprint when a field is added`, () => {
78
+ const before = routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`];
79
+ const after = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
80
+ expect(before).toBeDefined();
81
+ expect(after).not.toBe(before);
82
+ });
83
+
84
+ it(`changes the fingerprint when a field's type changes`, () => {
85
+ const before = routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`];
86
+ const after = routeShapes(shaped(z.object({ a: z.number() })))[`vpn.list`];
87
+ expect(after).not.toBe(before);
88
+ });
89
+
90
+ it(`is blind to the order fields are declared in — a reordered object is not a wire change`, () => {
91
+ const one = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
92
+ const other = routeShapes(shaped(z.object({ b: z.number(), a: z.string() })))[`vpn.list`];
93
+ expect(other).toBe(one);
94
+ });
95
+
96
+ it(`reads a defaulted field differently on the way in than on the way out`, () => {
97
+ // `.default()` makes a field optional going in and required coming out — the same declaration, two
98
+ // wire shapes. Reading both directions the same way would call them identical.
99
+ const one = z.object({ a: z.string().default(`x`) });
100
+ const asOutput = routeShapes(shaped(one))[`vpn.list`];
101
+ const asInput = routeShapes({ vpn: { list: oc.route({ method: "GET", path: "/vpn" }).input(one) } })[`vpn.list`];
102
+ expect(asOutput).toBeDefined();
103
+ expect(asInput).not.toBe(asOutput);
104
+ });
105
+
106
+ it(`treats a route declaring no schemas as a shape of its own`, () => {
107
+ // Not a special case: "this route carries nothing" is a real shape, and a build that later gives it an
108
+ // output has genuinely changed it. All three fixture routes declare nothing, so all three agree.
109
+ const shapes = routeShapes(fixture);
110
+ expect(Object.keys(shapes).toSorted()).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
111
+ expect(new Set(Object.values(shapes)).size).toBe(1);
112
+ expect(routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`]).not.toBe(shapes[`vpn.list`]);
113
+ });
114
+
115
+ it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
116
+ // An oRPC event iterator wraps its output in an opaque type with no schema underneath. The route keeps
117
+ // its NAME (it is still advertised as existing) and simply carries no shape — which reads downstream as
118
+ // "assume compatible". The two neighbours still get fingerprinted, which is the part that matters: one
119
+ // unexpressable route must not cost the whole walk.
120
+ const withStream = {
121
+ vpn: {
122
+ list: fixture.vpn.list,
123
+ watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(eventIterator(z.object({ a: z.string() }))),
124
+ },
125
+ };
126
+ expect(Object.keys(routeShapes(withStream)).toSorted()).toEqual([`vpn.list`]);
127
+ expect(
128
+ contractRoutes(withStream)
129
+ .map((route) => route.name)
130
+ .toSorted(),
131
+ ).toEqual([`vpn.list`, `vpn.watch`]);
132
+ });
133
+ });
134
+
68
135
  describe(`the real sandbox contract`, () => {
136
+ it(`fingerprints all but the streaming routes`, () => {
137
+ const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
138
+ // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these seven
139
+ // cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
140
+ // a route that quietly lost its shape check, which is worth failing a test over.
141
+ expect(unshaped.toSorted()).toEqual([
142
+ `agent.attach`,
143
+ `capabilities.add`,
144
+ `intentic.applyEvents`,
145
+ `intentic.run`,
146
+ `system.events`,
147
+ `system.manageMachineSandbox`,
148
+ `vpn.connect`,
149
+ ]);
150
+ });
151
+
152
+ it(`fingerprints every other route exactly once`, () => {
153
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
154
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 7);
155
+ });
156
+
69
157
  it(`derives a route table with no duplicate names`, () => {
70
158
  expect(SANDBOX_ROUTE_NAMES.length).toBe(SANDBOX_ROUTES.length);
71
159
  expect(new Set(SANDBOX_ROUTE_NAMES).size).toBe(SANDBOX_ROUTE_NAMES.length);