carthagent 1.0.7 → 1.1.0-beta.1

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 (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,280 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import type { Context } from '@opencode/plugin/promise/plugin';
6
+ import { CloudClient, cloudControlUrl } from '../../../../lib/cloud/client.mjs';
7
+ import { atomicWrite, runtimePaths, withFileLock } from '../../../../lib/opencode/runtime.mjs';
8
+ import { Voyages, voyagesSlots, voyagesAppearance, type VoyagesProfile, type VoyagesItem, type VoyagesQuote, type VoyagesPending } from '../voyages-rpc';
9
+ import { verifiedRewardSource } from './daily-source-rpc';
10
+
11
+ type Port = Pick<Context, 'integration'> & Partial<Pick<Context, 'location' | 'session' | 'worktree'>>;
12
+ type Call = import('@opencode/plugin/promise/rpc').RpcCallContext<typeof Voyages.methods.snapshot>;
13
+ type CloudPort = { request(path: string, options: { method: string; accessToken: string; signal: AbortSignal; body?: unknown }): Promise<unknown> };
14
+ type Pending = VoyagesPending & { key: string };
15
+ type State = { quotes: VoyagesQuote[]; pending?: Pending; rewardPreview?: { sessionID: string; runId: string; snapshot: string } };
16
+ class Failure extends Error {
17
+ constructor(readonly code: string, readonly status = 0, readonly uncertain = false) { super(code); }
18
+ }
19
+ const codes = new Set(('authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope milestone_locked purchase_required insufficient_coins profile_not_found not_found not_available version_conflict already_owned quote_expired terms_changed quote_consumed key_conflict rate_limited quote_limit voyages_unavailable internal_error invalid_body unknown_field version_required invalid_name invalid_config invalid_item invalid_id invalid_key wallet_limit').split(' '));
20
+ const noDebit = new Set(('invalid_body unknown_field invalid_id invalid_key milestone_locked insufficient_coins not_found not_available already_owned quote_expired terms_changed authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope').split(' '));
21
+ for (const code of ['delivery_ineligible', 'delivery_conflict', 'snapshot_claimed', 'reward_daily_limit', 'progression_limit', 'invalid_delivery']) codes.add(code);
22
+ type Journal = { get(file: string): Promise<unknown>; set(file: string, state: State): Promise<void> };
23
+ const fileJournal: Journal = {
24
+ async get(file) {
25
+ let handle;
26
+ try { handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
27
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; }
28
+ try {
29
+ const details = await handle.stat();
30
+ check(details.isFile() && details.size <= 65536);
31
+ const bytes = Buffer.alloc(65537);
32
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
33
+ check(bytesRead <= 65536 && bytesRead === details.size);
34
+ return JSON.parse(bytes.toString('utf8', 0, bytesRead));
35
+ } finally { await handle.close(); }
36
+ },
37
+ async set(file, state) {
38
+ const contents = JSON.stringify(state); check(Buffer.byteLength(contents) <= 65536);
39
+ await atomicWrite(file, contents, 0o600);
40
+ // Persist the rename as well as the file bytes on POSIX filesystems.
41
+ if (process.platform !== 'win32') {
42
+ const directory = await open(join(file, '..'), 'r');
43
+ try { await directory.sync(); } finally { await directory.close(); }
44
+ }
45
+ },
46
+ };
47
+ function check(ok: unknown): asserts ok { if (!ok) throw new Failure('invalid_response'); }
48
+ function obj(value: unknown): Record<string, unknown> { check(value && typeof value === 'object' && !Array.isArray(value)); return value as Record<string, unknown>; }
49
+ function str(value: unknown, max = 128): string { check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value)); return value; }
50
+ function int(value: unknown, min = 0): number { check(typeof value === 'number' && Number.isSafeInteger(value) && value >= min); return value; }
51
+ function num(value: unknown): number { check(typeof value === 'number' && Number.isFinite(value) && value >= 0); return value; }
52
+ function key(value: unknown): string { const result = str(value, 64); check(/^[a-zA-Z0-9_-]+$/.test(result)); return result; }
53
+ function fullItem(value: unknown): string { const result = str(value); const parts = result.split(':'); const [slot, item] = parts; check(parts.length === 2 && voyagesSlots.includes(slot as typeof voyagesSlots[number]) && item); key(item); return result; }
54
+ function choice(value: unknown, choices: readonly string[]): string { check(choices.includes(value as string)); return value as string; }
55
+ function profile(value: unknown): VoyagesProfile {
56
+ const p = obj(value), c = obj(p.config);
57
+ const config = Object.fromEntries(voyagesSlots.map(slot => [slot, key(c[slot])])) as VoyagesProfile['config'];
58
+ config.skin = choice(c.skin, voyagesAppearance.skin);
59
+ config.hair = choice(c.hair, voyagesAppearance.hair);
60
+ config.pattern = choice(c.pattern, voyagesAppearance.pattern);
61
+ check(Array.isArray(p.owned) && p.owned.length <= 10000);
62
+ const owned = p.owned.map(fullItem); check(new Set(owned).size === owned.length);
63
+ return { version: int(p.version, 1), name: str(p.name, 20), shipName: str(p.shipName, 28), coins: int(p.coins), totalXp: int(p.totalXp), level: int(p.level, 1), xpIntoLevel: int(p.xpIntoLevel), xpForLevel: int(p.xpForLevel, 1), km: num(p.km), weeklyKm: num(p.weeklyKm), config, owned };
64
+ }
65
+ function item(value: unknown, owned: ReadonlySet<string>): VoyagesItem {
66
+ const v = obj(value);
67
+ check(typeof v.requiresPurchase === 'boolean' && typeof v.available === 'boolean' && typeof v.eligible === 'boolean');
68
+ const id = fullItem(v.item), itemKey = key(v.itemKey);
69
+ check(id === `${v.slot}:${itemKey}`);
70
+ // Retired paid equipment remains owned; a missing offer price is not zero.
71
+ check(v.price !== null || owned.has(id));
72
+ check(v.reason === null || typeof v.reason === 'string' && v.reason.length <= 128 && !/[\u0000-\u001f\u007f]/.test(v.reason));
73
+ return { item: id, name: str(v.name), slot: v.slot as VoyagesItem['slot'], itemKey, price: v.price === null ? null : int(v.price), requiresPurchase: v.requiresPurchase, available: v.available, eligible: v.eligible, reason: v.reason as string | null };
74
+ }
75
+ function quote(value: unknown): VoyagesQuote {
76
+ const q = obj(value);
77
+ const iso = str(q.expiresAt, 24);
78
+ check(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(iso));
79
+ const epoch = Date.parse(iso);
80
+ check(Number.isFinite(epoch) && epoch > 0 && epoch <= 8.64e15);
81
+ check(new Date(epoch).toISOString().slice(0, 19) === iso.slice(0, 19));
82
+ return { id: str(q.id), item: fullItem(q.item), price: int(q.price), expiresAt: new Date(epoch).toISOString() };
83
+ }
84
+ function safe(error: unknown, uncertain = false): Failure {
85
+ if (error instanceof Failure) return new Failure(error.code, error.status, uncertain || error.uncertain);
86
+ const e = error as { code?: string; status?: number } | null;
87
+ const status = Number.isInteger(e?.status) && e!.status! >= 400 && e!.status! <= 599 ? e!.status! : 0;
88
+ return new Failure(codes.has(e?.code ?? '') ? e!.code! : status ? 'request_rejected' : 'request_failed', status, uncertain);
89
+ }
90
+
91
+ /** No credentials enter RPC DTOs. Native resolve owns serialized OAuth refresh. */
92
+ export function createVoyagesRpcHandlers(ctx: Port, options: { cloudClient?: CloudPort; baseUrl?: string; now?: () => number; profileRoot?: string; journal?: Journal } = {}) {
93
+ const baseUrl = (options.baseUrl ?? cloudControlUrl()).replace(/\/+$/, '');
94
+ const cloud: CloudPort = options.cloudClient ?? new CloudClient({ baseUrl });
95
+ const now = options.now ?? Date.now;
96
+ // Native plugin storage is not relied on for cross-workspace/profile recovery.
97
+ // This journal contains purchase terms/keys only, never authentication tokens.
98
+ const directory = join(runtimePaths({ root: options.profileRoot }).state, 'carthagent', 'voyages');
99
+ const journal = options.journal ?? fileJournal;
100
+ async function access() {
101
+ const active = await ctx.integration.connection.active('experiential-labs');
102
+ if (!active || active.type !== 'credential' || active.method !== 'oauth') throw new Failure('authentication_required', 401);
103
+ let credential;
104
+ try { credential = await ctx.integration.connection.resolve(active); } catch { throw new Failure('authentication_required', 401); }
105
+ if (credential?.type !== 'oauth' || credential.methodID !== 'carthagent-device' || !credential.access) throw new Failure('authentication_required', 401);
106
+ const scope = join(directory, createHash('sha256').update(JSON.stringify([baseUrl, active.id])).digest('hex'));
107
+ return { id: active.id, token: credential.access, scope };
108
+ }
109
+ type Access = Awaited<ReturnType<typeof access>>;
110
+ async function current(a: Access, signal: AbortSignal) {
111
+ signal.throwIfAborted();
112
+ const active = await ctx.integration.connection.active('experiential-labs');
113
+ if (!active || active.type !== 'credential' || active.id !== a.id || active.method !== 'oauth') throw new Failure('account_changed', 401);
114
+ }
115
+ async function request(a: Access, path: string, signal: AbortSignal, method = 'GET', body?: unknown) {
116
+ await current(a, signal);
117
+ const result = await cloud.request(`/v1/voyages/${path}`, { method, accessToken: a.token, signal, ...(body === undefined ? {} : { body }) });
118
+ await current(a, signal);
119
+ return obj(result);
120
+ }
121
+ async function read(a: Access): Promise<State> {
122
+ // Always read fresh after acquiring the profile-wide cross-process lock.
123
+ const raw = await journal.get(`${a.scope}.json`);
124
+ if (raw === undefined) return { quotes: [] };
125
+ const s = obj(raw); check(Array.isArray(s.quotes) && s.quotes.length <= 20);
126
+ const quotes = s.quotes.map(quote).filter(q => Date.parse(q.expiresAt) > now());
127
+ let rewardPreview: State['rewardPreview'];
128
+ if (s.rewardPreview !== undefined) {
129
+ const r = obj(s.rewardPreview);
130
+ if (r.sessionID !== undefined) rewardPreview = { sessionID: str(r.sessionID), runId: str(r.runId), snapshot: str(r.snapshot) };
131
+ }
132
+ if (s.pending === undefined) return { quotes, ...(rewardPreview ? { rewardPreview } : {}) };
133
+ const p = obj(s.pending);
134
+ return { quotes, pending: { quoteId: str(p.quoteId), key: str(p.key), item: fullItem(p.item), price: int(p.price) }, ...(rewardPreview ? { rewardPreview } : {}) };
135
+ }
136
+ const write = (a: Access, state: State) => journal.set(`${a.scope}.json`, state);
137
+ function run<T>(fn: (input: Record<string, unknown>, call: Call, a: Access) => Promise<T>) {
138
+ return async (input: unknown, call: Call) => {
139
+ try {
140
+ call.signal.throwIfAborted();
141
+ const a = await access();
142
+ return await withFileLock(`${a.scope}.lock`, async () => { await current(a, call.signal); return fn(obj(input), call, a); });
143
+ } catch (error) {
144
+ const e = safe(error);
145
+ return call.error('voyagesError', e.code, { code: e.code, status: e.status, uncertain: e.uncertain });
146
+ }
147
+ };
148
+ }
149
+ async function buy(a: Access, state: State, call: Call, replay: boolean) {
150
+ const pending = state.pending!;
151
+ try {
152
+ const r = await request(a, 'purchases', call.signal, 'POST', { quoteId: pending.quoteId, key: pending.key });
153
+ const receipt = { receiptId: str(r.receiptId), item: fullItem(r.item), price: int(r.price) };
154
+ check(receipt.item === pending.item && receipt.price === pending.price);
155
+ await write(a, { quotes: [], ...(state.rewardPreview ? { rewardPreview: state.rewardPreview } : {}) });
156
+ await current(a, call.signal);
157
+ return receipt;
158
+ } catch (error) {
159
+ const e = safe(error);
160
+ // Backend checks exact committed replay under the player lock BEFORE
161
+ // expiry/debit. Thus 409 quote_expired proves this pair never committed.
162
+ // Auth, key conflicts and all other replay errors remain uncertain.
163
+ if ((!replay && e.status >= 400 && e.status < 500 && noDebit.has(e.code)) || (replay && e.status === 409 && e.code === 'quote_expired')) {
164
+ try { await write(a, { quotes: [], ...(state.rewardPreview ? { rewardPreview: state.rewardPreview } : {}) }); } catch { throw new Failure('storage_failed', 0, true); }
165
+ throw e;
166
+ }
167
+ throw safe(error, true);
168
+ }
169
+ }
170
+ async function versioned(a: Access, path: string, input: Record<string, unknown>, call: Call, method: string) {
171
+ try { return { profile: profile((await request(a, path, call.signal, method, input)).profile) }; }
172
+ catch (error) {
173
+ const e = safe(error);
174
+ // Never retry a versioned write. Unknown outcomes require profile reconcile.
175
+ throw safe(error, e.status === 0 || e.status >= 500);
176
+ }
177
+ }
178
+ function selection(input: Record<string, unknown>) {
179
+ choice(input.slot, voyagesSlots);
180
+ key(input.item);
181
+ }
182
+ return {
183
+ previewReward: run(async (input, call, a) => {
184
+ const state = await read(a);
185
+ const prior = state.rewardPreview;
186
+ let source: Awaited<ReturnType<typeof rewardSource>> | undefined;
187
+ let sourceError: unknown;
188
+ try { source = await rewardSource(input.sessionID, call.signal); } catch (error) { sourceError = error; }
189
+ if (prior && prior.sessionID === input.sessionID && (!source || source.runId === prior.runId)) {
190
+ try {
191
+ const existing = rewardResult(await request(a, `delivery-rewards/${encodeURIComponent(prior.runId)}`, call.signal), prior.runId, true);
192
+ return { runId: prior.runId, snapshot: prior.snapshot, coins: existing.reward.coins, xp: existing.reward.xp, km: existing.reward.km, dailyLimit: 3 };
193
+ } catch (error) {
194
+ if ((error as { status?: number }).status !== 404) throw safe(error);
195
+ }
196
+ }
197
+ if (!source) throw sourceError;
198
+ const policy = obj((await request(a, 'delivery-rewards/policy', call.signal)).policy);
199
+ check(policy.version === 'verified-delivery-beta-v1' && policy.basis === 'local_harness_attested' && policy.coins === 25 && policy.xp === 100 && policy.km === 1 && policy.dailyLimit === 3);
200
+ await write(a, { ...state, rewardPreview: { sessionID: str(input.sessionID), runId: source.runId, snapshot: source.snapshot } });
201
+ return { runId: source.runId, snapshot: source.snapshot, coins: int(policy.coins, 1), xp: int(policy.xp, 1), km: int(policy.km, 1), dailyLimit: int(policy.dailyLimit, 1) };
202
+ }),
203
+ claimReward: run(async (input, call, a) => {
204
+ const preview = (await read(a)).rewardPreview;
205
+ if (!preview || preview.sessionID !== input.sessionID || preview.runId !== input.runId || preview.snapshot !== input.snapshot) throw new Failure('reward_preview_required');
206
+ // Read the receipt first: a committed claim remains recoverable after
207
+ // source changes or restart. Only an explicit 404 permits a new claim.
208
+ try {
209
+ const existing = await request(a, `delivery-rewards/${encodeURIComponent(preview.runId)}`, call.signal);
210
+ return rewardResult(existing, preview.runId, true);
211
+ } catch (error) {
212
+ if ((error as { status?: number }).status !== 404) throw safe(error, true);
213
+ }
214
+ const source = await rewardSource(input.sessionID, call.signal);
215
+ if (source.runId !== input.runId || source.snapshot !== input.snapshot) throw new Failure('delivery_changed');
216
+ try {
217
+ const result = await request(a, 'delivery-rewards', call.signal, 'POST', source);
218
+ check(typeof result.replayed === 'boolean');
219
+ return rewardResult(result, source.runId, result.replayed as boolean);
220
+ } catch (error) { throw safe(error, !(error as { status?: number })?.status || (error as { status?: number }).status! >= 500); }
221
+ }),
222
+ snapshot: run(async (_input, call, a) => {
223
+ const bootstrap = await request(a, 'bootstrap', call.signal);
224
+ check(bootstrap.signedIn === true);
225
+ const p = profile(bootstrap.profile);
226
+ const shop = await request(a, 'shop', call.signal);
227
+ check(Array.isArray(shop.items) && shop.items.length <= 10000);
228
+ const owned = new Set(p.owned);
229
+ const items = shop.items.map(value => item(value, owned)); check(new Set(items.map(i => i.item)).size === items.length);
230
+ const state = await read(a); await current(a, call.signal);
231
+ const pending = state.pending;
232
+ return { profile: p, items, ...(pending ? { pending: { quoteId: pending.quoteId, item: pending.item, price: pending.price } } : {}) };
233
+ }),
234
+ customize: run(async (input, call, a) => versioned(a, 'profile', input, call, 'PATCH')),
235
+ equip: run(async (input, call, a) => { selection(input); return versioned(a, 'equip', input, call, 'POST'); }),
236
+ quote: run(async (input, call, a) => {
237
+ selection(input);
238
+ const state = await read(a);
239
+ if (state.pending) throw new Failure('purchase_pending', 0, true);
240
+ const q = quote((await request(a, 'quotes', call.signal, 'POST', input)).quote);
241
+ check(q.item === `${input.slot}:${input.item}` && Date.parse(q.expiresAt) > now() && Date.parse(q.expiresAt) <= now() + 125000);
242
+ await write(a, { ...state, quotes: [...state.quotes.filter(v => v.id !== q.id), q].slice(-20) });
243
+ await current(a, call.signal);
244
+ return { quote: q };
245
+ }),
246
+ purchase: run(async (input, call, a) => {
247
+ const state = await read(a);
248
+ if (state.pending) throw new Failure('purchase_pending', 0, true);
249
+ const q = state.quotes.find(q => q.id === input.quoteId);
250
+ if (!q) throw new Failure('quote_required');
251
+ await current(a, call.signal);
252
+ state.pending = { quoteId: q.id, key: randomUUID(), item: q.item, price: q.price };
253
+ // A single durable record contains both the authoritative terms and pair.
254
+ // Do not send anything if persistence fails or cancellation was observed.
255
+ await write(a, state);
256
+ return buy(a, state, call, false);
257
+ }),
258
+ retryPurchase: run(async (input, call, a) => {
259
+ const state = await read(a);
260
+ if (!state.pending) throw new Failure('no_pending_purchase');
261
+ if (input.quoteId !== state.pending.quoteId) throw new Failure('pending_changed', 0, true);
262
+ return buy(a, state, call, true);
263
+ }),
264
+ };
265
+ function rewardResult(result: Record<string, unknown>, runId: string, replayed: boolean) {
266
+ const r = obj(result.reward);
267
+ check(r.runId === runId && r.basis === 'local_harness_attested' && r.policyVersion === 'verified-delivery-beta-v1' && r.coins === 25 && r.xp === 100 && r.km === 1);
268
+ check(Number.isFinite(Date.parse(str(r.awardedAt))));
269
+ return { reward: { id: str(r.id), runId: str(r.runId), policyVersion: str(r.policyVersion), coins: int(r.coins, 1), xp: int(r.xp, 1), km: int(r.km, 1), awardedAt: str(r.awardedAt), basis: str(r.basis) }, replayed };
270
+ }
271
+ async function rewardSource(sessionID: unknown, signal: AbortSignal) {
272
+ if (!ctx.location || !ctx.session || !ctx.worktree) throw new Failure('delivery_ineligible');
273
+ try { return await verifiedRewardSource({ location: ctx.location, session: ctx.session, worktree: ctx.worktree }, str(sessionID), signal); }
274
+ catch (error) { throw new Failure(error instanceof Error && ['delivery_changed', 'not_owned'].includes(error.message) ? error.message : 'delivery_ineligible'); }
275
+ }
276
+ }
277
+
278
+ export async function registerVoyagesRpc(ctx: Context) {
279
+ return ctx.rpc.register(Voyages, createVoyagesRpcHandlers(ctx));
280
+ }
@@ -46,8 +46,13 @@ export async function setupNativeTestPlugin(context: Parameters<typeof registerS
46
46
  const registration = await context.rpc.register(Carthagent, {
47
47
  health: async () => ({ protocol: 1, hostVersion: context.app.version, directory: context.location.directory }),
48
48
  echo: async input => ({ text: (input as { text: string }).text }),
49
+ budgetStatus: async () => { throw new Error('Use TestDiagnostics.state for budgets in the isolated delivery test plugin.'); },
49
50
  authStart: async () => { throw new Error('Device authorization is not available in the isolated delivery test plugin.'); },
50
51
  authCancel: async () => ({ cancelled: false }),
52
+ shipAccount: async () => { throw new Error('Ship account access is not available in the isolated delivery test plugin.'); },
53
+ shipUsage: async () => { throw new Error('Ship usage access is not available in the isolated delivery test plugin.'); },
54
+ shipBilling: async () => { throw new Error('Ship billing is not available in the isolated delivery test plugin.'); },
55
+ shipLogout: async () => { throw new Error('Ship credential changes are not available in the isolated delivery test plugin.'); },
51
56
  });
52
57
  cleanups.push(async () => registration.dispose());
53
58
  const diagnostics = await context.rpc.register(TestDiagnostics, {