carthagent 1.0.7 → 1.1.0-preview.13

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 (289) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +336 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  58. package/docs/release-notes-native-draft.md +73 -0
  59. package/docs/release-notes-preview.10.md +121 -0
  60. package/docs/release-notes-preview.11.md +76 -0
  61. package/docs/release-notes-preview.12.md +55 -0
  62. package/docs/release-notes-preview.13.md +68 -0
  63. package/docs/release-notes-preview.9.md +130 -0
  64. package/docs/source-preview.md +120 -0
  65. package/evaluate.mjs +28 -1
  66. package/lib/budget.mjs +1 -1
  67. package/lib/codegraph.mjs +1 -1
  68. package/lib/delivery-review.mjs +11 -1
  69. package/lib/delivery.mjs +3 -2
  70. package/lib/environment.mjs +8 -0
  71. package/lib/guidance.mjs +1 -1
  72. package/lib/opencode/cli-runtime.mjs +1171 -0
  73. package/lib/opencode/cli.mjs +252 -0
  74. package/lib/opencode/credentials.mjs +63 -8
  75. package/lib/opencode/demo.mjs +53 -0
  76. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  77. package/lib/opencode/evaluation.mjs +32 -12
  78. package/lib/opencode/headless.mjs +8 -4
  79. package/lib/opencode/provider-policy.mjs +23 -0
  80. package/lib/opencode/review.mjs +37 -16
  81. package/lib/opencode/runtime-artifact.mjs +335 -0
  82. package/lib/opencode/runtime-github-transport.mjs +121 -0
  83. package/lib/opencode/runtime-release.json +36 -0
  84. package/lib/opencode/runtime.mjs +299 -20
  85. package/lib/opencode/service.mjs +77 -35
  86. package/lib/opencode/session-options.mjs +6 -2
  87. package/lib/opencode/update.mjs +222 -0
  88. package/lib/opencode/workspace.mjs +1 -1
  89. package/lib/planning-advice.mjs +2 -2
  90. package/lib/providers/experiential.mjs +2 -2
  91. package/lib/tui/framing.mjs +1 -80
  92. package/lib/update.mjs +10 -1
  93. package/package.json +33 -7
  94. package/plugins/carthagent/README.md +23 -0
  95. package/plugins/carthagent/src/bootstrap.ts +1 -1
  96. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  97. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  98. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  99. package/plugins/carthagent/src/index.ts +71 -24
  100. package/plugins/carthagent/src/rpc.ts +51 -6
  101. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  102. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  103. package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
  104. package/plugins/carthagent/src/server/delivery.ts +227 -112
  105. package/plugins/carthagent/src/server/framing.mjs +80 -0
  106. package/plugins/carthagent/src/server/policy.ts +1 -1
  107. package/plugins/carthagent/src/server/providers.ts +59 -31
  108. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  109. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  110. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  111. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  112. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  113. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  114. package/plugins/carthagent/src/testing.ts +5 -0
  115. package/plugins/carthagent/src/tui.tsx +496 -0
  116. package/plugins/carthagent/src/ui/client.ts +125 -0
  117. package/plugins/carthagent/src/ui/commands.ts +70 -0
  118. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  119. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  120. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  121. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  122. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  123. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  124. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  125. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  126. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  127. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  128. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  129. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  130. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  131. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  132. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  133. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  134. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  135. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  136. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  137. package/plugins/carthagent/src/ui/voyages.tsx +129 -0
  138. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  139. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  140. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  141. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  142. package/plugins/carthagent/tests/commands.test.ts +107 -0
  143. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  144. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  145. package/plugins/carthagent/themes/carthage.json +67 -0
  146. package/plugins/carthagent/tui.ts +26 -1
  147. package/script/ctg/build-owned-runtime.sh +166 -0
  148. package/tests/budget-engine.test.mjs +4 -3
  149. package/tests/cli.test.mjs +52 -96
  150. package/tests/daily-evidence.test.mjs +62 -0
  151. package/tests/delivery-review.test.mjs +11 -0
  152. package/tests/delivery.test.mjs +22 -1
  153. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  154. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  155. package/tests/installed-runtime-contract.test.mjs +43 -0
  156. package/tests/native-cli-host-smoke.mjs +457 -0
  157. package/tests/native-daily-editor-focus.test.mjs +213 -0
  158. package/tests/native-daily-editor.test.mjs +43 -0
  159. package/tests/native-daily-quests-layout.test.mjs +53 -0
  160. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  161. package/tests/native-daily-quests-ui.test.mjs +778 -0
  162. package/tests/native-daily-source.test.mjs +451 -0
  163. package/tests/native-demo-ui.test.mjs +48 -0
  164. package/tests/native-evaluation-admission.test.mjs +24 -0
  165. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  166. package/tests/native-fixture-runtime.test.mjs +111 -0
  167. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  168. package/tests/native-glyph-pty.test.mjs +107 -0
  169. package/tests/native-manual-projects.test.mjs +170 -0
  170. package/tests/native-model-command.test.mjs +67 -0
  171. package/tests/native-owned-runtime-env.test.mjs +140 -0
  172. package/tests/native-paired-guards.test.mjs +464 -0
  173. package/tests/native-provider-policy.test.mjs +31 -0
  174. package/tests/native-pty-invocation.test.mjs +114 -0
  175. package/tests/native-pty-runtime.test.mjs +45 -0
  176. package/tests/native-resume-session.test.mjs +46 -0
  177. package/tests/native-review-consumer.test.mjs +286 -0
  178. package/tests/native-runtime-identity.test.mjs +214 -0
  179. package/tests/native-select-mouse.test.mjs +23 -0
  180. package/tests/native-ship-parity.test.mjs +526 -0
  181. package/tests/native-ship-plan.test.mjs +85 -0
  182. package/tests/native-ship-rpc.test.mjs +71 -0
  183. package/tests/native-ui-commands.test.mjs +6 -0
  184. package/tests/native-voyages-layout.test.mjs +28 -0
  185. package/tests/native-voyages-navigation.test.mjs +169 -0
  186. package/tests/native-voyages-rpc.test.mjs +395 -0
  187. package/tests/native-voyages-ui.test.mjs +277 -0
  188. package/tests/opencode-atomic-write.test.mjs +55 -0
  189. package/tests/opencode-cli.test.mjs +1312 -0
  190. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  191. package/tests/opencode-delivery.test.mjs +16 -0
  192. package/tests/opencode-providers.test.mjs +245 -18
  193. package/tests/opencode-runtime.test.mjs +546 -8
  194. package/tests/opencode-session-controls.test.mjs +15 -0
  195. package/tests/opencode-ship-rpc.test.mjs +71 -0
  196. package/tests/opencode-update.test.mjs +475 -0
  197. package/tests/opencode-workspace.test.mjs +51 -2
  198. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  199. package/tests/release-workflow.test.mjs +46 -0
  200. package/tests/runtime-artifact.test.mjs +269 -0
  201. package/tests/runtime-development-trust.test.mjs +230 -0
  202. package/tests/runtime-github-transport.test.mjs +132 -0
  203. package/tests/runtime-token-env.test.mjs +23 -0
  204. package/tests/server.test.mjs +3 -1
  205. package/tests/ship-telemetry.test.mjs +1 -1
  206. package/tests/source-preview.test.mjs +253 -0
  207. package/tests/test_native_terminal_probe.py +76 -0
  208. package/tests/test_native_terminal_semantics.py +232 -0
  209. package/tests/update.test.mjs +42 -5
  210. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  211. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  212. package/tests/workspace-live.test.mjs +127 -0
  213. package/tools/benchmark/diagnose-request.mjs +59 -36
  214. package/tools/benchmark/eval-common.mjs +3 -3
  215. package/tools/benchmark/evaluators.test.mjs +4 -4
  216. package/tools/benchmark/guard.test.mjs +35 -2
  217. package/tools/benchmark/guard.ts +20 -7
  218. package/tools/benchmark/planning-startup.test.mjs +30 -34
  219. package/tools/benchmark/runner.mjs +12 -7
  220. package/tools/benchmark/runner.test.mjs +23 -7
  221. package/tools/benchmark/upstream-error.test.mjs +16 -2
  222. package/tools/generate-owned-runtime-release.mjs +217 -0
  223. package/tools/installed-runtime-contract.mjs +16 -0
  224. package/tools/manual-runtime-manifest.mjs +39 -0
  225. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  226. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  227. package/tools/native-fixture.mjs +199 -40
  228. package/tools/native-glyph-helpers.py +127 -0
  229. package/tools/native-glyph-pty.py +144 -0
  230. package/tools/native-manual-projects.mjs +259 -0
  231. package/tools/native-manual.mjs +338 -0
  232. package/tools/native-model-picker-acceptance.py +306 -0
  233. package/tools/native-owned-runtime-env.sh +47 -0
  234. package/tools/native-paired-guards.mjs +282 -0
  235. package/tools/native-paired-manifest.mjs +72 -0
  236. package/tools/native-paired-orchestration.sh +51 -0
  237. package/tools/native-plugin-readiness.mjs +33 -0
  238. package/tools/native-plugin-readiness.test.mjs +53 -0
  239. package/tools/native-pty-invocation.mjs +26 -0
  240. package/tools/native-pty-runtime.mjs +83 -0
  241. package/tools/native-pty-smoke.py +147 -24
  242. package/tools/native-public-repo-shell-result.mjs +13 -0
  243. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  244. package/tools/native-runtime-identity.mjs +201 -0
  245. package/tools/native-slash-acceptance-env.py +38 -0
  246. package/tools/native-terminal-semantics.py +876 -0
  247. package/tools/owned-shell-observer/index.ts +16 -0
  248. package/tools/owned-shell-observer/package.json +1 -0
  249. package/tools/owned-visual-clear-env.py +48 -0
  250. package/tools/owned-visual-clear-model.py +21 -0
  251. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  252. package/tools/prepare-native-manual.mjs +122 -0
  253. package/tools/run-native-terminal-paired.sh +403 -0
  254. package/tools/runtime-token-env.mjs +8 -0
  255. package/tools/seed-native-switch-fixture.mjs +79 -0
  256. package/tools/test-native-controls.mjs +3 -1
  257. package/tools/test-native-daily-quests.mjs +65 -0
  258. package/tools/test-native-default-cli.mjs +106 -0
  259. package/tools/test-native-delivery-host.mjs +102 -20
  260. package/tools/test-native-evaluation.mjs +7 -3
  261. package/tools/test-native-glyph-pty.mjs +94 -0
  262. package/tools/test-native-host.mjs +87 -63
  263. package/tools/test-native-outcomes.mjs +3 -1
  264. package/tools/test-native-package.mjs +206 -38
  265. package/tools/test-native-performance.mjs +26 -0
  266. package/tools/test-native-product.mjs +90 -0
  267. package/tools/test-native-pty.mjs +34 -9
  268. package/tools/test-native-public-repo.mjs +224 -0
  269. package/tools/test-native-refresh-reentry.mjs +106 -0
  270. package/tools/test-native-report.mjs +27 -20
  271. package/tools/test-native-review.mjs +75 -0
  272. package/tools/test-native-runtime.mjs +120 -0
  273. package/tools/test-native-ship-models.mjs +176 -0
  274. package/tools/test-native-ship-walkthrough.mjs +96 -0
  275. package/tools/test-native-slash-commands.mjs +125 -0
  276. package/tools/test-native-slash-commands.py +242 -0
  277. package/tools/test-native-swe-host.mjs +224 -55
  278. package/tools/test-native-tasks.mjs +77 -0
  279. package/tools/test-native-terminal-performance.py +887 -0
  280. package/tools/test-native-terminal-readiness.sh +85 -0
  281. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  282. package/tools/test-native-visual-clear-installed.mjs +48 -0
  283. package/tools/test-owned-host-controls.mjs +238 -0
  284. package/tools/test-owned-opencode-runtime.mjs +35 -0
  285. package/tools/test-owned-visual-clear-env.py +120 -0
  286. package/tools/test-owned-visual-clear.py +373 -0
  287. package/tools/verify-draft-runtime-gate.mjs +138 -0
  288. package/tools/verify-owned-runtime-release.mjs +114 -0
  289. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,277 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { canEquip, createVoyagesState, handleVoyagesEvent, voyagesCatalogLabel, voyagesErrorCode, voyagesEventAction } from '../plugins/carthagent/src/ui/voyages-state.mjs';
