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,52 @@
1
+ import { Rpc } from '@opencode/plugin/rpc';
2
+
3
+ /** Potentially sensitive, local-only selection material. Nothing is selected for upload. */
4
+ export interface DailySourceReport {
5
+ runId: string;
6
+ revision: string;
7
+ planRevision: number;
8
+ freshness: 'current' | 'stale';
9
+ goal: string;
10
+ change: string;
11
+ checks: Array<{ id: string; before: 'not_run' | 'failed'; after: 'passed' | 'failed' | 'not_run'; revision: string; result: string }>;
12
+ /** Model-authored review/resolution claims, not independent verification. */
13
+ findings: Array<{ id: string; issue: string; evidence: string; resolution: string }>;
14
+ /** At least one field or item was omitted; the UI must warn before selection. */
15
+ truncated: boolean;
16
+ }
17
+
18
+ const id = { type: 'string', minLength: 1, maxLength: 128 } as const;
19
+ const fingerprint = { type: 'string', minLength: 64, maxLength: 64 } as const;
20
+ const goal = { type: 'string', maxLength: 1024 } as const;
21
+ const check = {
22
+ type: 'object', properties: {
23
+ id, before: { type: 'string', enum: ['not_run', 'failed'] },
24
+ after: { type: 'string', enum: ['passed', 'failed', 'not_run'] },
25
+ revision: fingerprint, result: goal,
26
+ }, required: ['id', 'before', 'after', 'revision', 'result'], additionalProperties: false,
27
+ } as const;
28
+ const finding = {
29
+ type: 'object', properties: { id, issue: { type: 'string', maxLength: 768 }, evidence: { type: 'string', maxLength: 1536 }, resolution: goal },
30
+ required: ['id', 'issue', 'evidence', 'resolution'], additionalProperties: false,
31
+ } as const;
32
+ const report = {
33
+ type: 'object', properties: {
34
+ runId: id, revision: fingerprint, planRevision: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
35
+ freshness: { type: 'string', enum: ['current', 'stale'] }, goal, change: { type: 'string', maxLength: 3072 },
36
+ checks: { type: 'array', items: check, maxItems: 12 }, findings: { type: 'array', items: finding, maxItems: 5 },
37
+ truncated: { type: 'boolean' },
38
+ }, required: ['runId', 'revision', 'planRevision', 'freshness', 'goal', 'change', 'checks', 'findings', 'truncated'], additionalProperties: false,
39
+ } as const;
40
+
41
+ /** Read-only local boundary, deliberately separate from authenticated/cloud RPCs. */
42
+ export const DailySource = Rpc.define({
43
+ id: 'carthagent.daily-source',
44
+ methods: {
45
+ report: {
46
+ input: { type: 'object', properties: { sessionID: id }, required: ['sessionID'], additionalProperties: false },
47
+ output: { type: 'object', properties: { source: { anyOf: [report, { type: 'null' }] } }, required: ['source'], additionalProperties: false },
48
+ errors: { dailySourceError: { type: 'object', properties: { code: id }, required: ['code'], additionalProperties: false } },
49
+ },
50
+ },
51
+ events: {},
52
+ });
@@ -1,37 +1,78 @@
1
1
  import { Plugin } from '@opencode/plugin';
2
+ import { resolve } from 'node:path';
2
3
  import { Carthagent } from './rpc';
4
+ import { registerSessionControls } from './server/session-controls';
5
+ import { registerWorkspaceRpc } from './server/workspace-rpc';
6
+ import { registerPullRequests } from './server/pull-request-rpc';
7
+ import { registerArchive } from './server/archive-rpc';
3
8
  import { registerProviders } from './server/providers';
4
9
  import { createDeviceAuthController } from './server/device-auth.ts';
5
10
  import { shipAgentDir } from '../../../lib/ship-auth.mjs';
6
- import { registerSessionControls } from './server/session-controls';
7
11
  import { registerNativeDelivery } from './server/native-delivery';
12
+ import { createShipRpcHandlers } from './server/ship-rpc';
13
+ import { registerVoyagesRpc } from './server/voyages-rpc';
14
+ import { registerDailyQuestsRpc } from './server/daily-quests-rpc';
15
+ import { registerDailySourceRpc } from './server/daily-source-rpc';
16
+ import { registerShipAgents, registerShipSkills } from './server/ship-agents';
8
17
 
