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,379 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { For, Show, createSignal, onCleanup, onMount } from "solid-js";
3
+ import { usePlugin } from "@opencode/plugin/tui";
4
+ import { pullRequestClient, rpcEventOptions, workspaceClient, type PullRequestRecord, type WorkspaceDelivery, type WorkspaceSnapshot } from "./client.js";
5
+ import { createLatestRequestGate, onWorkspaceUpdated, renderDeliveryReadiness } from "./workspace-live.js";
6
+
7
+ function sessionAttention(context: ReturnType<typeof usePlugin>, sessionID: string) {
8
+ return (context.data.session.permission.list(sessionID)?.length ?? 0) + (context.data.session.form.list(sessionID)?.length ?? 0);
9
+ }
10
+
11
+ export function WorkspacePage(props: { view: string; refreshKey?: string; sessionID?: string }) {
12
+ const context = usePlugin();
13
+ const [snapshot, setSnapshot] = createSignal<WorkspaceSnapshot>();
14
+ const [activeSessions, setActiveSessions] = createSignal<Record<string, { type: "running" }>>({});
15
+ const [deliveryEvidence, setDeliveryEvidence] = createSignal<WorkspaceDelivery>();
16
+ const [reviewEvidence, setReviewEvidence] = createSignal<unknown>();
17
+ const [inventorySessions, setInventorySessions] = createSignal<ReturnType<typeof context.data.session.list>>([]);
18
+ const [query, setQuery] = createSignal("");
19
+ const [error, setError] = createSignal("");
20
+ const [rpcConnected, setRpcConnected] = createSignal(true);
21
+ const [pullRequests, setPullRequests] = createSignal<Record<string, PullRequestRecord[]>>({});
22
+ const snapshotRequests = createLatestRequestGate();
23
+ const evidenceRequests = createLatestRequestGate();
24
+ const currentSessionID = () => {
25
+ const currentRoute = context.ui.router.current();
26
+ return props.sessionID ?? (currentRoute.type === "session" ? currentRoute.sessionID : currentRoute.type === "plugin" && currentRoute.name === "carthagent" && typeof currentRoute.data?.sessionID === "string" ? currentRoute.data.sessionID : undefined);
27
+ };
28
+ const sessionLocation = () => currentSessionID() ? context.data.session.get(currentSessionID()!)?.location : undefined;
29
+ const api = workspaceClient(context, sessionLocation);
30
+ const currentLocation = () => sessionLocation() ?? context.location ?? context.data.location.default();
31
+ const activeSession = () => {
32
+ const activeRoute = context.ui.router.current();
33
+ return activeRoute.type === "plugin" ? activeRoute.data?.sessionID ?? props.sessionID : activeRoute.type === "session" ? activeRoute.sessionID : props.sessionID;
34
+ };
35
+ const localSessions = () => inventorySessions().filter((session) => !session.parentID);
36
+ const syncNativeSessions = async (isCurrent: () => boolean, signal: AbortSignal) => {
37
+ const all = [] as ReturnType<typeof context.data.session.list>;
38
+ let cursor: string | undefined;
39
+ do {
40
+ const page = await context.client.session.list({ limit: 100, order: "desc", ...(cursor ? { cursor } : {}) }, { signal });
41
+ if (!isCurrent()) return;
42
+ all.push(...page.data);
43
+ cursor = page.cursor.next ?? undefined;
44
+ } while (cursor);
45
+ if (!isCurrent()) return;
46
+ const sessions = all.filter((session) => !session.parentID);
47
+ for (const session of sessions) {
48
+ if (!isCurrent()) return;
49
+ await Promise.allSettled([
50
+ context.data.session.sync(session.id),
51
+ context.data.session.permission.sync(session.id),
52
+ context.data.session.form.sync(session.id, session.location),
53
+ ]);
54
+ }
55
+ if (isCurrent()) setInventorySessions(all);
56
+ };
57
+ type WorkspaceSession = { id: string; title: string; directory: string; status: string; projectID?: string; attention?: number };
58
+ const workspaceSessions = (): WorkspaceSession[] => {
59
+ const snapshotSessions = snapshot()?.sessions ?? [];
60
+ const known = new Set(snapshotSessions.map((session) => session.id));
61
+ const projectIDs = new Set(snapshot()?.projects.map((project) => project.id) ?? []);
62
+ const projectDirectories = new Set(snapshot()?.projects.map((project) => project.directory) ?? []);
63
+ const nativeSessions = localSessions().filter((session) => !known.has(session.id) &&
64
+ (projectIDs.has(session.projectID) || projectDirectories.has(session.location.directory)));
65
+ return [
66
+ ...snapshotSessions.map((session): WorkspaceSession => ({ id: session.id, title: session.title, directory: session.directory, status: session.status, projectID: session.projectID, attention: sessionAttention(context, session.id) })),
67
+ ...nativeSessions.map((session) => ({ id: session.id, title: session.title ?? session.id, directory: session.location.directory, status: activeSessions()[session.id]?.type === "running" ? "running" : "idle", projectID: session.projectID, attention: sessionAttention(context, session.id) })),
68
+ ].filter((session) => !query() || `${session.title} ${session.id} ${session.directory}`.toLowerCase().includes(query().toLowerCase()));
69
+ };
70
+
71
+ const refresh = async (signal?: AbortSignal) => {
72
+ const request = snapshotRequests.begin();
73
+ if (!api) { if (request.isCurrent()) setError("Workspace service is not connected. Start the CTG-local workspace server to browse tasks and worktrees."); return; }
74
+ const requestSignal = signal ? AbortSignal.any([signal, request.signal]) : request.signal;
75
+ try {
76
+ const result = await api.snapshot(undefined, requestSignal);
77
+ if (!request.isCurrent()) return;
78
+ setSnapshot(result);
79
+ setRpcConnected(true);
80
+ setError("");
81
+ } catch (cause) {
82
+ if (request.isCurrent()) {
83
+ setRpcConnected(false);
84
+ setError(cause instanceof Error ? cause.message : String(cause));
85
+ }
86
+ }
87
+ };
88
+ onMount(() => {
89
+ let generation = 0;
90
+ let observedDirectory = currentLocation().directory;
91
+ let nativeAbort: AbortController | undefined;
92
+ let rehydrateAbort: AbortController | undefined;
93
+ let rpcAbort: AbortController | undefined;
94
+ let stopRpc: (() => void) | undefined;
95
+ const subscribeRpc = () => {
96
+ rpcAbort?.abort();
97
+ stopRpc?.();
98
+ if (!api) return;
99
+ rpcAbort = new AbortController();
100
+ stopRpc = onWorkspaceUpdated(api.events, () => currentLocation().directory, () => {
101
+ snapshotRequests.invalidate();
102
+ evidenceRequests.invalidate();
103
+ void refresh();
104
+ }, rpcEventOptions(context, rpcAbort.signal));
105
+ };
106
+ const queueRehydrate = (() => {
107
+ let queued = false;
108
+ return () => {
109
+ if (queued) return;
110
+ queued = true;
111
+ queueMicrotask(() => { queued = false; void rehydrate(); });
112
+ };
113
+ })();
114
+ const rehydrate = async () => {
115
+ const currentGeneration = ++generation;
116
+ snapshotRequests.invalidate();
117
+ evidenceRequests.invalidate();
118
+ rehydrateAbort?.abort();
119
+ rehydrateAbort = new AbortController();
120
+ nativeAbort?.abort();
121
+ nativeAbort = rehydrateAbort;
122
+ const isCurrent = () => currentGeneration === generation && !rehydrateAbort?.signal.aborted;
123
+ const [projects, active] = await Promise.allSettled([
124
+ context.data.project.sync(),
125
+ context.client.session.active({ signal: rehydrateAbort.signal }),
126
+ ]);
127
+ if (!isCurrent()) return;
128
+ if (projects.status === "rejected") setError(String(projects.reason));
129
+ if (active.status === "fulfilled") setActiveSessions(active.value);
130
+ else setError(String(active.reason));
131
+ try { await syncNativeSessions(isCurrent, nativeAbort.signal); }
132
+ catch (cause) { if (isCurrent()) setError(String(cause)); }
133
+ if (!isCurrent()) return;
134
+ await refresh(rehydrateAbort.signal);
135
+ if (!isCurrent()) return;
136
+ if (props.view === "evidence" || props.view === "workflow" || props.view === "architecture") {
137
+ const selected = activeSession();
138
+ if (selected) await refreshEvidence(selected);
139
+ }
140
+ };
141
+ const refreshLocationBinding = () => {
142
+ const directory = currentLocation().directory;
143
+ if (directory === observedDirectory) return false;
144
+ observedDirectory = directory;
145
+ generation++;
146
+ snapshotRequests.invalidate();
147
+ evidenceRequests.invalidate();
148
+ rehydrateAbort?.abort();
149
+ nativeAbort?.abort();
150
+ rpcAbort?.abort();
151
+ stopRpc?.();
152
+ subscribeRpc();
153
+ void rehydrate();
154
+ return true;
155
+ };
156
+ const invalidateFromHostEvent = (details: unknown) => {
157
+ const event = details as { type: string; data?: { sessionID?: string; form?: { sessionID?: string; info?: { sessionID?: string } } }; location?: { directory: string } };
158
+ const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID ?? event.data?.form?.info?.sessionID;
159
+ if (sessionID) {
160
+ context.data.session.invalidate(sessionID);
161
+ context.data.session.permission.invalidate(sessionID);
162
+ context.data.session.form.invalidate(sessionID, event.location ?? currentLocation());
163
+ }
164
+ if (["session.created", "session.deleted", "session.updated", "session.status", "session.idle", "session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "permission.asked", "permission.replied", "form.created", "form.updated", "form.deleted", "form.replied", "form.cancelled"].includes(event.type)) {
165
+ queueRehydrate();
166
+ }
167
+ };
168
+ void rehydrate();
169
+ subscribeRpc();
170
+ const stopEvents = context.data.listen(({ details }) => {
171
+ if (refreshLocationBinding()) return;
172
+ if (details.type === "server.connected") {
173
+ setRpcConnected(true);
174
+ subscribeRpc();
175
+ void rehydrate();
176
+ } else {
177
+ if ((details as { type: string }).type === "server.disconnected") {
178
+ setRpcConnected(false);
179
+ generation++;
180
+ snapshotRequests.invalidate();
181
+ evidenceRequests.invalidate();
182
+ nativeAbort?.abort();
183
+ rpcAbort?.abort();
184
+ stopRpc?.();
185
+ } else invalidateFromHostEvent(details);
186
+ }
187
+ });
188
+ onCleanup(() => { generation++; snapshotRequests.invalidate(); evidenceRequests.invalidate(); rehydrateAbort?.abort(); nativeAbort?.abort(); rpcAbort?.abort(); stopRpc?.(); stopEvents(); });
189
+ });
190
+ const filteredProjects = () => (snapshot()?.projects ?? []).filter((project) =>
191
+ `${project.name ?? ""} ${project.directory}`.toLowerCase().includes(query().toLowerCase()));
192
+ const navigationOptions = () => {
193
+ const state = snapshot();
194
+ if (!state) return [];
195
+ const projects = filteredProjects();
196
+ const projectIDs = new Set(projects.map((project) => project.id));
197
+ const tasks = state.tasks.filter((task) => projectIDs.has(task.projectID));
198
+ const sessions = workspaceSessions();
199
+ return [
200
+ ...projects.map((project) => ({ name: `Project · ${project.name ?? project.directory}`, description: project.directory, value: { type: "project", id: project.id } })),
201
+ ...tasks.map((task) => ({ name: `Task · ${task.title} [${task.status}]`, description: task.directory ?? task.branch ?? task.id, value: { type: "task", id: task.id } })),
202
+ ...sessions.map((session) => ({ name: `Session · ${session.title}${session.attention ? ` [${session.attention} pending]` : ` [${session.status}]`}`, description: session.directory, value: { type: "session", id: session.id } })),
203
+ ];
204
+ };
205
+ const selectNavigation = (option: { value?: { type: string; id: string } } | null) => {
206
+ const selection = option?.value;
207
+ if (selection?.type === "session") void openSession(selection.id);
208
+ else if (selection?.type === "task") {
209
+ const task = snapshot()?.tasks.find((item) => item.id === selection.id);
210
+ const session = snapshot()?.sessions.find((item) => item.taskID === task?.id);
211
+ if (session) void openSession(session.id);
212
+ else setError("This task does not have an associated native session yet.");
213
+ } else if (selection?.type === "project") {
214
+ const project = snapshot()?.projects.find((item) => item.id === selection.id);
215
+ if (project) void worktree(project.id);
216
+ }
217
+ };
218
+ const openSession = async (sessionID: string) => {
219
+ if (!api) { context.ui.router.navigate({ type: "session", sessionID }); return; }
220
+ try { await api.open({ sessionID }); context.ui.router.navigate({ type: "session", sessionID }); }
221
+ catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
222
+ };
223
+ const worktree = async (projectID: string) => {
224
+ const name = await context.ui.dialog.prompt({ title: "Create CTG worktree", placeholder: "feature-name" });
225
+ if (!name || !api) return;
226
+ try { const result = await api.createWorktree({ projectID, name }); context.ui.toast.show({ message: `Worktree created: ${context.ui.format.path(result.directory)}`, variant: "success" }); await refresh(); }
227
+ catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
228
+ };
229
+
230
+ const evidenceSessions = () => workspaceSessions().filter((session) => !activeSession() || session.id === activeSession());
231
+ const readiness = () => renderDeliveryReadiness(deliveryEvidence());
232
+ const taskForSession = (sessionID: string) => snapshot()?.sessions.find((session) => session.id === sessionID)?.taskID;
233
+ const loadPullRequests = async (sessionID: string) => {
234
+ const taskID = taskForSession(sessionID);
235
+ if (!taskID) { setError("This session is not attached to a CTG task."); return; }
236
+ try {
237
+ const result = await pullRequestClient(context, sessionLocation).list({ sessionID, taskID });
238
+ setPullRequests((current) => ({ ...current, [taskID]: result.pullRequests }));
239
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
240
+ };
241
+ const associatePullRequest = async (sessionID: string) => {
242
+ const taskID = taskForSession(sessionID);
243
+ if (!taskID) { setError("Associate a pull request from a session attached to a CTG task."); return; }
244
+ const answer = await context.ui.dialog.prompt({ title: "Associate GitHub pull request", placeholder: "Pull request number" });
245
+ if (!answer) return;
246
+ const number = Number(answer.trim());
247
+ if (!Number.isSafeInteger(number) || number < 1) { setError("Enter a positive pull request number."); return; }
248
+ try {
249
+ const result = await pullRequestClient(context, sessionLocation).associate({ sessionID, taskID, number });
250
+ setPullRequests((current) => ({ ...current, [taskID]: [...(current[taskID] ?? []).filter((item) => item.number !== number), result.pullRequest] }));
251
+ setError("");
252
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
253
+ };
254
+ const refreshPullRequest = async (sessionID: string, taskID: string, number: number) => {
255
+ try {
256
+ const result = await pullRequestClient(context, sessionLocation).refresh({ sessionID, taskID, number });
257
+ setPullRequests((current) => ({ ...current, [taskID]: (current[taskID] ?? []).map((item) => item.number === number ? result.pullRequest : item) }));
258
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
259
+ };
260
+ const refreshEvidence = async (sessionID: string) => {
261
+ if (!api) return;
262
+ const request = evidenceRequests.begin();
263
+ const signal = request.signal;
264
+ const [delivery, review] = await Promise.allSettled([
265
+ api.delivery({ sessionID }, signal),
266
+ api.review({ sessionID }, signal),
267
+ ]);
268
+ if (!request.isCurrent() || activeSession() !== sessionID) return;
269
+ setDeliveryEvidence(delivery.status === "fulfilled" ? delivery.value : { status: "invalid", error: String(delivery.reason) });
270
+ setReviewEvidence(review.status === "fulfilled" ? review.value : { error: String(review.reason) });
271
+ };
272
+ onMount(() => {
273
+ if (["evidence", "workflow", "architecture"].includes(props.view) && activeSession()) void refreshEvidence(activeSession()!);
274
+ });
275
+ return <box flexDirection="column" padding={1}>
276
+ <text fg={context.theme.text.feedback.warning.base}>CARTHAGENT · {props.view.toUpperCase()}</text>
277
+ <text fg={context.theme.text.muted}>CTG workspace tasks and native sessions associated with its projects.</text>
278
+ <box flexDirection="row" gap={1}>
279
+ <text fg={context.theme.text.action.secondary.base}>Filter:</text>
280
+ <input placeholder="project name or path" value={query()} onInput={setQuery} />
281
+ <text fg={context.theme.text.action.primary.base} on:click={() => void refresh()}>↻ Refresh</text>
282
+ </box>
283
+ <Show when={error()}><text fg={context.theme.text.feedback.error.base}>{error()}</text></Show>
284
+ <Show when={!rpcConnected()}><text fg={context.theme.text.feedback.warning.base}>Workspace RPC disconnected · cached view may be stale; waiting for native reconnect</text></Show>
285
+ <Show when={activeSession()}>{(active) => <box flexDirection="column" marginTop={1}>
286
+ <text fg={context.theme.text.action.primary.base}>Selected native session</text>
287
+ <For each={api ? snapshot()?.sessions.filter((session) => session.id === active()) ?? [] : localSessions().filter((session) => session.id === active())}>{(session) =>
288
+ <box flexDirection="column">
289
+ <text fg={context.theme.text.base}>{session.title}</text>
290
+ <Show when={api}>
291
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void api?.delivery({ sessionID: active() }).then((value: unknown) => context.ui.dialog.alert({ title: "Observed delivery evidence", message: JSON.stringify(value ?? { status: "none" }, null, 2) })).catch((cause: unknown) => setError(String(cause)))}>View delivery evidence</text>
292
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void api?.review({ sessionID: active() }).then((value) => context.ui.dialog.alert({ title: "Review status", message: `${value.status}${value.summary ? `\n\n${value.summary}` : ""}` })).catch((cause: unknown) => setError(String(cause)))}>View review status</text>
293
+ </Show>
294
+ </box>}</For>
295
+ <Show when={taskForSession(active())}>
296
+ <text fg={context.theme.text.action.primary.base}>Task pull requests · GitHub is queried only by explicit actions</text>
297
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void loadPullRequests(active())}>Load cached pull requests</text>
298
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void associatePullRequest(active())}>+ Associate pull request…</text>
299
+ <For each={pullRequests()[taskForSession(active())!] ?? []}>{(pr) => <box flexDirection="column">
300
+ <text fg={context.theme.text.base}>#{pr.number} · {pr.state}{pr.draft ? " · draft" : ""} · checks {pr.checks} · review {pr.review} · {pr.source}</text>
301
+ <text fg={context.theme.text.muted}>{pr.headRef} → {pr.baseRef} · {pr.head.slice(0, 10)} · updated {pr.updatedAt}</text>
302
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void refreshPullRequest(active(), pr.taskID, pr.number)}>↻ Refresh #{pr.number} from GitHub</text>
303
+ </box>}</For>
304
+ </Show>
305
+ </box>}</Show>
306
+ <Show when={snapshot()} fallback={<text fg={context.theme.text.faint}>{error() || "Loading local workspace…"}</text>}>
307
+ <Show when={props.view === "workflow"}>
308
+ <text fg={context.theme.text.action.primary.base}>Authoritative delivery workflow · {deliveryEvidence()?.status ?? "select a session"}</text>
309
+ <text fg={readiness().gate === "open" ? context.theme.text.feedback.success.base : context.theme.text.feedback.warning.base}>{readiness().label} · finish gate {readiness().gate}</text>
310
+ <For each={readiness().sections}>{(section) => <text fg={context.theme.text.feedback.warning.base}>Incomplete DTO section · {section}</text>}</For>
311
+ <For each={readiness().receipts}>{(receipt) => <text fg={context.theme.text.feedback.warning.base}>{receipt}</text>}</For>
312
+ <Show when={deliveryEvidence()?.workflow} fallback={<text fg={context.theme.text.faint}>Select a session with a delivery report to load workflow nodes, edges, and step receipts.</text>}>
313
+ <text fg={context.theme.text.muted}>{deliveryEvidence()?.workflow?.semantics}</text>
314
+ <For each={deliveryEvidence()?.workflow?.nodes ?? []}>{(node) => <text fg={node.status === "failed" || node.status === "blocked" ? context.theme.text.feedback.error.base : node.status === "complete" || node.status === "passed" ? context.theme.text.feedback.success.base : context.theme.text.base}>[{node.status}] {node.title} · {node.type}{node.step ? ` · step ${node.step}` : ""} · checks: {node.checks.join(", ") || "none"}</text>}</For>
315
+ <For each={deliveryEvidence()?.workflow?.edges ?? []}>{(edge) => <text fg={context.theme.text.muted}>{edge.from} → {edge.to} · {edge.label ?? edge.kind}{edge.when ? ` · when ${edge.when}` : ""}</text>}</For>
316
+ </Show>
317
+ <text fg={context.theme.text.action.secondary.base} on:click={() => activeSession() && void refreshEvidence(activeSession()!)}>↻ Refresh delivery workflow</text>
318
+ </Show>
319
+ <Show when={props.view === "architecture"}>
320
+ <text fg={context.theme.text.action.primary.base}>Delivery architecture · {deliveryEvidence()?.status ?? "select a session"}</text>
321
+ <text fg={context.theme.text.feedback.warning.base}>{readiness().label} · finish gate {readiness().gate}</text>
322
+ <For each={readiness().sections}>{(section) => <text fg={context.theme.text.feedback.warning.base}>Incomplete DTO section · {section}</text>}</For>
323
+ <For each={readiness().receipts}>{(receipt) => <text fg={context.theme.text.feedback.warning.base}>{receipt}</text>}</For>
324
+ <Show when={deliveryEvidence()?.architecture} fallback={<text fg={context.theme.text.faint}>Select a session with a delivery report to load authored architecture nodes, edges, and ports.</text>}>
325
+ <For each={deliveryEvidence()?.architecture?.nodes ?? []}>{(node) => <text fg={context.theme.text.base}>◆ {node.title} · {node.type ?? "component"}{node.layer ? ` · layer ${node.layer}` : ""}{node.status ? ` · ${node.status}` : ""}</text>}</For>
326
+ <For each={deliveryEvidence()?.architecture?.edges ?? []}>{(edge) => <text fg={context.theme.text.muted}>{edge.from} → {edge.to} · {edge.label ?? edge.kind}</text>}</For>
327
+ <For each={deliveryEvidence()?.architecture?.ports ?? []}>{(port) => <text fg={context.theme.text.action.secondary.base}>⇄ {port.id} · {port.owner} via {port.adapter} · root {port.compositionRoot}{port.integrationAssertion ? ` · verifies ${port.integrationAssertion}` : ""}</text>}</For>
328
+ </Show>
329
+ <text fg={context.theme.text.action.secondary.base} on:click={() => activeSession() && void refreshEvidence(activeSession()!)}>↻ Refresh delivery architecture</text>
330
+ </Show>
331
+ <Show when={props.view === "evidence"}>
332
+ <text fg={context.theme.text.action.primary.base}>Observed delivery evidence</text>
333
+ <For each={evidenceSessions()}>{(session) => <text fg={context.theme.text.muted}>{session.title} · {session.status} · {session.directory}</text>}</For>
334
+ <Show when={evidenceSessions().length === 0}><text fg={context.theme.text.faint}>No matching workspace session has recorded evidence.</text></Show>
335
+ <Show when={activeSession()}>
336
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void refreshEvidence(activeSession()!)}>↻ Refresh selected session evidence</text>
337
+ <text fg={context.theme.text.action.primary.base}>Delivery receipt</text>
338
+ <Show when={deliveryEvidence()} fallback={<text fg={context.theme.text.faint}>Loading delivery receipt…</text>}>
339
+ <text fg={context.theme.text.base}>Status: {deliveryEvidence()?.status} · {deliveryEvidence()?.freshness ?? "freshness unavailable"} · revision {deliveryEvidence()?.revision ?? "—"} · run {deliveryEvidence()?.runId ?? "—"}</text>
340
+ <text fg={readiness().gate === "open" ? context.theme.text.feedback.success.base : readiness().gate === "locked" ? context.theme.text.feedback.warning.base : context.theme.text.muted}>{readiness().label} · finish gate {readiness().gate} · {readiness().coverageLabel}</text>
341
+ <For each={readiness().sections}>{(section) => <text fg={context.theme.text.muted}>Returned DTO section · {section}</text>}</For>
342
+ <For each={readiness().receipts}>{(receipt) => <text fg={context.theme.text.feedback.warning.base}>{receipt}</text>}</For>
343
+ <Show when={deliveryEvidence()?.plan}>{(plan) => <box flexDirection="column">
344
+ <text fg={context.theme.text.action.primary.base}>Goal: {plan().goal}</text>
345
+ <Show when={deliveryEvidence()?.truncation?.steps}>{(scope) => <text fg={context.theme.text.muted}>Steps: {scope().returned} of {scope().total} returned</text>}</Show>
346
+ <For each={plan().steps}>{(step) => <text fg={step.status === "completed" ? context.theme.text.feedback.success.base : context.theme.text.base}>[{step.status}] {step.id} · {step.title}{step.dependsOn.length ? ` · after ${step.dependsOn.join(", ")}` : ""}</text>}</For>
347
+ <Show when={deliveryEvidence()?.truncation?.checks}>{(scope) => <text fg={context.theme.text.muted}>Checks: {scope().returned} of {scope().total} returned</text>}</Show>
348
+ <For each={deliveryEvidence()?.evidence ?? []}>{(item) => <text fg={item.status === "fresh" ? context.theme.text.feedback.success.base : item.status === "failed" || item.status === "stale" ? context.theme.text.feedback.error.base : context.theme.text.feedback.warning.base}>Receipt {item.id}: {item.status}{item.code !== undefined ? ` · exit ${item.code}` : ""}{item.filename ? ` · ${item.filename}` : ""}</text>}</For>
349
+ </box>}</Show>
350
+ </Show>
351
+ <text fg={context.theme.text.action.primary.base}>Review receipt</text>
352
+ <Show when={reviewEvidence()} fallback={<text fg={context.theme.text.faint}>Loading review receipt…</text>}>
353
+ <text fg={context.theme.text.muted}>{String((reviewEvidence() as { status?: string; summary?: string } | undefined)?.status ?? "unavailable")}{(reviewEvidence() as { summary?: string } | undefined)?.summary ? ` · ${(reviewEvidence() as { summary: string }).summary}` : ""}</text>
354
+ </Show>
355
+ </Show>
356
+ </Show>
357
+ <select options={navigationOptions()} onSelect={(_index, option) => selectNavigation(option)} focused />
358
+ <For each={filteredProjects()}>{(project) => <box flexDirection="column" marginTop={1}>
359
+ <text fg={context.theme.text.action.primary.base}>◆ {project.name ?? project.directory}</text>
360
+ <text fg={context.theme.text.muted}>{context.ui.format.path(project.directory)}</text>
361
+ <text fg={context.theme.text.action.secondary.base} on:click={() => void worktree(project.id)}>+ Create worktree</text>
362
+ <For each={snapshot()?.tasks.filter((task) => task.projectID === project.id)}>{(task) => <box flexDirection="column" paddingLeft={2}>
363
+ <text fg={task.attention ? context.theme.text.feedback.warning.base : context.theme.text.base}>{task.attention ? "! " : "· "}{task.title} [{task.status}]</text>
364
+ <For each={snapshot()?.sessions.filter((session) => session.taskID === task.id)}>{(session) =>
365
+ <text fg={context.theme.text.muted}> ↳ {session.title} · {session.status}</text>}</For>
366
+ </box>}</For>
367
+ </box>}</For>
368
+ </Show>
369
+ <Show when={snapshot()}>
370
+ <text fg={context.theme.text.action.primary.base}>Workspace native sessions</text>
371
+ <For each={workspaceSessions()}>{(session) => <text fg={session.attention ? context.theme.text.feedback.warning.base : context.theme.text.action.secondary.base} on:click={() => void openSession(session.id)}>{session.attention ? "! " : "· "}{session.title} [{session.attention ? `${session.attention} pending` : session.status}] · {session.directory}</text>}</For>
372
+ </Show>
373
+ <Show when={!snapshot()}>
374
+ <text fg={context.theme.text.action.primary.base}>Local Carthagent sessions</text>
375
+ <For each={localSessions().filter((session) => !query() || `${session.title ?? ""} ${session.id} ${session.location.directory}`.toLowerCase().includes(query().toLowerCase()))}>{(session) => <text fg={context.theme.text.action.secondary.base} on:click={() => context.ui.router.navigate({ type: "session", sessionID: session.id })}>· {session.title ?? session.id} [{context.data.session.status(session.id)}]</text>}</For>
376
+ </Show>
377
+ <text fg={context.theme.text.faint}>Keyboard: use /ctg-open to search sessions; /ctg-worktree creates a worktree; /ctg-task starts a task.</text>
378
+ </box>;
379
+ }
@@ -0,0 +1,64 @@
1
+ import { Rpc } from '@opencode/plugin/rpc';
2
+
3
+ export const voyagesSlots = ['hairStyle', 'hat', 'shirt', 'bottoms', 'shoes', 'pet', 'hull', 'sail', 'setting'] as const;
4
+ export const voyagesAppearance = { skin: ['warm', 'light', 'deep', 'tan'], hair: ['dark', 'silver', 'copper'], pattern: ['stripes', 'plain'] } as const;
5
+ export type VoyagesSlot = typeof voyagesSlots[number];
6
+ export type VoyagesProfile = {
7
+ version: number; name: string; shipName: string; coins: number; totalXp: number; level: number;
8
+ xpIntoLevel: number; xpForLevel: number; km: number; weeklyKm: number;
9
+ config: Record<VoyagesSlot, string> & { skin: string; hair: string; pattern: string }; owned: string[];
10
+ };
11
+ export type VoyagesItem = { item: string; name: string; slot: VoyagesSlot; itemKey: string; price: number | null; requiresPurchase: boolean; available: boolean; eligible: boolean; reason: string | null };
12
+ export type VoyagesQuote = { id: string; item: string; price: number; expiresAt: string };
13
+ export type VoyagesPending = { quoteId: string; item: string; price: number };
14
+ export type VoyagesSnapshot = { profile: VoyagesProfile; items: VoyagesItem[]; pending?: VoyagesPending };
15
+
16
+ const text = { type: 'string', minLength: 1, maxLength: 128 } as const;
17
+ // Native Effect JSON-schema conversion does not support arbitrary patterns.
18
+ // The server validates equipment key syntax before forwarding mutations.
19
+ const key = { type: 'string', minLength: 1, maxLength: 64 } as const;
20
+ const integer = { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER } as const;
21
+ const version = { ...integer, minimum: 1 } as const;
22
+ const slot = { type: 'string', enum: voyagesSlots } as const;
23
+ const skin = { type: 'string', enum: voyagesAppearance.skin } as const;
24
+ const hair = { type: 'string', enum: voyagesAppearance.hair } as const;
25
+ const pattern = { type: 'string', enum: voyagesAppearance.pattern } as const;
26
+ const name = { type: 'string', minLength: 1, maxLength: 20 } as const;
27
+ const shipName = { type: 'string', minLength: 1, maxLength: 28 } as const;
28
+ const config = {
29
+ type: 'object', properties: { hairStyle: key, hat: key, shirt: key, bottoms: key, shoes: key, pet: key, hull: key, sail: key, setting: key, skin, hair, pattern },
30
+ required: [...voyagesSlots, 'skin', 'hair', 'pattern'], additionalProperties: false,
31
+ } as const;
32
+ const profile = {
33
+ type: 'object', properties: { version, name, shipName, coins: integer, totalXp: integer, level: version, xpIntoLevel: integer, xpForLevel: version, km: { type: 'number', minimum: 0 }, weeklyKm: { type: 'number', minimum: 0 }, config, owned: { type: 'array', maxItems: 10000, items: text, uniqueItems: true } },
34
+ required: ['version', 'name', 'shipName', 'coins', 'totalXp', 'level', 'xpIntoLevel', 'xpForLevel', 'km', 'weeklyKm', 'config', 'owned'], additionalProperties: false,
35
+ } as const;
36
+ const item = {
37
+ type: 'object', properties: { item: text, name: text, slot, itemKey: key, price: { ...integer, type: ['integer', 'null'] }, requiresPurchase: { type: 'boolean' }, available: { type: 'boolean' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'], maxLength: 128 } },
38
+ required: ['item', 'name', 'slot', 'itemKey', 'price', 'requiresPurchase', 'available', 'eligible', 'reason'], additionalProperties: false,
39
+ } as const;
40
+ const pending = { type: 'object', properties: { quoteId: text, item: text, price: integer }, required: ['quoteId', 'item', 'price'], additionalProperties: false } as const;
41
+ const quote = { type: 'object', properties: { id: text, item: text, price: integer, expiresAt: { type: 'string', format: 'date-time' } }, required: ['id', 'item', 'price', 'expiresAt'], additionalProperties: false } as const;
42
+ const errors = { voyagesError: { type: 'object', properties: { code: text, status: { type: 'integer', minimum: 0, maximum: 599 }, uncertain: { type: 'boolean' } }, required: ['code', 'status', 'uncertain'], additionalProperties: false } } as const;
43
+ const empty = { type: 'object', additionalProperties: false } as const;
44
+ const selection = { type: 'object', properties: { slot, item: key }, required: ['slot', 'item'], additionalProperties: false } as const;
45
+ const profileResult = { type: 'object', properties: { profile }, required: ['profile'], additionalProperties: false } as const;
46
+ const receipt = { type: 'object', properties: { receiptId: text, item: text, price: integer }, required: ['receiptId', 'item', 'price'], additionalProperties: false } as const;
47
+ const reward = { type: 'object', properties: { id: text, runId: text, policyVersion: text, coins: integer, xp: integer, km: integer, awardedAt: text, basis: text }, required: ['id', 'runId', 'policyVersion', 'coins', 'xp', 'km', 'awardedAt', 'basis'], additionalProperties: false } as const;
48
+ const rewardPreview = { type: 'object', properties: { runId: text, snapshot: text, coins: integer, xp: integer, km: integer, dailyLimit: integer }, required: ['runId', 'snapshot', 'coins', 'xp', 'km', 'dailyLimit'], additionalProperties: false } as const;
49
+
50
+ /** Authenticated server-only Voyages boundary. Confirmation belongs to the UI. */
51
+ export const Voyages = Rpc.define({
52
+ id: 'carthagent.voyages',
53
+ methods: {
54
+ previewReward: { errors, input: { type: 'object', properties: { sessionID: text }, required: ['sessionID'], additionalProperties: false }, output: rewardPreview },
55
+ claimReward: { errors, input: { type: 'object', properties: { sessionID: text, runId: text, snapshot: text }, required: ['sessionID', 'runId', 'snapshot'], additionalProperties: false }, output: { type: 'object', properties: { reward, replayed: { type: 'boolean' } }, required: ['reward', 'replayed'], additionalProperties: false } },
56
+ snapshot: { errors, input: empty, output: { type: 'object', properties: { profile, items: { type: 'array', maxItems: 10000, items: item }, pending }, required: ['profile', 'items'], additionalProperties: false } },
57
+ customize: { errors, input: { type: 'object', properties: { version, name, shipName, skin, hair, pattern }, required: ['version'], additionalProperties: false }, output: profileResult },
58
+ equip: { errors, input: { ...selection, properties: { ...selection.properties, version }, required: ['version', 'slot', 'item'] }, output: profileResult },
59
+ quote: { errors, input: selection, output: { type: 'object', properties: { quote }, required: ['quote'], additionalProperties: false } },
60
+ purchase: { errors, input: { type: 'object', properties: { quoteId: text }, required: ['quoteId'], additionalProperties: false }, output: receipt },
61
+ retryPurchase: { errors, input: { type: 'object', properties: { quoteId: text }, required: ['quoteId'], additionalProperties: false }, output: receipt },
62
+ },
63
+ events: {},
64
+ });
@@ -131,3 +131,39 @@ export interface WorkspaceSnapshot {
131
131
  tasks: Array<{ id: string; projectID: string; title: string; status: string; attention?: boolean; directory?: string; branch?: string }>;
132
132
  sessions: Array<{ id: string; taskID?: string; projectID?: string; title: string; status: string; updatedAt?: string; directory: string }>;
133
133
  }
134
+
135
+ export interface WorkspaceDelivery {
136
+ status: string;
137
+ freshness?: "current" | "stale";
138
+ completion?: "ready" | "incomplete";
139
+ gate?: "locked" | "open";
140
+ runId?: string;
141
+ revision?: number;
142
+ fingerprint?: string;
143
+ truncation?: Record<string, { returned: number; total: number }>;
144
+ plan?: {
145
+ goal: string;
146
+ verification: string;
147
+ acceptance: Array<{ requirement: string; checks: string[] }>;
148
+ artifacts: string[];
149
+ outputs: string[];
150
+ assumptions: string[];
151
+ checks: Array<{ id: string; kind: string; argv: string[]; timeoutSeconds: number }>;
152
+ steps: Array<{ id: string; title: string; status: string; dependsOn: string[]; checks: string[] }>;
153
+ scenarios: Array<{ id: string; requirement: string; kind: string; path: string[]; checks: string[]; input: string; expected: string; assertion: string }>;
154
+ risks: Array<{ id: string; description: string; status: string; blocking: boolean; evidence: string }>;
155
+ };
156
+ workflow?: {
157
+ authored: boolean;
158
+ semantics: string;
159
+ nodes: Array<{ id: string; title: string; type: string; status: string; step?: string; requirements: string[]; components: string[]; checks: string[]; inputs: string[]; artifacts: string[]; effects: string[]; condition?: string }>;
160
+ edges: Array<{ from: string; to: string; kind: string; label?: string; when?: string; stop?: string; maxAttempts?: number }>;
161
+ };
162
+ architecture?: {
163
+ nodes: Array<{ id: string; title: string; type?: string; status?: string; layer?: string; excludes?: string[]; requirements?: string[] }>;
164
+ edges: Array<{ from: string; to: string; kind: string; label?: string }>;
165
+ ports: Array<{ id: string; owner: string; adapter: string; compositionRoot: string; input: string; output: string; errors: string; injection: string; testDouble: string; integrationAssertion: string }>;
166
+ };
167
+ evidence?: Array<{ id: string; status: "pending" | "failed" | "fresh" | "stale"; executedRevision?: number; code?: number; filename?: string }>;
168
+ error?: string;
169
+ }
@@ -0,0 +1,107 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { COMMANDS, matchCommand, NATIVE_COMMAND_COLLISIONS, resolveCommand } from "../src/ui/commands.js";
4
+ import { readFile } from "node:fs/promises";
5
+ import { parseThemeDocument } from "@opencode/theme/tui";
6
+ import { cliConfigDirectory, cliConfigPath, readCliConfig, writeNativeTheme } from "../src/ui/host-state.js";
7
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { provisionNativeThemes } from "../src/ui/theme-provision.mjs";
11
+
12
+ test("Carthagent command manifest resolves visible CTG commands", () => {
13
+ assert.equal(resolveCommand("/help")?.id, "help");
14
+ assert.equal(resolveCommand("/ctg-help")?.id, "help");
15
+ assert.deepEqual(COMMANDS.find((item) => item.id === "help")?.aliases, ["ctg-help"]);
16
+ assert.equal(COMMANDS.find((item) => item.id === "help")?.submitSlash, true);
17
+ assert.equal(resolveCommand("/review")?.id, "review");
18
+ assert.equal(resolveCommand("/review-mode")?.id, "review");
19
+ for (const name of ["workflow", "architecture", "evidence"]) assert.equal(resolveCommand(name)?.hidden, undefined);
20
+ for (const name of ["model", "compact", "autocompact", "abort", "clear"]) assert.equal(resolveCommand(name)?.id, name);
21
+ assert.deepEqual(matchCommand("/arch").map((item) => item.name), ["architecture", "archive"]);
22
+ assert.deepEqual(matchCommand("/rev status"), []);
23
+ for (const name of ["login", "account", "usage", "billing", "logout-cloud", "archive", "demo", "voyages", "quests"]) {
24
+ assert.ok(resolveCommand(`/${name}`), `/${name} must resolve to a registered handler`);
25
+ }
26
+ assert.equal(resolveCommand("/cloud")?.id, "account");
27
+ assert.equal(resolveCommand("/auth")?.id, "login");
28
+ assert.match(resolveCommand("/voyages")!.description, /daily quests/);
29
+ assert.match(resolveCommand("/quests")!.description, /separate explicit consent/);
30
+ });
31
+
32
+ test("manifest prevents collisions with native OpenCode slash commands", () => {
33
+ for (const name of NATIVE_COMMAND_COLLISIONS) assert.equal(COMMANDS.some((item) => item.name === name || item.aliases?.includes(name)), false, name);
34
+ assert.equal(NATIVE_COMMAND_COLLISIONS.includes("help" as typeof NATIVE_COMMAND_COLLISIONS[number]), false);
35
+ assert.deepEqual(COMMANDS.filter((item) => ["model", "compact", "abort", "resume"].includes(item.name)).map((item) => item.name).sort(), ["abort", "compact", "model", "resume"]);
36
+ });
37
+
38
+ test("theme provisioning installs the startup default through its launcher-facing export", async () => {
39
+ const { provisionDefaultNativeTheme } = await import("../src/ui/theme-provision.mjs");
40
+ const directory = await mkdtemp(join(tmpdir(), "ctg-default-theme-"));
41
+ try {
42
+ const result = await provisionDefaultNativeTheme(directory);
43
+ assert.ok(result.installed.includes("carthage"));
44
+ const theme = parseThemeDocument(JSON.parse(await readFile(join(result.directory, "carthage.json"), "utf8")), "carthage");
45
+ assert.equal(theme.dark.hue.yellow[400], "#d5af66");
46
+ } finally { await rm(directory, { recursive: true, force: true }); }
47
+ });
48
+
49
+ test("startup theme provisioning covers compatibility themes and preserves host/user themes", async () => {
50
+ const { provisionDefaultNativeTheme } = await import("../src/ui/theme-provision.mjs");
51
+ const directory = await mkdtemp(join(tmpdir(), "ctg-startup-themes-"));
52
+ try {
53
+ await mkdir(join(directory, "themes"), { recursive: true });
54
+ await writeFile(join(directory, "themes", "forest.json"), '{"userOwned":true}');
55
+ const result = await provisionDefaultNativeTheme(directory);
56
+ assert.ok(result.installed.includes("carthage"));
57
+ assert.ok(result.installed.includes("forest") === false);
58
+ assert.ok(result.conflicts.includes("forest"));
59
+ assert.ok(result.installed.includes("opencode") === false);
60
+ assert.ok(result.installed.includes("tokyonight") === false);
61
+ assert.deepEqual(JSON.parse(await readFile(join(directory, "themes", "forest.json"), "utf8")), { userOwned: true });
62
+ } finally { await rm(directory, { recursive: true, force: true }); }
63
+ });
64
+
65
+ test("Carthage theme validates against the OpenCode V2 theme schema and resolver", async () => {
66
+ const raw = JSON.parse(await readFile(new URL("../themes/carthage.json", import.meta.url), "utf8"));
67
+ const resolved = parseThemeDocument(raw, "carthage");
68
+ assert.ok(resolved.dark?.hue.yellow);
69
+ assert.equal(resolved.base.categorical?.length, 5);
70
+ });
71
+
72
+ test("native CLI theme write preserves unrelated cli.json settings", async () => {
73
+ const directory = await mkdtemp(join(tmpdir(), "ctg-cli-config-"));
74
+ const path = join(directory, "cli.json");
75
+ try {
76
+ await writeFile(path, JSON.stringify({ theme: { name: "opencode", mode: "dark" }, session: { thinking: "show" }, custom: true }));
77
+ await writeNativeTheme(path, "carthage");
78
+ const config = await readCliConfig(path);
79
+ assert.deepEqual(config.theme, { name: "carthage", mode: "dark" });
80
+ assert.deepEqual(config.session, { thinking: "show" });
81
+ assert.equal(config.custom, true);
82
+ assert.equal(cliConfigPath({ XDG_CONFIG_HOME: "/tmp/profile" }, "/home/test"), join("/tmp/profile", "opencode", "cli.json"));
83
+ assert.equal(cliConfigDirectory({ OPENCODE_CONFIG_DIR: "/tmp/opencode-profile" }), "/tmp/opencode-profile");
84
+ assert.equal(cliConfigDirectory({ XDG_CONFIG_HOME: "/tmp/profile" }, "/home/test"), join("/tmp/profile", "opencode"));
85
+ } finally { await rm(directory, { recursive: true, force: true }); }
86
+ });
87
+
88
+ test("native theme provisioning converts legacy palettes inside the selected OpenCode profile", async () => {
89
+ const directory = await mkdtemp(join(tmpdir(), "ctg-native-themes-"));
90
+ const themeDirectory = join(directory, "themes");
91
+ try {
92
+ await mkdir(themeDirectory, { recursive: true });
93
+ await writeFile(join(themeDirectory, "user-theme.json"), '{"custom":true}');
94
+ const result = await provisionNativeThemes(directory, ["carthage", "nebula"]);
95
+ assert.deepEqual(result.installed.sort(), ["carthage", "nebula"]);
96
+ assert.deepEqual(JSON.parse(await readFile(join(themeDirectory, "user-theme.json"), "utf8")), { custom: true });
97
+ const carthage = parseThemeDocument(JSON.parse(await readFile(join(themeDirectory, "carthage.json"), "utf8")), "carthage");
98
+ const nebula = parseThemeDocument(JSON.parse(await readFile(join(themeDirectory, "nebula.json"), "utf8")), "nebula");
99
+ assert.equal(carthage.dark.hue.yellow[400], "#d5af66");
100
+ assert.equal(carthage.dark.hue.yellow[300], "#e6c480");
101
+ assert.ok(nebula.dark.hue.blue[400]);
102
+ const again = await provisionNativeThemes(directory, ["carthage"]);
103
+ assert.deepEqual(again.installed, []);
104
+ await writeFile(join(themeDirectory, "forest.json"), '{"userOwned":true}');
105
+ await assert.rejects(provisionNativeThemes(directory, ["forest"]), /Refusing to replace/);
106
+ } finally { await rm(directory, { recursive: true, force: true }); }
107
+ });