4
+
5
+ const item = { item: 'hat:beanie', itemKey: 'beanie', name: 'Beanie', slot: 'hat', price: 5, requiresPurchase: true, available: true, eligible: true };
6
+ const snapshot = () => ({ profile: { version: 2, name: 'Captain', shipName: 'Ship', coins: 10, config: { hat: 'none' }, owned: [] }, items: [{ ...item }] });
7
+ const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
8
+ function fixture(overrides = {}) {
9
+ const writes = [];
10
+ const api = {
11
+ snapshot: async () => snapshot(),
12
+ customize: async (input) => { writes.push(['customize', input]); },
13
+ equip: async (input) => { writes.push(['equip', input]); },
14
+ quote: async () => ({ quote: { id: 'q1', item: item.item, price: 5, expiresAt: new Date(20000).toISOString() } }),
15
+ purchase: async (input) => { writes.push(['purchase', input]); },
16
+ retryPurchase: async (input) => { writes.push(['retry', input]); },
17
+ ...overrides,
18
+ };
19
+ let latest;
20
+ const controller = createVoyagesState({ api, now: () => 10000, changed: (next) => { latest = next; } });
21
+ return { controller, writes, api, view: () => latest };
22
+ }
23
+
24
+ test('canceling customization, equip, purchase and pending retry performs zero writes', async () => {
25
+ const { controller, view, writes } = fixture();
26
+ await controller.refresh();
27
+ await controller.customize(async () => ({ name: 'Preview' }), async () => false);
28
+ view().snapshot.profile.owned.push(item.item);
29
+ await controller.equip(item, async () => false);
30
+ view().snapshot.profile.owned = [];
31
+ await controller.purchase(item, async () => false);
32
+ view().snapshot.pending = { quoteId: 'q1', item: item.item, price: 5 };
33
+ await controller.retry(async () => false);
34
+ assert.deepEqual(writes, []);
35
+ });
36
+
37
+ test('quote expiry during confirmation prevents purchase', async () => {
38
+ const { controller, api, writes } = fixture();
39
+ let now = 10000;
40
+ let uiView;
41
+ const ui = createVoyagesState({ api, now: () => now, changed: (next) => { uiView = next; } });
42
+ await ui.refresh();
43
+ await ui.purchase(item, async () => { now = 21000; return true; });
44
+ assert.deepEqual(writes, []);
45
+ assert.match(uiView.error, /no success assumed/);
46
+ assert.equal(uiView.notice, '');
47
+ controller.dispose();
48
+ });
49
+
50
+ test('invalid quote, insufficient funds and ineligible item never purchase', async () => {
51
+ for (const quote of [ { item: 'hat:other', price: 5 }, { item: item.item, price: 11 }, { item: item.item, price: -1 } ]) {
52
+ let confirmed = false;
53
+ const { controller, writes } = fixture({ quote: async () => ({ quote: { ...quote, id: 'q1', expiresAt: new Date(20000).toISOString() } }) });
54
+ await controller.refresh();
55
+ await controller.purchase(item, async () => { confirmed = true; return true; });
56
+ assert.equal(confirmed, false);
57
+ assert.deepEqual(writes, []);
58
+ }
59
+ const { controller, writes } = fixture();
60
+ await controller.refresh();
61
+ await controller.purchase({ ...item, eligible: false }, async () => true);
62
+ assert.deepEqual(writes, []);
63
+ });
64
+
65
+ test('network/auth failures clear protected state and never report success', async () => {
66
+ const { controller, view, api } = fixture();
67
+ await controller.refresh();
68
+ api.snapshot = async () => { throw new Error('secret unsafe detail'); };
69
+ await controller.refresh();
70
+ assert.equal(view().snapshot, undefined);
71
+ assert.equal(view().notice, '');
72
+ assert.doesNotMatch(view().error, /secret/);
73
+ });
74
+
75
+ test('account switch and disposal ignore stale callbacks and dialog confirmation', async () => {
76
+ for (const dispose of [false, true]) {
77
+ const pending = deferred();
78
+ const { controller, view, writes } = fixture();
79
+ await controller.refresh();
80
+ const action = controller.customize(async () => ({ name: 'Other' }), () => pending.promise);
81
+ await Promise.resolve();
82
+ if (dispose) controller.dispose(); else controller.invalidate();
83
+ pending.resolve(true);
84
+ await action;
85
+ assert.deepEqual(writes, []);
86
+ assert.equal(view().snapshot, undefined);
87
+ assert.equal(view().notice, '');
88
+ }
89
+ const stale = deferred();
90
+ const { controller, view } = fixture({ snapshot: () => stale.promise });
91
+ const read = controller.refresh();
92
+ controller.invalidate();
93
+ stale.resolve(snapshot());
94
+ await read;
95
+ assert.equal(view().snapshot, undefined);
96
+ });
97
+
98
+ test('uncertain purchase reconciles pending but never reports success or retries automatically', async () => {
99
+ let purchases = 0;
100
+ const { controller, view } = fixture({
101
+ purchase: async () => { purchases++; throw { type: 'voyagesError', message: 'network_error', data: { code: 'network_error', uncertain: true, status: 0 } }; },
102
+ snapshot: async () => ({ ...snapshot(), ...(purchases ? { pending: { quoteId: 'q1', item: item.item, price: 5 } } : {}) }),
103
+ });
104
+ await controller.refresh();
105
+ await controller.purchase(item, async () => true);
106
+ assert.equal(purchases, 1);
107
+ assert.equal(view().notice, '');
108
+ assert.equal(view().snapshot.pending.quoteId, 'q1');
109
+ await controller.purchase(item, async () => true);
110
+ assert.equal(purchases, 1);
111
+ });
112
+
113
+ test('zero-priced paid offers require purchase; owned/free catalog entries equip explicitly', async () => {
114
+ const { controller, view, writes } = fixture();
115
+ await controller.refresh();
116
+ assert.equal(canEquip(view().snapshot, { ...item, price: 0 }), false);
117
+ assert.equal(canEquip(view().snapshot, { ...item, requiresPurchase: false }), true);
118
+ view().snapshot.profile.owned.push(item.item);
119
+ assert.equal(canEquip(view().snapshot, item), true);
120
+ await controller.equip(item, async () => true);
121
+ assert.deepEqual(writes, [['equip', { version: 2, slot: 'hat', item: 'beanie' }]]);
122
+ });
123
+
124
+ test('busy guard covers asynchronous dialogs and writes', async () => {
125
+ const pending = deferred();
126
+ const { controller, writes } = fixture();
127
+ await controller.refresh();
128
+ const first = controller.customize(async () => ({ name: 'Preview' }), () => pending.promise);
129
+ await controller.purchase(item, async () => true);
130
+ await controller.refresh();
131
+ pending.resolve(true);
132
+ await first;
133
+ assert.deepEqual(writes, [['customize', { version: 2, name: 'Preview' }]]);
134
+ });
135
+
136
+ test('native RPC error envelope exposes only validated structured Voyages codes', async () => {
137
+ assert.equal(voyagesErrorCode({ type: 'voyagesError', message: 'unsafe token', data: { code: 'terms_changed', status: 409, uncertain: false } }), 'terms_changed');
138
+ for (const error of [new Error('secret'), { type: 'other', data: { code: 'terms_changed' } }, { type: 'voyagesError', data: { code: 'secret value\n' } }, { type: 'voyagesError', data: {} }]) {
139
+ assert.equal(voyagesErrorCode(error), 'voyages_request_failed');
140
+ }
141
+ const { controller, view, api } = fixture();
142
+ await controller.refresh();
143
+ api.customize = async () => { throw { type: 'voyagesError', message: 'unsafe detail', data: { code: 'version_conflict', status: 409, uncertain: false } }; };
144
+ await controller.customize(async () => ({ name: 'Draft' }), async () => true);
145
+ assert.match(view().error, /^version_conflict\./);
146
+ assert.doesNotMatch(view().error, /unsafe/);
147
+ assert.equal(view().notice, '');
148
+ });
149
+
150
+ test('native event mapping respects location and integration scope', () => {
151
+ const location = { directory: '/selected', workspaceID: 'w1' };
152
+ for (const type of ['provider.updated', 'integration.updated', 'credential.updated', 'server.connected']) {
153
+ assert.equal(voyagesEventAction({ type, data: {} }, location), 'refresh');
154
+ assert.equal(voyagesEventAction({ type, location, data: {} }, location), 'refresh');
155
+ assert.equal(voyagesEventAction({ type, location: { directory: '/other' }, data: {} }, location), 'ignore');
156
+ assert.equal(voyagesEventAction({ type, location: { ...location, workspaceID: 'w2' }, data: {} }, location), 'ignore');
157
+ }
158
+ assert.equal(voyagesEventAction({ type: 'credential.switched', data: { integrationID: 'experiential-labs', credentialID: null } }, location), 'refresh');
159
+ assert.equal(voyagesEventAction({ type: 'credential.switched', data: { integrationID: 'anthropic' } }, location), 'ignore');
160
+ assert.equal(voyagesEventAction({ type: 'session.updated', data: {} }, location), 'ignore');
161
+ assert.equal(voyagesEventAction({ type: 'server.disconnected' }, location), 'clear');
162
+ });
163
+
164
+ test('credential event immediately clears old profile/dialog and freshly loads signed-in account', async () => {
165
+ const { controller, view, api, writes } = fixture();
166
+ await controller.refresh();
167
+ const confirmation = deferred();
168
+ const action = controller.customize(async () => ({ name: 'Old account draft' }), () => confirmation.promise);
169
+ await Promise.resolve();
170
+ const nextAccount = deferred();
171
+ let signal;
172
+ api.snapshot = (abortSignal) => { signal = abortSignal; return nextAccount.promise; };
173
+ let closed = 0;
174
+ const reloading = handleVoyagesEvent(controller, { type: 'credential.switched', data: { integrationID: 'experiential-labs', credentialID: 'new' } }, { directory: '/selected' }, () => { closed++; });
175
+ assert.equal(view().snapshot, undefined);
176
+ assert.equal(closed, 1);
177
+ assert.ok(signal instanceof AbortSignal);
178
+ confirmation.resolve(true);
179
+ await action;
180
+ assert.deepEqual(writes, []);
181
+ const next = snapshot(); next.profile.name = 'New account';
182
+ nextAccount.resolve(next);
183
+ await reloading;
184
+ assert.equal(view().snapshot.profile.name, 'New account');
185
+ await handleVoyagesEvent(controller, { type: 'credential.switched', data: { integrationID: 'anthropic' } }, { directory: '/selected' }, () => { closed++; });
186
+ assert.equal(closed, 1);
187
+ assert.equal(view().snapshot.profile.name, 'New account');
188
+ handleVoyagesEvent(controller, { type: 'server.disconnected' }, { directory: '/selected' });
189
+ assert.equal(view().snapshot, undefined);
190
+ });
191
+
192
+ test('retired null prices stay unknown, never quote/purchase or imply free equipment', async () => {
193
+ for (const requiresPurchase of [true, false, null]) {
194
+ const retired = { ...item, price: null, requiresPurchase };
195
+ let quotes = 0;
196
+ const { controller, view, writes } = fixture({
197
+ snapshot: async () => ({ ...snapshot(), items: [retired] }),
198
+ quote: async () => { quotes++; throw new Error('must not quote'); },
199
+ });
200
+ await controller.refresh();
201
+ assert.equal(view().snapshot.items[0].price, null);
202
+ assert.match(voyagesCatalogLabel(view().snapshot, retired), /Retired.*price unavailable/);
203
+ assert.doesNotMatch(voyagesCatalogLabel(view().snapshot, retired), /Free|null coins|0 coins/);
204
+ assert.equal(canEquip(view().snapshot, retired), false);
205
+ await controller.purchase(retired, async () => true);
206
+ assert.equal(quotes, 0);
207
+ assert.deepEqual(writes, []);
208
+ view().snapshot.profile.owned.push(retired.item);
209
+ assert.match(voyagesCatalogLabel(view().snapshot, retired), /^Owned.*price unavailable/);
210
+ assert.equal(canEquip(view().snapshot, retired), true);
211
+ assert.equal(canEquip(view().snapshot, { ...retired, available: false }), false);
212
+ assert.equal(canEquip(view().snapshot, { ...retired, eligible: false }), false);
213
+ await controller.equip(retired, async () => true);
214
+ assert.deepEqual(writes, [['equip', { version: 2, slot: 'hat', item: 'beanie' }]]);
215
+ assert.equal(quotes, 0);
216
+ }
217
+ });
218
+
219
+ test('pending retry stays bound to Q1 when another TUI replaces server pending with Q2', async () => {
220
+ let pending = { quoteId: 'q1', item: item.item, price: 5 };
221
+ const { controller, writes } = fixture({ snapshot: async () => ({ ...snapshot(), pending }) });
222
+ await controller.refresh();
223
+ await controller.retry(async (shown) => {
224
+ assert.equal(shown.quoteId, 'q1');
225
+ pending = { quoteId: 'q2', item: 'hat:other', price: 8 };
226
+ return true;
227
+ });
228
+ assert.deepEqual(writes, [['retry', { quoteId: 'q1' }]]);
229
+ assert.equal('state' in controller, false);
230
+ });
231
+
232
+ test('human wait longer than a minute does not expire name/equip confirmation; timeout starts on each network call', async () => {
233
+ let time = 0;
234
+ const timers = [];
235
+ const writes = [];
236
+ const current = snapshot(); current.profile.owned.push(item.item);
237
+ const ui = createVoyagesState({
238
+ now: () => time,
239
+ timeoutSignal: () => { const abort = new AbortController(); timers.push({ time, abort }); return abort.signal; },
240
+ api: {
241
+ snapshot: async () => current,
242
+ customize: async (input, signal) => { assert.equal(signal.aborted, false); writes.push(input); },
243
+ equip: async (input, signal) => { assert.equal(signal.aborted, false); writes.push(input); },
244
+ },
245
+ });
246
+ await ui.refresh();
247
+ await ui.customize(async () => {
248
+ assert.equal(timers.length, 1);
249
+ time = 120000;
250
+ timers[0].abort.abort();
251
+ return { name: 'After a long wait' };
252
+ }, async () => { time += 120000; return true; });
253
+ await ui.equip(item, async () => { time += 120000; return true; });
254
+ assert.equal(writes.length, 2);
255
+ assert.equal(timers[1].time, 240000);
256
+ assert.equal(timers[3].time, 360000);
257
+ });
258
+
259
+ test('per-request timeout is reported visibly, not silently dropped as an expired UI lifetime', async () => {
260
+ const timers = [];
261
+ let latest;
262
+ const failed = deferred();
263
+ const ui = createVoyagesState({
264
+ timeoutSignal: () => { const abort = new AbortController(); timers.push(abort); return abort.signal; },
265
+ changed: (next) => { latest = next; if (next.error) failed.resolve(); },
266
+ api: { snapshot: (signal) => new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })) },
267
+ });
268
+ const loading = ui.refresh();
269
+ timers[0].abort(new DOMException('Timed out', 'TimeoutError'));
270
+ await failed.promise;
271
+ assert.match(latest.error, /no success assumed/);
272
+ assert.equal(latest.snapshot, undefined);
273
+ timers[1].abort(new DOMException('Timed out', 'TimeoutError'));
274
+ await loading;
275
+ assert.equal(latest.busy, false);
276
+ assert.equal(latest.notice, '');
277
+ });
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { cleanupAtomicWriteStaging, withFileLock } from '../lib/opencode/runtime.mjs';
7
+
8
+ const UUID = '12345678-1234-1234-1234-123456789abc';
9
+
10
+ test('atomicWrite cleanup matches only exact basename/PID/UUID staging files and unlinks links, not targets or directories', async t => {
11
+ const root = await mkdtemp(join(tmpdir(), 'atomic-staging-'));
12
+ t.after(() => rm(root, { recursive: true, force: true }));
13
+ const file = join(root, 'record.[private].json');
14
+ const target = join(root, 'target');
15
+ await writeFile(file, 'main remains');
16
+ await writeFile(target, 'target remains');
17
+ const staged = `${file}.${process.pid}.${UUID}.tmp`;
18
+ const link = `${file}.123.${UUID.toUpperCase()}.tmp`;
19
+ await writeFile(staged, Buffer.from([0xff, 0x00, 0x80]));
20
+ await symlink(target, link);
21
+ const kept = [
22
+ `${file}.0.${UUID}.tmp`, `${file}.01.${UUID}.tmp`, `${file}.-1.${UUID}.tmp`,
23
+ `${file}.123.not-uuid.tmp`, `${file}.123.${UUID}.tmp.backup`,
24
+ `${file}.backup.123.${UUID}.tmp`, join(root, `other.json.123.${UUID}.tmp`),
25
+ ];
26
+ for (const path of kept) await writeFile(path, 'unrelated remains');
27
+ const directory = `${file}.456.${UUID}.tmp`;
28
+ await mkdir(directory);
29
+ await writeFile(join(directory, 'child'), 'child remains');
30
+ await withFileLock(`${file}.lock`, () => cleanupAtomicWriteStaging(file));
31
+ for (const path of [staged, link]) await assert.rejects(lstat(path), { code: 'ENOENT' });
32
+ assert.equal(await readFile(file, 'utf8'), 'main remains');
33
+ assert.equal(await readFile(target, 'utf8'), 'target remains');
34
+ for (const path of kept) assert.equal(await readFile(path, 'utf8'), 'unrelated remains');
35
+ assert.equal(await readFile(join(directory, 'child'), 'utf8'), 'child remains');
36
+ });
37
+
38
+ test('atomicWrite cleanup rejects missing, non-directory and linked parent directories without touching targets', async t => {
39
+ const root = await mkdtemp(join(tmpdir(), 'atomic-staging-parent-'));
40
+ t.after(() => rm(root, { recursive: true, force: true }));
41
+ const directory = join(root, 'real');
42
+ await mkdir(directory);
43
+ const staged = join(directory, `record.json.123.${UUID}.tmp`);
44
+ await writeFile(staged, 'must remain');
45
+ const linked = join(root, 'linked');
46
+ await symlink(directory, linked, 'junction');
47
+ const nonDirectory = join(root, 'not-a-directory');
48
+ await writeFile(nonDirectory, 'must remain');
49
+ for (const parent of [join(root, 'missing'), nonDirectory, linked]) {
50
+ await assert.rejects(cleanupAtomicWriteStaging(join(parent, 'record.json')));
51
+ assert.equal(await readFile(staged, 'utf8'), 'must remain');
52
+ }
53
+ assert.deepEqual(await readdir(directory), [`record.json.123.${UUID}.tmp`]);
54
+ assert.equal(await readFile(nonDirectory, 'utf8'), 'must remain');
55
+ });