9
- async function cleanup(resources: Array<() => Promise<void>>, originalError?: unknown) {
10
- const failures: unknown[] = [];
11
- for (const dispose of resources.reverse()) {
12
- try { await dispose(); }
13
- catch (error) { failures.push(error); }
14
- }
15
- if (originalError !== undefined && failures.length) {
16
- throw new AggregateError([originalError, ...failures], 'Plugin setup failed and one or more completed registrations could not be disposed.');
17
- }
18
- if (originalError !== undefined) throw originalError;
19
- if (failures.length) throw new AggregateError(failures, 'One or more plugin registrations could not be disposed.');
20
- }
21
-
22
- export async function setupCarthagent(context: Parameters<typeof registerSessionControls>[0], options: { delivery?: boolean; deliveryOptions?: Parameters<typeof registerNativeDelivery>[1] } = {}) {
18
+ export async function setupCarthagent(
19
+ context: Parameters<typeof registerSessionControls>[0],
20
+ options: { delivery?: boolean; deliveryOptions?: Parameters<typeof registerNativeDelivery>[1] } = {},
21
+ ) {
23
22
  const cleanups: Array<() => Promise<void>> = [];
23
+ const dispose = async () => {
24
+ const failures: unknown[] = [];
25
+ for (const cleanup of cleanups.splice(0).reverse()) {
26
+ try { await cleanup(); }
27
+ catch (error) { failures.push(error); }
28
+ }
29
+ if (failures.length) throw new AggregateError(failures, 'Carthagent plugin cleanup failed');
30
+ };
31
+ const deviceAuth = createDeviceAuthController({ env: process.env, agentDir: shipAgentDir() });
32
+ cleanups.push(() => deviceAuth.cleanup());
24
33
  try {
34
+ cleanups.push(await registerShipAgents(context));
35
+ cleanups.push(await registerShipSkills(context));
36
+ cleanups.push(await registerProviders(context, { deviceAuth }));
25
37
  cleanups.push(await registerSessionControls(context));
26
- const deviceAuth = createDeviceAuthController({ env: process.env, agentDir: shipAgentDir() });
27
- cleanups.push(() => deviceAuth.cleanup());
28
- const rpcRegistration = await context.rpc.register(Carthagent, {
38
+ cleanups.push(await registerWorkspaceRpc(context, context.options));
39
+ cleanups.push(await registerPullRequests(context));
40
+ if (options.delivery !== false) cleanups.push(await registerNativeDelivery(context, { ...sessionOptionDefaults(context.options), ...options.deliveryOptions }));
41
+ let configuredRoots;
42
+ try { configuredRoots = process.env.CTG_NATIVE_ARCHIVE_ROOTS ? JSON.parse(process.env.CTG_NATIVE_ARCHIVE_ROOTS) : undefined; }
43
+ catch { throw new Error('Invalid native archive roots from the CTG host'); }
44
+ const roots = context.options.archiveRoots ?? configuredRoots ?? [resolve(shipAgentDir(process.env), 'sessions')];
45
+ if (!Array.isArray(roots) || roots.some(root => typeof root !== 'string')) throw new Error('Invalid native archive roots from the CTG host');
46
+ const archive = await registerArchive(context, { ...context.options, archiveRoots: roots });
47
+ cleanups.push(() => archive.dispose());
48
+ const voyages = await registerVoyagesRpc(context);
49
+ cleanups.push(() => voyages.dispose());
50
+ const daily = await registerDailyQuestsRpc(context);
51
+ cleanups.push(() => daily.dispose());
52
+ const dailySource = await registerDailySourceRpc(context);
53
+ cleanups.push(() => dailySource.dispose());
54
+ const shipHandlers = createShipRpcHandlers(context);
55
+ const registration = await context.rpc.register(Carthagent, {
29
56
  health: async () => ({
30
57
  protocol: 1,
31
58
  hostVersion: context.app.version,
32
59
  directory: context.location.directory,
60
+ capabilities: options.delivery === false ? [] : ['delivery.session-options.v1', 'delivery.budgets.v1', 'delivery.policy-overrides.v1'],
33
61
  }),
34
- echo: async input => ({ text: (input as { text: string }).text }),
62
+ echo: async (input) => ({ text: (input as { text: string }).text }),
63
+ budgetStatus: async (input, call) => {
64
+ call.signal.throwIfAborted();
65
+ const { sessionID } = input as { sessionID: string };
66
+ const session = await context.session.get({ sessionID }, { signal: call.signal });
67
+ if (session.location.directory !== context.location.directory) return call.error('budgetUnavailable', 'Session is outside this workspace location.', { reason: 'wrong-location' });
68
+ const saved = await context.storage.get(`session/${sessionID}`) as { budget?: { tools: number; repairs: number; startedAt: number; limits: { tools: number; seconds: number; repairs: number }; stopped: string | null } } | undefined;
69
+ const budget = saved?.budget;
70
+ if (!budget) return call.error('budgetUnavailable', 'Session budget has not been initialized.', { reason: 'not-initialized' });
71
+ return {
72
+ tools: budget.tools, maxTools: budget.limits.tools, seconds: Math.max(0, Math.floor((Date.now() - budget.startedAt) / 1000)),
73
+ maxSeconds: budget.limits.seconds, repairs: budget.repairs, maxRepairs: budget.limits.repairs, stopped: budget.stopped,
74
+ };
75
+ },
35
76
  authStart: async (_input, call) => {
36
77
  call.signal.throwIfAborted();
37
78
  const flow = await deviceAuth.start(call.signal);
@@ -49,14 +90,20 @@ export async function setupCarthagent(context: Parameters<typeof registerSession
49
90
  call.signal.throwIfAborted();
50
91
  return { cancelled: deviceAuth.cancel((input as { flowId: string }).flowId) };
51
92
  },
93
+ ...shipHandlers,
52
94
  });
53
- cleanups.push(() => rpcRegistration.dispose());
54
- cleanups.push(await registerProviders(context, { deviceAuth }));
55
- if (options.delivery) cleanups.push(await registerNativeDelivery(context, options.deliveryOptions));
95
+ cleanups.push(() => registration.dispose());
96
+ return dispose;
56
97
  } catch (error) {
57
- await cleanup(cleanups, error);
98
+ try { await dispose(); }
99
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Carthagent plugin setup failed'); }
100
+ throw error;
58
101
  }
59
- return async () => cleanup(cleanups);
60
102
  }
61
103
 
62
104
  export default Plugin.define({ id: 'carthagent', setup: setupCarthagent });
105
+
106
+ function sessionOptionDefaults(options: Record<string, unknown>) {
107
+ const allowed = ['maxTools', 'maxSeconds', 'maxRepairs', 'codegraphMaxFiles', 'bashCap', 'workflow', 'validators', 'context', 'strict', 'delivery', 'protectExisting', 'rewriteCap', 'toolOutputCap', 'turnDelayMs', 'jev', 'jevProvider'];
108
+ return Object.fromEntries(allowed.filter(key => options[key] !== undefined).map(key => [key, options[key]]));
109
+ }
@@ -7,16 +7,42 @@ export const Carthagent = Rpc.define({
7
7
  health: {
8
8
  input: { type: 'object', additionalProperties: false },
9
9
  output: {
10
- type: 'object',
11
- properties: {
12
- protocol: { type: 'integer', const: 1 },
13
- hostVersion: { type: 'string' },
14
- directory: { type: 'string' },
15
- },
10
+ type: 'object',
11
+ properties: {
12
+ protocol: { type: 'integer', const: 1 },
13
+ hostVersion: { type: 'string' },
14
+ directory: { type: 'string' },
15
+ capabilities: { type: 'array', items: { type: 'string', enum: ['delivery.session-options.v1', 'delivery.budgets.v1', 'delivery.policy-overrides.v1'] }, uniqueItems: true },
16
+ },
16
17
  required: ['protocol', 'hostVersion', 'directory'],
17
18
  additionalProperties: false,
18
19
  },
19
20
  },
21
+ budgetStatus: {
22
+ input: {
23
+ type: 'object', properties: { sessionID: { type: 'string', minLength: 1 } },
24
+ required: ['sessionID'], additionalProperties: false,
25
+ },
26
+ output: {
27
+ type: 'object', properties: {
28
+ tools: { type: 'integer', minimum: 0 },
29
+ maxTools: { type: 'integer', minimum: 0 },
30
+ seconds: { type: 'integer', minimum: 0 },
31
+ maxSeconds: { type: 'integer', minimum: 0 },
32
+ repairs: { type: 'integer', minimum: 0 },
33
+ maxRepairs: { type: 'integer', minimum: 0 },
34
+ stopped: { type: ['string', 'null'], enum: ['elapsed-time', 'tool-calls', 'repair-rounds', null] },
35
+ },
36
+ required: ['tools', 'maxTools', 'seconds', 'maxSeconds', 'repairs', 'maxRepairs', 'stopped'],
37
+ additionalProperties: false,
38
+ },
39
+ errors: {
40
+ budgetUnavailable: {
41
+ type: 'object', properties: { reason: { type: 'string', enum: ['wrong-location', 'not-initialized'] } },
42
+ required: ['reason'], additionalProperties: false,
43
+ },
44
+ },
45
+ },
20
46
  echo: {
21
47
  input: {
22
48
  type: 'object',
@@ -59,6 +85,25 @@ export const Carthagent = Rpc.define({
59
85
  additionalProperties: false,
60
86
  },
61
87
  },
88
+ shipAccount: {
89
+ input: { type: 'object', additionalProperties: false },
90
+ output: { type: 'object', properties: { account: { type: 'object', additionalProperties: true }, error: { type: 'string' } }, required: ['account'], additionalProperties: false },
91
+ errors: { unauthenticated: { type: 'object', properties: {}, additionalProperties: false } },
92
+ },
93
+ shipUsage: {
94
+ input: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 200 } }, additionalProperties: false },
95
+ output: { type: 'object', properties: { usage: { type: 'object', additionalProperties: true } }, required: ['usage'], additionalProperties: false },
96
+ },
97
+ shipBilling: {
98
+ input: { type: 'object', properties: { action: { type: 'string', enum: ['checkout', 'portal'] } }, required: ['action'], additionalProperties: false },
99
+ output: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'], additionalProperties: false },
100
+ errors: { unauthenticated: { type: 'object', properties: {}, additionalProperties: false } },
101
+ },
102
+ shipLogout: {
103
+ input: { type: 'object', properties: { session: { type: 'string', minLength: 1, maxLength: 128 } }, additionalProperties: false },
104
+ output: { type: 'object', properties: { revoked: { type: 'boolean' }, credentialID: { type: 'string' } }, required: ['revoked'], additionalProperties: false },
105
+ errors: { unauthenticated: { type: 'object', properties: {}, additionalProperties: false } },
106
+ },
62
107
  },
63
108
  events: {
64
109
  changed: {
@@ -3,6 +3,7 @@ import { boundedCodeNavResult } from "../../../../lib/codegraph-query.mjs"
3
3
  import { resolve } from "node:path"
4
4
  import type { Context } from "@opencode/plugin/promise/plugin"
5
5
  import type { Tool } from "@opencode/schema/tool"
6
+ import { effectiveSessionOptions } from "../../../../lib/opencode/session-options.mjs"
6
7
 
7
8
  type CodeNavInput = { op: string; name?: string; path?: string; container?: string; limit?: number; offset?: number }
8
9
 
@@ -28,13 +29,19 @@ export async function registerCodegraph(ctx: Context, options: { maxFiles?: numb
28
29
  const input = rawInput as CodeNavInput
29
30
  const session = await ctx.session.get({ sessionID: toolContext.sessionID })
30
31
  const root = resolve(session.location.directory, session.subpath || ".")
31
- let index = indexes.get(root)
32
+ const defaults = {
33
+ ...ctx.options,
34
+ ...(options.maxFiles === undefined ? {} : { codegraphMaxFiles: options.maxFiles }),
35
+ }
36
+ const configuredMaxFiles = (effectiveSessionOptions(session, defaults) as Record<string, unknown>).codegraphMaxFiles
37
+ const maxFiles = typeof configuredMaxFiles === "number" ? configuredMaxFiles : 8000
38
+ if (!Number.isSafeInteger(maxFiles) || maxFiles < 1) throw Error("codegraph maxFiles must be a positive safe integer")
39
+ const cacheKey = `${root}\0${maxFiles}`
40
+ let index = indexes.get(cacheKey)
32
41
  if (!index) {
33
- const maxFiles = options.maxFiles ?? 8000
34
- if (!Number.isSafeInteger(maxFiles) || maxFiles < 1) throw Error("codegraph maxFiles must be a positive safe integer")
35
42
  index = createCodeIndex(root, { maxFiles })
36
- index.catch(() => indexes.delete(root))
37
- indexes.set(root, index)
43
+ index.catch(() => indexes.delete(cacheKey))
44
+ indexes.set(cacheKey, index)
38
45
  }
39
46
  const current = await index
40
47
  await current.refresh()
@@ -0,0 +1,277 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open } from 'node:fs/promises';
4
+ import { dirname, 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, cleanupAtomicWriteStaging, runtimePaths, withFileLock } from '../../../../lib/opencode/runtime.mjs';
8
+ import { dailyRequest } from '../daily-evidence.mjs';
9
+ import { DailyQuests, type DailyEvidence, type DailySubmission, type DailyQuest, type DailySlate, type DailyRecord } from '../daily-quests-rpc';
10
+ import { providerConstants } from './providers';
11
+
12
+ type Port = Pick<Context, 'integration'>;
13
+ type Call = import('@opencode/plugin/promise/rpc').RpcCallContext<typeof DailyQuests.methods.snapshot>;
14
+ type CloudPort = { request(path: string, options: { method: string; accessToken: string; signal: AbortSignal; body?: unknown }): Promise<unknown> };
15
+ type State = { version: 1; records: DailyRecord[] };
16
+ type Journal = { get(file: string): Promise<unknown>; set(file: string, state: State): Promise<void>; cleanup?(file: string): Promise<void> };
17
+ const MAX_FILE = 262144, MAX_RECORDS = 64, DAY = 86400000;
18
+ const { SHIP_INTEGRATION_ID, OAUTH_METHOD } = providerConstants;
19
+ class Failure extends Error {
20
+ constructor(readonly code: string, readonly status = 0, readonly uncertain = false) { super(code); }
21
+ }
22
+ const codes = new Set(('authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope not_found rate_limited voyages_unavailable daily_quests_unavailable daily_grading_unavailable daily_unavailable daily_consent_required invalid_daily_evidence invalid_daily_day invalid_body unknown_field invalid_id key_conflict daily_quest_complete daily_quest_expired daily_submission_active daily_submission_limit daily_outcome_claimed internal_error').split(' '));
23
+ const freshRejectionCodes = new Set(('authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope not_found rate_limited daily_consent_required invalid_daily_evidence invalid_body unknown_field invalid_id key_conflict daily_quest_complete daily_quest_expired daily_submission_active daily_submission_limit daily_outcome_claimed').split(' '));
24
+ function safe(error: unknown, uncertain = false): Failure {
25
+ if (error instanceof Failure) return new Failure(error.code, error.status, uncertain || error.uncertain);
26
+ const e = error as { code?: string; status?: number } | null;
27
+ const status = Number.isInteger(e?.status) && e!.status! >= 400 && e!.status! <= 599 ? e!.status! : 0;
28
+ return new Failure(codes.has(e?.code ?? '') ? e!.code! : status ? 'request_rejected' : 'request_failed', status, uncertain);
29
+ }
30
+ function check(ok: unknown): asserts ok { if (!ok) throw new Failure('invalid_response'); }
31
+ function obj(value: unknown): Record<string, unknown> { check(value && typeof value === 'object' && !Array.isArray(value)); return value as Record<string, unknown>; }
32
+ function str(value: unknown, max = 128): string {
33
+ check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f-\u009f\p{Cs}]/u.test(value)); return value;
34
+ }
35
+ function uuid(value: unknown): string { const s = str(value, 36); check(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(s)); return s.toLowerCase(); }
36
+ function choice<T extends string>(value: unknown, choices: readonly T[]): T { check(choices.includes(value as T)); return value as T; }
37
+ function iso(value: unknown): string {
38
+ const s = str(value, 32);
39
+ check(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,6})?Z$/.test(s) && Number.isFinite(Date.parse(s)));
40
+ check(new Date(Date.parse(s)).toISOString().slice(0, 19) === s.slice(0, 19)); return s;
41
+ }
42
+ function date(value: unknown): string {
43
+ const s = str(value, 10); check(/^\d{4}-\d\d-\d\d$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString().slice(0, 10) === s); return s;
44
+ }
45
+ const nullableIso = (value: unknown) => value === null ? null : iso(value);
46
+ function flags(v: Record<string, unknown>) { check(v.assessmentBasis === 'tui_submitted' && v.rewards === false); return { assessmentBasis: 'tui_submitted' as const, rewards: false as const }; }
47
+ function submission(value: unknown, expectedId?: string, expectedQuest?: string): DailySubmission {
48
+ const s = obj(value), id = uuid(s.id), questId = uuid(s.questId);
49
+ check((expectedId === undefined || id === expectedId) && (expectedQuest === undefined || questId === expectedQuest));
50
+ const status = choice(s.status, ['queued', 'grading', 'graded', 'failed'] as const);
51
+ const decision = s.decision === null ? null : choice(s.decision, ['assessed_complete', 'not_met', 'needs_evidence'] as const);
52
+ let scores: Record<string, number> | null = null;
53
+ if (s.scores !== null) {
54
+ const entries = Object.entries(obj(s.scores)); check(entries.length > 0 && entries.length <= 20);
55
+ scores = Object.fromEntries(entries.map(([k, v]) => { str(k, 64); check(typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1); return [k, v]; }));
56
+ }
57
+ check(Number.isInteger(s.attempts) && (s.attempts as number) >= 0 && (s.attempts as number) <= 4);
58
+ const finishedAt = nullableIso(s.finishedAt), nextAttemptAt = nullableIso(s.nextAttemptAt);
59
+ const model = s.model === null ? null : str(s.model), rubricVersion = s.rubricVersion === null ? null : str(s.rubricVersion, 256);
60
+ const errorCode = s.errorCode === null ? null : choice(s.errorCode, ['daily_grader_invalid', 'daily_grader_unavailable', 'daily_grader_timeout', 'daily_grader_aborted', 'daily_payload_expired', 'daily_attempts_exhausted'] as const);
61
+ const terminal = status === 'graded' || status === 'failed';
62
+ check(terminal === (finishedAt !== null) && (status === 'queued' || nextAttemptAt === null));
63
+ check(status === 'graded' ? decision !== null && scores !== null && model !== null && rubricVersion !== null : decision === null && scores === null && model === null && rubricVersion === null);
64
+ return { id, questId, status, decision, scores, model, rubricVersion, attempts: s.attempts as number, createdAt: iso(s.createdAt), consentAt: iso(s.consentAt), finishedAt, nextAttemptAt, errorCode, ...flags(s) };
65
+ }
66
+ function quest(value: unknown): DailyQuest {
67
+ const q = obj(value), d = obj(q.definition);
68
+ check(Array.isArray(d.criteria) && d.criteria.length > 0 && d.criteria.length <= 20);
69
+ const criteria = d.criteria.map(v => { const c = obj(v); return { id: str(c.id, 64), text: str(c.text, 4096) }; });
70
+ check(new Set(criteria.map(c => c.id)).size === criteria.length);
71
+ const status = choice(q.status, ['open', 'assessed_complete'] as const), completedAt = nullableIso(q.completedAt);
72
+ check((status === 'assessed_complete') === (completedAt !== null));
73
+ return { id: uuid(q.id), day: date(q.day), definition: { templateId: str(d.templateId), title: str(d.title, 256), kind: choice(d.kind, ['change', 'tests', 'review'] as const), rubricVersion: str(d.rubricVersion, 256), criteria }, status, endsAt: iso(q.endsAt), completedAt, ...flags(q) };
74
+ }
75
+ function slate(value: unknown): DailySlate {
76
+ const s = obj(value); check(Array.isArray(s.items) && s.items.length === 3);
77
+ const items = s.items.map(quest), day = date(s.day), endsAt = iso(s.endsAt);
78
+ check(new Set(items.map(q => q.id)).size === 3 && new Set(items.map(q => q.definition.kind)).size === 3 && items.every(q => q.day === day && q.endsAt === endsAt));
79
+ return { day, endsAt, policyVersion: str(s.policyVersion), items, ...flags(s) };
80
+ }
81
+ const terminal = (r: DailyRecord) => r.submission?.status === 'graded' || r.submission?.status === 'failed';
82
+ const fileJournal: Journal = {
83
+ cleanup: cleanupAtomicWriteStaging,
84
+ async get(file) {
85
+ let entry;
86
+ try { entry = await lstat(file, { bigint: true }); }
87
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; }
88
+ check(entry.isFile() && entry.size <= BigInt(MAX_FILE));
89
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
90
+ try {
91
+ const details = await handle.stat({ bigint: true });
92
+ // Windows lacks O_NOFOLLOW. Reject links before opening and verify that
93
+ // the opened handle and current directory entry still identify the same
94
+ // regular file before reading any bytes. Keep POSIX's atomic nofollow too.
95
+ const current = await lstat(file, { bigint: true });
96
+ check(details.isFile() && details.size <= BigInt(MAX_FILE) && current.isFile()
97
+ && entry.dev === details.dev && entry.ino === details.ino
98
+ && current.dev === details.dev && current.ino === details.ino);
99
+ const bytes = Buffer.alloc(MAX_FILE + 1);
100
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
101
+ check(bytesRead <= MAX_FILE && BigInt(bytesRead) === details.size);
102
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, bytesRead)));
103
+ } finally { await handle.close(); }
104
+ },
105
+ async set(file, state) {
106
+ const contents = JSON.stringify(state); check(Buffer.byteLength(contents) <= MAX_FILE);
107
+ await atomicWrite(file, contents, 0o600);
108
+ if (process.platform !== 'win32') {
109
+ const directory = await open(dirname(file), 'r');
110
+ try { await directory.sync(); } finally { await directory.close(); }
111
+ }
112
+ },
113
+ };
114
+
115
+ /** Authenticated boundary. Never reads cookies, automatically posts, or uploads
116
+ * project content. Retention is enforced on journal access, not by a background
117
+ * process; dormant profiles/backups can retain bytes until next access. */
118
+ export function createDailyQuestsRpcHandlers(ctx: Port, options: { cloudClient?: CloudPort; baseUrl?: string; now?: () => number; profileRoot?: string; journal?: Journal } = {}) {
119
+ const baseUrl = (options.baseUrl ?? cloudControlUrl()).replace(/\/+$/, '');
120
+ const cloud: CloudPort = options.cloudClient ?? new CloudClient({ baseUrl });
121
+ const journal = options.journal ?? fileJournal, now = options.now ?? Date.now;
122
+ const directory = join(runtimePaths({ root: options.profileRoot }).state, 'carthagent', 'daily-quests');
123
+ async function access() {
124
+ const active = await ctx.integration.connection.active(SHIP_INTEGRATION_ID);
125
+ if (!active || active.type !== 'credential' || active.method !== 'oauth') throw new Failure('authentication_required', 401);
126
+ let credential;
127
+ try { credential = await ctx.integration.connection.resolve(active); } catch { throw new Failure('authentication_required', 401); }
128
+ if (credential?.type !== 'oauth' || credential.methodID !== OAUTH_METHOD || !credential.access) throw new Failure('authentication_required', 401);
129
+ const accountKey = createHash('sha256').update(JSON.stringify([baseUrl, active.id])).digest('hex');
130
+ return { id: active.id, token: credential.access, accountKey, scope: join(directory, accountKey) };
131
+ }
132
+ type Access = Awaited<ReturnType<typeof access>>;
133
+ async function current(a: Access, signal: AbortSignal) {
134
+ signal.throwIfAborted();
135
+ const active = await ctx.integration.connection.active(SHIP_INTEGRATION_ID);
136
+ if (!active || active.type !== 'credential' || active.id !== a.id || active.method !== 'oauth') throw new Failure('account_changed', 401);
137
+ }
138
+ async function request(a: Access, path: string, signal: AbortSignal, body?: DailyRecord['request']) {
139
+ await current(a, signal);
140
+ try {
141
+ const result = await cloud.request(`/v1/voyages/${path}`, { method: body ? 'POST' : 'GET', accessToken: a.token, signal, ...(body ? { body } : {}) });
142
+ await current(a, signal); return obj(result);
143
+ } catch (error) { await current(a, signal); throw error; }
144
+ }
145
+ async function write(a: Access, state: State) {
146
+ try { await journal.set(`${a.scope}.json`, state); } catch { throw new Failure('storage_failed'); }
147
+ }
148
+ async function read(a: Access): Promise<State> {
149
+ try {
150
+ const raw = await journal.get(`${a.scope}.json`);
151
+ if (raw === undefined) return { version: 1, records: [] };
152
+ const s = obj(raw); check(s.version === 1 && Array.isArray(s.records) && s.records.length <= MAX_RECORDS);
153
+ const records = s.records.map(v => {
154
+ const r = obj(v), id = uuid(r.id), questId = uuid(r.questId), createdAt = iso(r.createdAt);
155
+ const accepted = r.submission === null ? null : submission(r.submission, id, questId);
156
+ let frozen: DailyRecord['request'] = null;
157
+ if (r.request !== null) {
158
+ const p = obj(r.request); check(p.consent === true && p.id === id);
159
+ frozen = dailyRequest(p.id, p.evidence) as DailyRecord['request'];
160
+ // No silently repaired payload, extra fields or re-canonicalization
161
+ // of persisted bytes: retry must be the exact previously frozen body.
162
+ check(JSON.stringify(frozen) === JSON.stringify(r.request) && accepted === null);
163
+ }
164
+ return { id, questId, createdAt, request: frozen, submission: accepted };
165
+ });
166
+ check(new Set(records.map(r => r.id)).size === records.length && records.filter(r => r.request).length <= 1);
167
+ const before = JSON.stringify(records);
168
+ const state: State = { version: 1, records: records.filter(r => !terminal(r) || now() - Date.parse(r.createdAt) < 90 * DAY) };
169
+ for (const r of state.records) if (r.request && now() - Date.parse(r.createdAt) >= DAY) r.request = null;
170
+ // Persist erasure before proceeding; never upload if retention storage fails.
171
+ if (JSON.stringify(state.records) !== before) await write(a, state);
172
+ return state;
173
+ } catch (error) { if (error instanceof Failure && error.code === 'storage_failed') throw error; throw new Failure('journal_invalid'); }
174
+ }
175
+ function room(state: State) {
176
+ if (state.records.length < MAX_RECORDS) return;
177
+ const candidates = state.records.filter(terminal).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
178
+ if (!candidates.length) throw new Failure('journal_full');
179
+ state.records = state.records.filter(r => r !== candidates[0]);
180
+ }
181
+ async function reconcile(a: Access, state: State, s: DailySubmission) {
182
+ let r = state.records.find(r => r.id === s.id);
183
+ if (!r) { room(state); r = { id: s.id, questId: s.questId, createdAt: s.createdAt, request: null, submission: null }; state.records.push(r); }
184
+ check(r.questId === s.questId);
185
+ r.submission = s; r.request = null;
186
+ await write(a, state);
187
+ }
188
+ function run<T>(mutation: boolean, fn: (input: Record<string, unknown>, call: Call, a: Access, state: State) => Promise<T>) {
189
+ return async (input: unknown, call: Call) => {
190
+ try {
191
+ call.signal.throwIfAborted(); const a = await access();
192
+ return await withFileLock(`${a.scope}.lock`, async () => {
193
+ await current(a, call.signal); const i = obj(input);
194
+ if (mutation && i.accountKey !== a.accountKey) throw new Failure('account_changed', 401);
195
+ try { await journal.cleanup?.(`${a.scope}.json`); }
196
+ catch { throw new Failure('storage_failed'); }
197
+ const state = await read(a);
198
+ const result = await fn(i, call, a, state); await current(a, call.signal); return result;
199
+ });
200
+ } catch (error) {
201
+ const e = safe(error); return call.error('dailyQuestError', e.code, { code: e.code, status: e.status, uncertain: e.uncertain });
202
+ }
203
+ };
204
+ }
205
+ async function dispatch(a: Access, state: State, r: DailyRecord, call: Call, replay: boolean) {
206
+ try {
207
+ const response = await request(a, `daily-quests/${r.questId}/submissions`, call.signal, r.request);
208
+ const s = submission(response.submission, r.id, r.questId);
209
+ await reconcile(a, state, s); return { submission: s };
210
+ } catch (error) {
211
+ const e = safe(error);
212
+ // Fresh definitive rejections are not accepted jobs: remove the record,
213
+ // not just its payload (null/null is reserved for unresolved dispatches).
214
+ // Opaque/proxy 4xx and every replay failure remain uncertain.
215
+ if (!replay && [400, 401, 403, 404, 409, 429].includes(e.status) && freshRejectionCodes.has(e.code)) {
216
+ try { await write(a, { ...state, records: state.records.filter(record => record !== r) }); }
217
+ catch { throw new Failure('storage_failed', 0, true); }
218
+ throw e;
219
+ }
220
+ throw safe(error, true);
221
+ }
222
+ }
223
+ return {
224
+ snapshot: run(false, async (_input, call, a, state) => ({ accountKey: a.accountKey, slate: slate(await request(a, 'daily-quests', call.signal)), records: state.records })),
225
+ detail: run(false, async (input, call, a, state) => {
226
+ const id = uuid(input.id), response = await request(a, `daily-quests/${id}`, call.signal), q = quest(response);
227
+ check(q.id === id);
228
+ const latestSubmission = response.latestSubmission === null ? null : submission(response.latestSubmission, undefined, id);
229
+ if (latestSubmission) await reconcile(a, state, latestSubmission);
230
+ return { ...q, latestSubmission };
231
+ }),
232
+ submit: run(true, async (input, call, a, state) => {
233
+ if (input.consent !== true) throw new Failure('daily_consent_required', 400);
234
+ let body: NonNullable<DailyRecord['request']>;
235
+ try { body = dailyRequest(input.id, input.evidence) as { id: string; consent: true; evidence: DailyEvidence }; }
236
+ catch { throw new Failure('invalid_daily_evidence', 400); }
237
+ const questId = uuid(input.questId);
238
+ if (state.records.some(r => r.id === body.id)) throw new Failure('submission_exists');
239
+ if (state.records.some(r => r.request !== null)) throw new Failure('submission_pending', 0, true);
240
+ room(state);
241
+ const r: DailyRecord = { id: body.id, questId, createdAt: new Date(now()).toISOString(), request: body, submission: null };
242
+ await current(a, call.signal); state.records.push(r);
243
+ // This durable exact canonical body is the write-ahead boundary. Any
244
+ // failure or cancellation here prevents dispatch, never triggers replay.
245
+ await write(a, state);
246
+ return dispatch(a, state, r, call, false);
247
+ }),
248
+ retry: run(true, async (input, call, a, state) => {
249
+ const id = uuid(input.id), r = state.records.find(r => r.id === id);
250
+ if (!r?.request) throw new Failure('no_pending_payload');
251
+ // Reconcile first. Authentication/conflict/outage errors stop here rather
252
+ // than blindly replaying. Only authoritative absence permits this user's
253
+ // explicit retry of the saved UUID/body, including across UTC midnight.
254
+ let response;
255
+ try { response = await request(a, `daily-submissions/${id}`, call.signal); }
256
+ catch (error) { const e = safe(error); if (e.status !== 404 || e.code !== 'not_found') throw safe(error, true); }
257
+ if (response) {
258
+ try { const s = submission(response.submission, id, r.questId); await reconcile(a, state, s); return { submission: s }; }
259
+ catch (error) { throw safe(error, true); }
260
+ }
261
+ return dispatch(a, state, r, call, true);
262
+ }),
263
+ poll: run(false, async (input, call, a, state) => {
264
+ const id = uuid(input.id), r = state.records.find(r => r.id === id);
265
+ const s = submission((await request(a, `daily-submissions/${id}`, call.signal)).submission, id, r?.questId);
266
+ await reconcile(a, state, s); return { submission: s };
267
+ }),
268
+ forgetPayload: run(true, async (input, _call, a, state) => {
269
+ const id = uuid(input.id), r = state.records.find(r => r.id === id);
270
+ if (!r) throw new Failure('record_not_found');
271
+ r.request = null; await write(a, state); return { ok: true as const };
272
+ }),
273
+ };
274
+ }
275
+ export async function registerDailyQuestsRpc(ctx: Context) {
276
+ return ctx.rpc.register(DailyQuests, createDailyQuestsRpcHandlers(ctx));
277
+ }