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,496 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui";
3
+ import { Show } from "solid-js";
4
+ import { writeFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { nativeProviderName } from "../../../lib/opencode/provider-policy.mjs";
7
+ import { framePrompt } from "../../../lib/tui/framing.mjs";
8
+ import { carthagentConfigPath, loadCarthagentConfig, resolveReviewMode, reviewKickoff, saveCarthagentConfig } from "../../../lib/review.mjs";
9
+ import { COMMANDS, NATIVE_COMMAND_COLLISIONS } from "./ui/commands.js";
10
+ import { Workspace, rpcEventOptions, sessionControls, workspaceClient, shipClient, archiveClient, cloudAccountText, cloudUsageText, archiveItemText } from "./ui/client.js";
11
+ import { findNativeResumeSession } from "./ui/resume-session.mjs";
12
+ import { cliConfigDirectory, cliConfigPath, readCliConfig, writeNativeTheme } from "./ui/host-state.js";
13
+ import { WorkspacePage } from "./ui/workspace.js";
14
+ import { VoyagesPage } from "./ui/voyages.js";
15
+ import { DailyQuestsPage } from "./ui/daily-quests.js";
16
+ import { VoyagesNavigation } from "./ui/voyages-navigation.js";
17
+ import { nativeNavigation } from "./ui/native-navigation.js";
18
+ import { provisionNativeThemes } from "./ui/theme-provision.mjs";
19
+ import { setSessionAutoCompaction } from "./ui/session-preferences.mjs";
20
+ import { runModelCommand } from "./ui/model-command.js";
21
+ import { createNativeReviewConsumer, nativeReviewMode, sendNativeReviewKickoff, subscribeNativeReviewEvents } from "./ui/native-review.mjs";
22
+
23
+ const THEMES = ["carthage", "tokyonight", "nebula", "ember", "forest", "mono", "obsidian", "midnight", "solarized-dark", "nord", "contrast-dark", "okabe-dark", "paper", "daylight", "solarized-light", "contrast-light", "okabe-light", "solarized", "okabe", "contrast", "system"];
24
+ const OPENCODE_BUILTIN_THEMES = new Set(["opencode", "tokyonight"]);
25
+
26
+ export default Plugin.define({
27
+ id: "carthagent.tui",
28
+ setup(context) {
29
+ const unregister = context.ui.router.register({ name: "carthagent", render: ({ data }) => <WorkspacePage view={String(data?.view ?? "workspace")} refreshKey={String(data?.refresh ?? "")} sessionID={typeof data?.sessionID === "string" ? data.sessionID : undefined} /> });
30
+ const cleanups = [unregister];
31
+ cleanups.push(context.ui.router.register({ name: "voyages", render: ({ data }) => <VoyagesPage sessionID={typeof data?.sessionID === "string" ? data.sessionID : undefined} section={data?.section === "profile" || data?.section === "equipment" ? data.section : undefined} /> }));
32
+ cleanups.push(context.ui.router.register({ name: "daily-quests", render: ({ data }) => <DailyQuestsPage sessionID={typeof data?.sessionID === "string" ? data.sessionID : undefined} questID={typeof data?.questID === "string" ? data.questID : undefined} /> }));
33
+ const navigation = nativeNavigation(context);
34
+ if (navigation) cleanups.push(navigation.register({ name: "voyages", title: "Voyages", render: (input) => <VoyagesNavigation {...input} /> }));
35
+ const lifecycle = new AbortController();
36
+ cleanups.push(() => lifecycle.abort());
37
+ const [reviewLoopState, updateReviewLoopState] = context.storage.store("carthagent.native-review-loops", { initial: { sessions: [] as string[] } });
38
+ const reviewLoops = new Set<string>(reviewLoopState.sessions);
39
+ const reviewConsumer = createNativeReviewConsumer({
40
+ mode: (sessionID: string) => nativeReviewMode(context.data.session.get(sessionID), loadCarthagentConfig()),
41
+ diff: async (sessionID: string, signal?: AbortSignal) => await context.client.session.diff({ sessionID }, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000) }),
42
+ signal: lifecycle.signal,
43
+ isLoopActive: (sessionID: string) => reviewLoops.has(sessionID),
44
+ setLoopActive: (sessionID: string, active: boolean) => {
45
+ if (active) reviewLoops.add(sessionID);
46
+ else reviewLoops.delete(sessionID);
47
+ void updateReviewLoopState((state) => { state.sessions = [...reviewLoops]; });
48
+ },
49
+ // No formal delivery (unavailable) or a verified one; blocked/unfinished runs are not offered.
50
+ eligible: async (sessionID: string, signal?: AbortSignal) => {
51
+ const delivery = await workspaceClient(context, context.data.session.get(sessionID)?.location).delivery({ sessionID }, signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000));
52
+ return delivery.status === "unavailable" || delivery.status === "verified";
53
+ },
54
+ offer: async ({ sessionID, writes, start }: { sessionID: string; writes: number; start: () => Promise<boolean> }) => {
55
+ const accepted = await context.ui.dialog.confirm({ title: `Review ${context.data.session.get(sessionID)?.title ?? sessionID}?`, message: `This run changed ${writes} file${writes === 1 ? "" : "s"}. Start the PR → fresh-context review → fixes workflow?`, label: { confirm: "Start review", cancel: "Not now" } });
56
+ if (accepted) await start();
57
+ },
58
+ kickoff: async (reviewSessionID: string, signal?: AbortSignal) => sendNativeReviewKickoff({ client: context.client, sessionID: reviewSessionID, signal }),
59
+ });
60
+ cleanups.push(() => reviewConsumer.dispose());
61
+ cleanups.push(...subscribeNativeReviewEvents(
62
+ context.data,
63
+ reviewConsumer,
64
+ (error: unknown) => context.ui.toast.show({ message: error instanceof Error ? error.message : String(error), variant: "error" }),
65
+ async (sessionID: string, messageID: string, signal?: AbortSignal) => await context.client.session.message.get({ sessionID, messageID }, { signal }),
66
+ ));
67
+ const workspaceEvents = context.client.rpc(Workspace);
68
+ cleanups.push(workspaceEvents.events.on("updated", (event) => {
69
+ // Promise RPC exposes the server event envelope (including location)
70
+ // separately from the declared event data ({ revision }).
71
+ const route = context.ui.router.current();
72
+ const sessionID = route.type === "session" ? route.sessionID : route.type === "plugin" && ["carthagent", "voyages", "daily-quests"].includes(route.name) && typeof route.data?.sessionID === "string" ? route.data.sessionID : undefined;
73
+ const expected = (sessionID ? context.data.session.get(sessionID)?.location : undefined) ?? context.location ?? context.data.location.default();
74
+ if (event.location?.directory === expected.directory) context.data.project.invalidate();
75
+ }, rpcEventOptions(context, lifecycle.signal)));
76
+ context.keymap.layer(() => ({
77
+ mode: "global",
78
+ priority: 20,
79
+ commands: [
80
+ ...COMMANDS.filter((item) => !NATIVE_COMMAND_COLLISIONS.includes(item.name as typeof NATIVE_COMMAND_COLLISIONS[number]))
81
+ .map((item) => ({
82
+ id: `carthagent.${item.id}`,
83
+ title: `/${item.name}${item.args ? ` ${item.args}` : ""}`,
84
+ description: item.description,
85
+ group: "Carthagent",
86
+ palette: true as const,
87
+ slash: { name: item.name, ...(item.aliases ? { aliases: item.aliases } : {}), ...(item.args || item.submitSlash ? { arguments: true as const } : {}) },
88
+ run: (input?: string) => {
89
+ const report = (error: unknown) => context.ui.toast.show({ message: error instanceof Error ? error.message : String(error), variant: "error" });
90
+ if (item.id === "restart" || item.id === "abort") {
91
+ void dispatch(item.id, input ?? "").catch(report);
92
+ return false;
93
+ }
94
+ return dispatch(item.id, input ?? "").then(() => undefined).catch(report);
95
+ },
96
+ })),
97
+ ],
98
+ }));
99
+
100
+ async function dispatch(id: string, input: string) {
101
+ const route = context.ui.router.current();
102
+ const sessionID = route.type === "session" ? route.sessionID : route.type === "plugin" && ["carthagent", "voyages", "daily-quests"].includes(route.name) && typeof route.data?.sessionID === "string" ? route.data.sessionID : undefined;
103
+ const activeSession = sessionID ? context.data.session.get(sessionID) : undefined;
104
+ const location = activeSession?.location ?? context.location ?? context.data.location.default();
105
+ const arg = input.trim();
106
+ const toast = (message: string, variant: "info" | "success" | "warning" | "error" = "info") => context.ui.toast.show({ message, variant });
107
+ const selectedModel = context.ui.model.current();
108
+ const sessionApi = sessionControls(context, location);
109
+ switch (id) {
110
+ case "voyages":
111
+ context.ui.router.navigate({ type: "plugin", name: "voyages", data: { ...(sessionID ? { sessionID } : {}) } });
112
+ return;
113
+ case "quests":
114
+ context.ui.router.navigate({ type: "plugin", name: "daily-quests", data: { ...(sessionID ? { sessionID } : {}) } });
115
+ return;
116
+ case "login":
117
+ context.keymap.dispatch("provider.connect");
118
+ return;
119
+ case "help": case "ctg-help":
120
+ await context.ui.dialog.alert({ title: "Carthagent commands", message: COMMANDS.map((command) => `/${command.name}${command.args ? ` ${command.args}` : ""}${command.aliases?.length ? ` (${command.aliases.map((alias) => `/${alias}`).join(", ")})` : ""} — ${command.description}`).join("\n") + "\n\nCtrl+R sessions · Ctrl+T settings · Ctrl+N new session are native Carthagent actions." });
121
+ return;
122
+ case "resume": {
123
+ const query = arg.toLowerCase();
124
+ const pageSize = 100;
125
+ const match = query ? await findNativeResumeSession({ query: arg, directory: location.directory, client: context.client }) : undefined;
126
+ if (match) { context.ui.router.navigate({ type: "session", sessionID: match.id }); return; }
127
+ if (query) {
128
+ const archived = await archiveClient(context, location).search({ query: arg, limit: 1 }) as { total: number };
129
+ if (archived.total > 0) { toast(`“${arg}” matched ${archived.total} legacy archive result(s); archives are read-only and cannot be resumed. Use /archive search ${arg}.`, "warning"); return; }
130
+ toast(`No native session matches “${arg}” in ${context.ui.format.path(location.directory)}.`, "warning");
131
+ return;
132
+ }
133
+ let cursor: string | undefined;
134
+ const sessions: Array<{ id: string; title?: string; parentID?: string; time: { updated: number } }> = [];
135
+ do {
136
+ const page = await context.client.session.list({ limit: pageSize, order: "desc", directory: location.directory, ...(cursor ? { cursor } : {}) });
137
+ sessions.push(...page.data.filter((session) => !session.parentID));
138
+ cursor = page.cursor.next ?? undefined;
139
+ } while (cursor);
140
+ const selected = await context.ui.dialog.select({ title: `Resume native session · ${sessions.length} loaded`, options: sessions.map((session) => ({ title: session.title ?? session.id, value: session.id, description: `${new Date(session.time.updated).toLocaleString()} · ${session.id}` })) });
141
+ if (selected) context.ui.router.navigate({ type: "session", sessionID: selected });
142
+ return;
143
+ }
144
+ case "ctg-task": {
145
+ if (!arg) { toast("Usage: /ctg-task <task description>.", "warning"); return; }
146
+ const result = await workspaceClient(context, location).createTask({ task: arg }, AbortSignal.timeout(60_000));
147
+ context.ui.router.navigate({ type: "session", sessionID: result.sessionID });
148
+ toast(`Task created in ${context.ui.format.path(result.directory)}.`, "success");
149
+ return;
150
+ }
151
+ case "restart":
152
+ if (!sessionID) { toast("/restart requires an open session; it interrupts only the selected session and never restarts the shared service.", "warning"); return; }
153
+ await context.client.session.interrupt({ sessionID, resume: false });
154
+ toast("Selected session interrupted; its transcript is preserved.", "success");
155
+ return;
156
+ case "theme": {
157
+ const theme = arg.toLowerCase();
158
+ const cliPath = cliConfigPath();
159
+ const currentConfig = await readCliConfig(cliPath);
160
+ const currentTheme = typeof currentConfig.theme === "string" ? currentConfig.theme : currentConfig.theme?.name;
161
+ if (theme) {
162
+ if (!THEMES.includes(theme)) { toast(`Unknown theme “${theme}”. Available compatibility names: ${THEMES.join(", ")}.`, "warning"); return; }
163
+ const provisioned: { directory?: string; installed: string[] } = OPENCODE_BUILTIN_THEMES.has(theme) ? { installed: [] } : await provisionNativeThemes(cliConfigDirectory(), [theme]);
164
+ if (provisioned.installed.length) {
165
+ toast(`Installed native theme “${theme}” in ${provisioned.directory ?? cliConfigDirectory()}. Carthagent discovers new theme files at startup; restart the TUI, then run /theme ${theme} again to select it.`, "warning");
166
+ return;
167
+ }
168
+ await writeNativeTheme(cliConfigPath(), theme);
169
+ toast(`Theme “${theme}” saved to native Carthagent cli.json. Carthagent reloads valid theme selections while the TUI is running.`, "success");
170
+ return;
171
+ }
172
+ const selected = await context.ui.dialog.select({ title: "Carthagent themes", current: currentTheme, options: THEMES.map((name) => ({ title: name, value: name, description: name === "carthage" ? "Carthagent purple field, gold accent" : "Carthagent theme" })) });
173
+ if (selected) {
174
+ const provisioned: { directory?: string; installed: string[] } = OPENCODE_BUILTIN_THEMES.has(selected) ? { installed: [] } : await provisionNativeThemes(cliConfigDirectory(), [selected]);
175
+ if (provisioned.installed.length) {
176
+ toast(`Installed native theme “${selected}” in ${provisioned.directory ?? cliConfigDirectory()}. Carthagent discovers new theme files at startup; restart the TUI, then select it again.`, "warning");
177
+ return;
178
+ }
179
+ await writeNativeTheme(cliPath, selected);
180
+ toast(`Theme “${selected}” saved to native Carthagent cli.json. Carthagent reloads valid theme selections while the TUI is running.`, "success");
181
+ }
182
+ return;
183
+ }
184
+ case "model":
185
+ await runModelCommand({
186
+ sync: () => context.data.location.model.sync(location),
187
+ list: () => context.data.location.model.list(location) ?? [],
188
+ select: (options) => context.ui.dialog.select({ title: "Select provider model", placeholder: "Search provider/model…", options }),
189
+ switchModel: (target, model) => context.client.session.switchModel({ sessionID: target, model }),
190
+ openNativePicker: (query) => context.keymap.dispatch("model.list", query),
191
+ providerName: nativeProviderName,
192
+ toast,
193
+ }, { sessionID, arg });
194
+ return;
195
+ case "thinking": {
196
+ if (!selectedModel) { toast("Choose a model first.", "warning"); return; }
197
+ const variants = context.ui.model.variant.list();
198
+ const selected = await context.ui.dialog.select({ title: "Model variant", current: selectedModel.variant, options: [{ title: "Default", value: "" }, ...variants.map((variant) => ({ title: variant, value: variant }))] });
199
+ if (selected !== undefined) context.ui.model.variant.set(selected || undefined);
200
+ return;
201
+ }
202
+ case "compact":
203
+ if (!sessionID) { toast("/compact requires an open session.", "warning"); return; }
204
+ {
205
+ const result = await sessionApi.compact({ sessionID, ...(arg ? { notes: arg } : {}) });
206
+ toast(result.admitted ? "Compaction with notes admitted to the selected session." : "Compaction was not admitted.", result.admitted ? "success" : "warning");
207
+ }
208
+ return;
209
+ case "autocompact":
210
+ if (!sessionID) { toast("/autocompact requires an open session.", "warning"); return; }
211
+ if (arg && !["on", "off"].includes(arg.toLowerCase())) { toast("Usage: /autocompact [on|off]. Bare /autocompact toggles the selected session.", "warning"); return; }
212
+ {
213
+ const action = arg.toLowerCase() || "toggle";
214
+ const enabled = await setSessionAutoCompaction(context.client.session, sessionID, location, action as "on" | "off" | "toggle");
215
+ toast(`Automatic compaction ${enabled ? "enabled" : "disabled"} for this session.`, "success");
216
+ }
217
+ return;
218
+ case "review": {
219
+ if (!sessionID) { toast("/review requires an open session.", "warning"); return; }
220
+ if (arg && !["ask", "yes", "no", "status"].includes(arg.toLowerCase())) { toast("Usage: /review [ask|yes|no|status]. ask/yes/no sets the Carthagent automatic-review default; bare /review always starts an independent reviewer.", "warning"); return; }
221
+ if (arg && ["ask", "yes", "no"].includes(arg.toLowerCase())) {
222
+ const mode = arg.toLowerCase() as "ask" | "yes" | "no";
223
+ const config = saveCarthagentConfig({ review: mode });
224
+ toast(`Automatic fresh-review default saved to ${carthagentConfigPath()}: ${resolveReviewMode(undefined, config)}.`, "success");
225
+ return;
226
+ }
227
+ if (arg.toLowerCase() === "status") { toast(`Automatic fresh-review default: ${nativeReviewMode(activeSession, loadCarthagentConfig())} (Carthagent profile: ${carthagentConfigPath()}).`, "info"); return; }
228
+ {
229
+ reviewConsumer.setLoopActive(sessionID, true);
230
+ try {
231
+ await sendNativeReviewKickoff({ client: context.client, sessionID, signal: lifecycle.signal, prompt: reviewKickoff() });
232
+ toast("Started the PR → fresh-context review → fixes workflow in this session.", "success");
233
+ } catch (error) {
234
+ reviewConsumer.setLoopActive(sessionID, false);
235
+ throw error;
236
+ }
237
+ }
238
+ return;
239
+ }
240
+ case "export":
241
+ if (!sessionID) { toast("/export requires an open session.", "warning"); return; }
242
+ {
243
+ const transfer = await context.client.session.export({ sessionID }, { signal: AbortSignal.timeout(60_000) });
244
+ const messages = transfer.messages;
245
+ const session = context.data.session.get(sessionID);
246
+ const title = session?.title ?? "Carthagent session";
247
+ const content = messages.map((message) => {
248
+ const who = message.type === "assistant" ? "Assistant" : message.type === "user" ? "You" : message.type;
249
+ const body = message.type === "assistant" ? message.content.map((part) => {
250
+ if (part.type === "text" || part.type === "reasoning") return part.text;
251
+ if (part.type === "tool") {
252
+ const state = part.state;
253
+ const input = typeof state.input === "string" ? state.input : JSON.stringify(state.input, null, 2);
254
+ const result = "content" in state && state.content ? `\nOutput: ${state.content.map((item) => item.type === "text" ? item.text : JSON.stringify(item)).join("\n")}` : "";
255
+ const failure = state.status === "error" ? `\nError: ${state.error.message}` : "";
256
+ return `[Tool ${part.name} · ${state.status}]\nInput: ${input}${result}${failure}`;
257
+ }
258
+ return "[Unsupported assistant content]";
259
+ }).join("\n")
260
+ : message.type === "user" || message.type === "synthetic" || message.type === "system" || message.type === "skill" ? message.text
261
+ : message.type === "shell" ? `$ ${message.command}\n${message.output?.output ?? ""}` : message.type === "compaction" ? `[Compaction ${message.status}${message.status === "completed" || message.status === "running" ? `: ${message.summary}` : `: ${message.error.message}`}]` : "";
262
+ return body ? `<article><h2>${escapeHtml(who)}</h2><pre>${escapeHtml(body)}</pre></article>` : "";
263
+ }).join("\n");
264
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title><style>body{margin:3rem auto;max-width:52rem;padding:0 1rem;background:#160e20;color:#f6f0e7;font:16px/1.6 system-ui}h1{color:#e6c480}article{border-top:1px solid #49364f;padding:1rem 0}h2{color:#c0b3ca;font-size:.9rem}pre{white-space:pre-wrap;font:inherit}</style><h1>${escapeHtml(title)}</h1>${content}</html>`;
265
+ const base = location.directory;
266
+ const safeTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "session";
267
+ const outputPath = resolve(base, arg || `${safeTitle}.html`);
268
+ await writeFile(outputPath, html, { encoding: "utf8", flag: "wx" });
269
+ toast(`Session exported to ${context.ui.format.path(outputPath)}`, "success");
270
+ }
271
+ return;
272
+ case "copy": {
273
+ if (!sessionID) { toast("/copy requires an open session.", "warning"); return; }
274
+ const { messages } = await context.client.session.export({ sessionID }, { signal: AbortSignal.timeout(60_000) });
275
+ const latest = messages.filter((message) => message.type === "assistant")
276
+ .sort((left, right) => (right.time.completed ?? right.time.created) - (left.time.completed ?? left.time.created))[0];
277
+ const text = latest?.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
278
+ if (!text) { toast("No assistant response to copy yet.", "warning"); return; }
279
+ await copyWithHost(text);
280
+ toast("Full assistant response copied.", "success");
281
+ return;
282
+ }
283
+ case "stats": {
284
+ if (!sessionID) { toast("/stats requires an open session.", "warning"); return; }
285
+ const session = context.data.session.get(sessionID);
286
+ const cachedCost = context.data.session.cost(sessionID);
287
+ await context.data.session.message.sync(sessionID);
288
+ const messages = context.data.session.message.list(sessionID);
289
+ const assistants = messages.filter((message) => message.type === "assistant");
290
+ const totals = assistants.reduce((sum, message) => {
291
+ const tokens = message.tokens;
292
+ if (tokens) {
293
+ sum.input += tokens.input;
294
+ sum.output += tokens.output;
295
+ sum.reasoning += tokens.reasoning;
296
+ sum.cacheRead += tokens.cache.read;
297
+ sum.cacheWrite += tokens.cache.write;
298
+ }
299
+ if (message.cost !== undefined) sum.cost += message.cost;
300
+ return sum;
301
+ }, { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
302
+ const hasMessageCost = assistants.some((message) => message.cost !== undefined);
303
+ if (!hasMessageCost && Number.isFinite(cachedCost) && cachedCost > 0) totals.cost = cachedCost;
304
+ const hasCost = hasMessageCost || Number.isFinite(cachedCost) && cachedCost > 0;
305
+ const hasTokens = assistants.some((message) => message.tokens !== undefined);
306
+ await context.ui.dialog.alert({ title: "Session stats", message: `${session?.title ?? sessionID}\nMessages: ${messages.length} (${assistants.length} assistant)\nInput tokens: ${hasTokens ? totals.input : "not reported"}\nOutput tokens: ${hasTokens ? totals.output : "not reported"}\nReasoning tokens: ${hasTokens ? totals.reasoning : "not reported"}\nCache read/write: ${hasTokens ? `${totals.cacheRead} / ${totals.cacheWrite}` : "not reported"}\nCost: ${hasCost ? `$${totals.cost.toFixed(4)}` : "not reported"}` });
307
+ return;
308
+ }
309
+ case "account": {
310
+ const result = await shipClient(context, location).account() as { account: Record<string, unknown> };
311
+ await context.ui.dialog.alert({ title: "Carthagent Ship account", message: cloudAccountText(result.account) });
312
+ return;
313
+ }
314
+ case "usage": {
315
+ const [accountResult, usageResult] = await Promise.all([
316
+ shipClient(context, location).account() as Promise<{ account: Record<string, unknown> }>,
317
+ shipClient(context, location).usage({ limit: 50 }) as Promise<{ usage: Record<string, unknown> }>,
318
+ ]);
319
+ await context.ui.dialog.alert({ title: "Carthagent Ship usage", message: cloudUsageText(usageResult.usage, accountResult.account) });
320
+ return;
321
+ }
322
+ case "billing": {
323
+ const action = arg.toLowerCase() || "checkout";
324
+ if (!["checkout", "portal"].includes(action)) { toast("Usage: /billing [checkout|portal].", "warning"); return; }
325
+ const { url } = await shipClient(context, location).billing({ action: action as "checkout" | "portal" }) as { url: string };
326
+ const { openBrowser } = await import("../../../lib/cloud/client.mjs");
327
+ const opened = await openBrowser(url).catch(() => false);
328
+ await context.ui.dialog.alert({ title: action === "portal" ? "Ship billing portal" : "Ship checkout", message: `${opened ? "Opened in your browser." : "Open this URL in your browser:"}\n${url}` });
329
+ return;
330
+ }
331
+ case "logout-cloud": {
332
+ const session = arg || undefined;
333
+ const confirmed = await context.ui.dialog.confirm({ title: session ? "Revoke Ship session" : "Sign out of Carthagent Ship", message: session ? `Revoke Ship session ${session}?` : "Revoke the active Ship session and remove its Carthagent connection?" });
334
+ if (confirmed !== true) return;
335
+ await shipClient(context, location).logout(session ? { session } : {});
336
+ toast(session ? `Revoked Ship session ${session}.` : "Revoked and removed the active Carthagent Ship connection.", "success");
337
+ return;
338
+ }
339
+ case "archive": {
340
+ const [operation, ...rest] = arg.split(/\s+/).filter(Boolean);
341
+ const archive = archiveClient(context, location);
342
+ if (operation && !["list", "search", "read", "export"].includes(operation)) { toast("Usage: /archive [list|search <query>|read <id>|export <id>].", "warning"); return; }
343
+ if (operation === "search" && !rest.length) { toast("Usage: /archive search <query>.", "warning"); return; }
344
+ if (operation === "read" || operation === "export") {
345
+ const id = rest[0];
346
+ if (!id) { toast(`Usage: /archive ${operation} <id>.`, "warning"); return; }
347
+ if (operation === "read") {
348
+ const result = await archive.read({ id }) as { records: unknown[] };
349
+ const content = result.records.map((record: unknown) => JSON.stringify(record)).join("\n") || "Archive is empty.";
350
+ const pageSize = 24000;
351
+ for (let offset = 0; offset < content.length; offset += pageSize) {
352
+ const end = Math.min(offset + pageSize, content.length);
353
+ await context.ui.dialog.alert({ title: `Archived session ${id} · characters ${offset + 1}–${end} of ${content.length}`, message: content.slice(offset, end) });
354
+ if (end < content.length && await context.ui.dialog.confirm({ title: "Continue archived session?", message: `Show characters ${end + 1}–${Math.min(end + pageSize, content.length)} of ${content.length}?`, label: { confirm: "Continue", cancel: "Stop reading" } }) !== true) break;
355
+ }
356
+ } else {
357
+ const result = await archive.export({ id, format: "jsonl" }) as { content: string };
358
+ const path = resolve(location.directory, `${id.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
359
+ await writeFile(path, result.content, { encoding: "utf8", flag: "wx" });
360
+ toast(`Complete archive export (${Buffer.byteLength(result.content, "utf8")} bytes) saved to ${context.ui.format.path(path)}.`, "success");
361
+ }
362
+ return;
363
+ }
364
+ const query = operation === "search" ? rest.join(" ") : undefined;
365
+ let offset = 0;
366
+ const pageSize = 50;
367
+ while (true) {
368
+ const result = (query
369
+ ? await archive.search({ query, offset, limit: pageSize })
370
+ : await archive.list({ offset, limit: pageSize })) as { total: number; offset: number; limit: number; items: Array<Record<string, unknown>> };
371
+ const returned = result.items.length;
372
+ const first = returned ? result.offset + 1 : 0;
373
+ const last = result.offset + returned;
374
+ await context.ui.dialog.alert({
375
+ title: `${query ? "Archive search" : "Archived sessions"} · ${first}–${last} of ${result.total}`,
376
+ message: `${returned} item(s) returned on this page${result.total > returned ? `; ${result.total} total matches` : ""}.\n\n${result.items.map(archiveItemText).join("\n\n") || (query ? "No archived sessions matched." : "No archived sessions found.")}`,
377
+ });
378
+ if (!returned || last >= result.total) break;
379
+ const next = await context.ui.dialog.confirm({ title: "More archived sessions?", message: `Showing ${last} of ${result.total}. Load the next page?`, label: { confirm: "Next page", cancel: "Done" } });
380
+ if (next !== true) break;
381
+ offset += returned;
382
+ }
383
+ return;
384
+ }
385
+ case "demo": {
386
+ const { runOfflineDemo } = await import("../../../lib/opencode/demo.mjs");
387
+ const lines: string[] = [];
388
+ const demoIo = Object.assign(Object.create(console), {
389
+ log: (...values: unknown[]) => { lines.push(values.map(String).join(" ")); },
390
+ error: (...values: unknown[]) => { lines.push(values.map(String).join(" ")); },
391
+ }) as Console;
392
+ let exitCode: number;
393
+ try { exitCode = await runOfflineDemo({ io: demoIo, signal: lifecycle.signal }); }
394
+ catch (error) {
395
+ if (lifecycle.signal.aborted) return;
396
+ throw error;
397
+ }
398
+ await context.ui.dialog.alert({ title: exitCode === 130 ? "Carthagent offline demo · cancelled" : exitCode === 0 ? "Carthagent offline demo · fixture passed" : "Carthagent offline demo · fixture failed", message: `${lines.join("\n")}\n\nActual fixture exit status: ${exitCode}\nThis disposable fixture is not evidence about the current workspace.` });
399
+ return;
400
+ }
401
+ case "clear":
402
+ if (!sessionID) { toast("/clear requires an open session.", "warning"); return; }
403
+ // Hand off to the registered host action; it owns presentation
404
+ // state and guarantees the durable conversation is untouched.
405
+ context.keymap.dispatch("session.transcript.clear");
406
+ return;
407
+ case "abort":
408
+ if (!sessionID) { toast("/abort requires an open session.", "warning"); return; }
409
+ await context.client.session.interrupt({ sessionID, resume: false });
410
+ return;
411
+ case "raw":
412
+ if (!sessionID || !arg) { toast("Usage: /raw <prompt> (inside a session).", "warning"); return; }
413
+ await context.client.session.prompt({ sessionID, text: arg, metadata: { "ctg.raw": true } });
414
+ return;
415
+ case "guide":
416
+ if (!sessionID) { toast("/guide requires an open session.", "warning"); return; }
417
+ if (!arg) {
418
+ const result = await sessionApi.framing({ sessionID });
419
+ toast(`Carthagent framing ${result.enabled ? "enabled" : "disabled"} for this session.`, "success");
420
+ return;
421
+ }
422
+ await context.client.session.prompt({ sessionID, text: framePrompt(arg) });
423
+ return;
424
+ case "workflow": case "architecture": case "evidence":
425
+ context.ui.router.navigate({ type: "plugin", name: "carthagent", data: { view: id, ...(sessionID ? { sessionID } : {}) } });
426
+ return;
427
+ case "ctg-workspace":
428
+ context.ui.router.navigate({ type: "plugin", name: "carthagent", data: { view: "workspace" } });
429
+ return;
430
+ case "ctg-open": {
431
+ const api = workspaceClient(context, location);
432
+ if (!api) { toast("CTG workspace service is unavailable.", "warning"); return; }
433
+ try {
434
+ const snapshot = await api.snapshot();
435
+ const sessions = snapshot.sessions.filter((session) => !arg || `${session.title} ${session.id}`.toLowerCase().includes(arg.toLowerCase()));
436
+ const selected = await context.ui.dialog.select({ title: "Open CTG session", options: sessions.map((session) => ({ title: session.title, value: session.id, description: `${session.status} · ${session.id}` })) });
437
+ if (selected) {
438
+ await api.open({ sessionID: selected });
439
+ context.ui.router.navigate({ type: "session", sessionID: selected });
440
+ }
441
+ } catch (error) { toast(error instanceof Error ? error.message : String(error), "error"); }
442
+ return;
443
+ }
444
+ case "ctg-worktree":
445
+ await createWorktree(arg);
446
+ return;
447
+ case "ctg-preview": case "ctg-review": {
448
+ if (!sessionID) { toast(`/${id} requires an open session.`, "warning"); return; }
449
+ const api = workspaceClient(context, location);
450
+ if (!api) { toast("CTG workspace service is unavailable.", "warning"); return; }
451
+ try {
452
+ const result = id === "ctg-review" ? await api.review({ sessionID }) : await api.delivery({ sessionID });
453
+ toast(result ? JSON.stringify(result) : "No CTG status is recorded for this session.", "info");
454
+ } catch (error) { toast(error instanceof Error ? error.message : String(error), "error"); }
455
+ return;
456
+ }
457
+ }
458
+ }
459
+
460
+ async function createWorktree(input: string) {
461
+ const route = context.ui.router.current();
462
+ const sessionID = route.type === "session" ? route.sessionID : route.type === "plugin" && route.name === "carthagent" && typeof route.data?.sessionID === "string" ? route.data.sessionID : undefined;
463
+ const selectedLocation = sessionID ? context.data.session.get(sessionID)?.location : undefined;
464
+ const api = workspaceClient(context, selectedLocation);
465
+ if (!api) { context.ui.toast.show({ message: "CTG workspace RPC is unavailable; no worktree was created.", variant: "warning" }); return; }
466
+ const [projectID, ...nameParts] = input.trim().split(/\s+/);
467
+ const name = nameParts.join(" ");
468
+ if (!projectID || !name) { context.ui.toast.show({ message: "Usage: /ctg-worktree <local-project-id> <name>.", variant: "warning" }); return; }
469
+ try {
470
+ const result = await api.createWorktree({ projectID, name });
471
+ context.ui.toast.show({ message: `CTG worktree created at ${context.ui.format.path(result.directory)}`, variant: "success" });
472
+ } catch (error) { context.ui.toast.show({ message: error instanceof Error ? error.message : String(error), variant: "error" }); }
473
+ }
474
+
475
+ async function copyWithHost(text: string) {
476
+ if (!context.renderer.isOsc52Supported()) throw new Error("This terminal does not support OSC 52 clipboard copy; select and copy the response manually.");
477
+ if (!context.renderer.copyToClipboardOSC52(text)) throw new Error("Terminal rejected clipboard copy; select and copy the response manually.");
478
+ }
479
+
480
+ function escapeHtml(value: string) {
481
+ return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!);
482
+ }
483
+
484
+ cleanups.push(context.ui.slot({ append: "session.panel", render: (panel) => <Show when={panel.name === "carthagent.delivery"}><text fg={context.theme.text.base}>Carthagent delivery · session {panel.sessionID} · use /workflow or /evidence for the CTG workspace report</text></Show> }));
485
+ cleanups.push(context.ui.slot({ append: "home.footer.status", render: () => <text fg={context.theme.hue.accent[400]}>◆ CTG</text> }));
486
+ const stopEvents = context.data.listen(({ details }) => {
487
+ if (details.type.startsWith("session.")) {
488
+ const data = (details as unknown as { data?: { sessionID?: string } }).data;
489
+ if (data?.sessionID) context.data.session.invalidate(data.sessionID);
490
+ }
491
+ if (details.type.startsWith("rpc.carthagent.workspace.")) context.data.project.invalidate();
492
+ });
493
+ cleanups.push(stopEvents);
494
+ return () => cleanups.reverse().forEach((cleanup) => cleanup());
495
+ },
496
+ });
@@ -0,0 +1,127 @@
1
+ import type { Context } from "@opencode/plugin/tui/context";
2
+ import type { LocationRef } from "@opencode/client";
3
+ import { Workspace, type WorkspaceDelivery, type WorkspaceSnapshot } from "../workspace-rpc.js";
4
+ import { SessionControls } from "../session-rpc.js";
5
+ import { PullRequests, type PullRequestRecord } from "../pull-request-rpc.js";
6
+ import { Archive } from "../archive-rpc.js";
7
+ import { Carthagent } from "../rpc.js";
8
+ import { Voyages, type VoyagesSnapshot, type VoyagesProfile, type VoyagesItem, type VoyagesQuote } from "../voyages-rpc.js";
9
+ import { DailyQuests, type DailyEvidence, type DailySnapshot, type DailyQuestDetail } from "../daily-quests-rpc.js";
10
+ import { DailySource, type DailySourceReport } from "../daily-source-rpc.js";
11
+ import { formatCloudAccount, formatCloudUsage } from "../../../../lib/cloud/client.mjs";
12
+
13
+ export { Workspace, SessionControls, PullRequests, Archive };
14
+ export type { WorkspaceDelivery, WorkspaceSnapshot, PullRequestRecord };
15
+ export type { VoyagesSnapshot, VoyagesProfile, VoyagesItem, VoyagesQuote };
16
+
17
+ const REQUEST_TIMEOUT_MS = 15_000;
18
+
19
+ type RpcContext = Pick<Context, "client" | "location" | "data">;
20
+
21
+ type SelectedLocation = LocationRef | (() => LocationRef | undefined);
22
+
23
+ function requestOptions(context: RpcContext, selectedLocation?: SelectedLocation, signal?: AbortSignal) {
24
+ const location = typeof selectedLocation === "function" ? selectedLocation() : selectedLocation;
25
+ return {
26
+ location: location ?? context.location ?? context.data.location.default(),
27
+ signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
28
+ };
29
+ }
30
+
31
+ export function voyagesClient(context: RpcContext, selectedLocation?: SelectedLocation) {
32
+ const rpc = context.client.rpc(Voyages);
33
+ return {
34
+ previewReward: (input: { sessionID: string }, signal?: AbortSignal) => rpc.previewReward(input, requestOptions(context, selectedLocation, signal)),
35
+ claimReward: (input: { sessionID: string; runId: string; snapshot: string }, signal?: AbortSignal) => rpc.claimReward(input, requestOptions(context, selectedLocation, signal)),
36
+ snapshot: (signal?: AbortSignal) => rpc.snapshot({}, requestOptions(context, selectedLocation, signal)) as Promise<VoyagesSnapshot>,
37
+ customize: (input: { version: number; name?: string; shipName?: string; skin?: string; hair?: string; pattern?: string }, signal?: AbortSignal) => rpc.customize(input, requestOptions(context, selectedLocation, signal)),
38
+ equip: (input: { version: number; slot: string; item: string }, signal?: AbortSignal) => rpc.equip(input, requestOptions(context, selectedLocation, signal)),
39
+ quote: (input: { slot: string; item: string }, signal?: AbortSignal) => rpc.quote(input, requestOptions(context, selectedLocation, signal)),
40
+ purchase: (input: { quoteId: string }, signal?: AbortSignal) => rpc.purchase(input, requestOptions(context, selectedLocation, signal)),
41
+ retryPurchase: (input: { quoteId: string }, signal?: AbortSignal) => rpc.retryPurchase(input, requestOptions(context, selectedLocation, signal)),
42
+ };
43
+ }
44
+
45
+ export function dailyQuestsClient(context: RpcContext, selectedLocation?: SelectedLocation) {
46
+ const rpc = context.client.rpc(DailyQuests);
47
+ return {
48
+ snapshot: (signal?: AbortSignal) => rpc.snapshot({}, requestOptions(context, selectedLocation, signal)) as Promise<DailySnapshot>,
49
+ detail: (input: { id: string }, signal?: AbortSignal) => rpc.detail(input, requestOptions(context, selectedLocation, signal)) as Promise<DailyQuestDetail>,
50
+ submit: (input: { accountKey: string; questId: string; id: string; consent: true; evidence: DailyEvidence }, signal?: AbortSignal) => rpc.submit(input, requestOptions(context, selectedLocation, signal)),
51
+ retry: (input: { accountKey: string; id: string }, signal?: AbortSignal) => rpc.retry(input, requestOptions(context, selectedLocation, signal)),
52
+ poll: (input: { id: string }, signal?: AbortSignal) => rpc.poll(input, requestOptions(context, selectedLocation, signal)),
53
+ forgetPayload: (input: { accountKey: string; id: string }, signal?: AbortSignal) => rpc.forgetPayload(input, requestOptions(context, selectedLocation, signal)),
54
+ };
55
+ }
56
+
57
+ export function dailySourceClient(context: RpcContext, selectedLocation?: SelectedLocation) {
58
+ const rpc = context.client.rpc(DailySource);
59
+ return { report: (input: { sessionID: string }, signal?: AbortSignal) => rpc.report(input, requestOptions(context, selectedLocation, signal)) as Promise<{ source: DailySourceReport | null }> };
60
+ }
61
+
62
+ export function workspaceClient(context: RpcContext, selectedLocation?: SelectedLocation) {
63
+ const rpc = context.client.rpc(Workspace);
64
+ return {
65
+ snapshot: (input?: { projectID?: string }, signal?: AbortSignal) => rpc.snapshot(input ?? {}, requestOptions(context, selectedLocation, signal)) as Promise<WorkspaceSnapshot>,
66
+ open: (input: { sessionID: string }, signal?: AbortSignal) => rpc.open(input, requestOptions(context, selectedLocation, signal)) as Promise<{ opened: boolean; directory: string }>,
67
+ createWorktree: (input: { projectID: string; name: string }, signal?: AbortSignal) => rpc.createWorktree(input, requestOptions(context, selectedLocation, signal)) as Promise<{ directory: string }>,
68
+ createTask: (input: { task: string; branch?: string }, signal?: AbortSignal) => rpc.createTask(input, requestOptions(context, selectedLocation, signal)) as Promise<{ taskID: string; sessionID: string; directory: string }>,
69
+ delivery: (input: { sessionID: string }, signal?: AbortSignal) => rpc.delivery(input, requestOptions(context, selectedLocation, signal)) as Promise<WorkspaceDelivery>,
70
+ review: (input: { sessionID: string }, signal?: AbortSignal) => rpc.review(input, requestOptions(context, selectedLocation, signal)) as Promise<{ status: string; summary?: string }>,
71
+ startReview: (input: { sessionID: string }, signal?: AbortSignal) => rpc.startReview(input, requestOptions(context, selectedLocation, signal)) as Promise<{ status: string; summary: string; sessionID?: string }>,
72
+ events: rpc.events,
73
+ };
74
+ }
75
+
76
+ export function sessionControls(context: RpcContext, selectedLocation?: SelectedLocation) {
77
+ const rpc = context.client.rpc(SessionControls);
78
+ return {
79
+ framing: (input: { sessionID: string; enabled?: boolean }, signal?: AbortSignal) => rpc.framing(input, requestOptions(context, selectedLocation, signal)) as Promise<{ enabled: boolean }>,
80
+ compact: (input: { sessionID: string; notes?: string }, signal?: AbortSignal) => rpc.compact(input, requestOptions(context, selectedLocation, signal)) as Promise<{ admitted: boolean }>,
81
+ };
82
+ }
83
+
84
+ export function pullRequestClient(context: RpcContext, selectedLocation?: SelectedLocation) {
85
+ const rpc = context.client.rpc(PullRequests);
86
+ return {
87
+ list: (input: { sessionID: string; taskID: string }, signal?: AbortSignal) => rpc.list(input, requestOptions(context, selectedLocation, signal)) as Promise<{ pullRequests: PullRequestRecord[] }>,
88
+ associate: (input: { sessionID: string; taskID: string; number: number }, signal?: AbortSignal) => rpc.associate(input, requestOptions(context, selectedLocation, signal)) as Promise<{ pullRequest: PullRequestRecord }>,
89
+ refresh: (input: { sessionID: string; taskID: string; number?: number }, signal?: AbortSignal) => rpc.refresh(input, requestOptions(context, selectedLocation, signal)) as Promise<{ pullRequest: PullRequestRecord }>,
90
+ };
91
+ }
92
+
93
+ export function shipClient(context: RpcContext, selectedLocation?: SelectedLocation) {
94
+ const rpc = context.client.rpc(Carthagent);
95
+ return {
96
+ account: (signal?: AbortSignal) => rpc.shipAccount({}, requestOptions(context, selectedLocation, signal)),
97
+ usage: (input: { limit?: number } = {}, signal?: AbortSignal) => rpc.shipUsage(input, requestOptions(context, selectedLocation, signal)),
98
+ billing: (input: { action: "checkout" | "portal" }, signal?: AbortSignal) => rpc.shipBilling(input, requestOptions(context, selectedLocation, signal)),
99
+ logout: (input: { session?: string } = {}, signal?: AbortSignal) => rpc.shipLogout(input, requestOptions(context, selectedLocation, signal)),
100
+ };
101
+ }
102
+
103
+ export function archiveClient(context: RpcContext, selectedLocation?: SelectedLocation) {
104
+ const rpc = context.client.rpc(Archive);
105
+ return {
106
+ list: (input: { offset?: number; limit?: number } = {}, signal?: AbortSignal) => rpc.list(input, requestOptions(context, selectedLocation, signal)),
107
+ search: (input: { query: string; offset?: number; limit?: number }, signal?: AbortSignal) => rpc.search(input, requestOptions(context, selectedLocation, signal)),
108
+ read: (input: { id: string }, signal?: AbortSignal) => rpc.read(input, requestOptions(context, selectedLocation, signal)),
109
+ export: (input: { id: string; format?: "json" | "jsonl" }, signal?: AbortSignal) => rpc.export(input, requestOptions(context, selectedLocation, signal)),
110
+ };
111
+ }
112
+
113
+ export function cloudAccountText(account: Record<string, unknown>) {
114
+ return formatCloudAccount(account);
115
+ }
116
+
117
+ export function cloudUsageText(usage: Record<string, unknown>, account?: Record<string, unknown>) {
118
+ return formatCloudUsage(usage, { account } as Parameters<typeof formatCloudUsage>[1]);
119
+ }
120
+
121
+ export function archiveItemText(item: Record<string, unknown>) {
122
+ return `${String(item.id)} · ${String(item.messageCount)} messages${item.createdAt ? ` · ${String(item.createdAt)}` : ""}${item.excerpt ? `\n${String(item.excerpt)}` : ""}`;
123
+ }
124
+
125
+ export function rpcEventOptions(_context: RpcContext, signal?: AbortSignal): { signal?: AbortSignal } {
126
+ return signal ? { signal } : {};
127
+ }