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
@@ -1,13 +1,47 @@
1
- import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
2
- import { randomUUID } from 'node:crypto';
3
- import { homedir } from 'node:os';
1
+ import { chmod, lstat, mkdir, open, opendir, readFile, realpath, rename, rm, stat, unlink } from 'node:fs/promises';
2
+ import { constants, readFileSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { arch, homedir, platform } from 'node:os';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import path from 'node:path';
6
8
  import { spawn } from 'node:child_process';
9
+ import { acquireOwnedRuntime } from './runtime-artifact.mjs';
10
+ import { createRuntimeGithubFetch } from './runtime-github-transport.mjs';
7
11
 
8
12
  export const OPENCODE_VERSION = '2.0.32';
13
+ export const CTG_RUNTIME_VERSION = '0.0.0-ctg.2.0.32';
14
+ export const CTG_RUNTIME_SOURCE = '843a6c514e606b436a7fe713422ace0a93ad3d61';
15
+ export const CTG_RUNTIME_LOCK_SHA256 = '9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d';
16
+ export const CTG_RUNTIME_PATCH_SHA256 = 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975';
17
+ // Update only this development pin after reviewing a new checked-in patch and
18
+ // script/ctg/prepare-development-runtime.sh. The published release pin above,
19
+ // its build recipe, and the release manifest stay immutable until a separately
20
+ // verified runtime release is promoted.
21
+ export const CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975';
9
22
  const DEFAULT_ROOT = path.join(homedir(), '.carthagent', 'opencode');
23
+ const require = createRequire(import.meta.url);
10
24
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
25
+ const RUNTIME_RELEASE_MANIFEST = path.join(PACKAGE_ROOT, 'lib', 'opencode', 'runtime-release.json');
26
+ // Tracks the verified cache selected by the packaged release path without
27
+ // turning it into the separate, explicitly trusted staging selector.
28
+ const RESOLVED_OWNED_RUNTIME = Symbol('resolvedOwnedRuntime');
29
+
30
+ export function resolvePathCommand(command, env = process.env, hostPlatform = platform()) {
31
+ const entries = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean);
32
+ const extensions = hostPlatform === 'win32' && !path.extname(command)
33
+ ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
34
+ : [''];
35
+ for (const entry of entries) {
36
+ for (const extension of extensions) {
37
+ const candidate = path.resolve(entry, `${command}${extension}`);
38
+ try {
39
+ const details = require('node:fs').statSync(candidate);
40
+ if (details.isFile() && (hostPlatform === 'win32' || (details.mode & 0o111) !== 0)) return candidate;
41
+ } catch {}
42
+ }
43
+ }
44
+ }
11
45
 
12
46
  function abs(value) { return path.resolve(value); }
13
47
 
@@ -34,31 +68,202 @@ export function runtimePaths(options = {}) {
34
68
 
35
69
  /** Find a CTG/OpenCode executable without downloading or replacing user binaries. */
36
70
  export async function resolveOpenCode(options = {}) {
71
+ const owned = options.ownedRuntime ?? process.env.CTG_OWNED_OPENCODE_RUNTIME;
37
72
  const explicit = options.binary ?? process.env.CTG_OPENCODE_BIN;
38
- const packaged = path.join(PACKAGE_ROOT, 'node_modules', '@opencode', 'cli', 'bin', 'opencode.exe');
73
+ if (!explicit && owned) return resolveOwnedOpenCode(owned, options);
74
+ if (!explicit) {
75
+ const release = await readRuntimeReleaseManifest(options.runtimeReleaseManifestPath ?? RUNTIME_RELEASE_MANIFEST);
76
+ if (release) {
77
+ const key = `${platform() === 'win32' ? 'windows' : platform()}-${arch()}`;
78
+ const asset = release.assets[key];
79
+ if (!asset) throw new Error(`This CTG release does not provide its owned Carthagent runtime for ${key}; no upstream or system fallback was attempted.`);
80
+ const root = await (options.acquireOwnedRuntime ?? acquireOwnedRuntime)({
81
+ releaseVersion: release.releaseVersion,
82
+ platform: platform() === 'win32' ? 'windows' : platform(),
83
+ arch: arch(),
84
+ assetName: asset.name,
85
+ expectedArchiveSha256: asset.archiveSha256,
86
+ expectedExecutableSha256: asset.executableSha256,
87
+ expectedSourceRevision: release.sourceRevision,
88
+ expectedPatchSha256: release.patchSha256,
89
+ expectedLockSha256: release.lockSha256,
90
+ expectedCompatibilityVersion: release.compatibilityVersion,
91
+ expectedRuntimeVersion: release.runtimeVersion,
92
+ expectedChannel: `ctg.${release.compatibilityVersion}`,
93
+ baseUrl: options.runtimeReleaseBaseUrl ?? `https://github.com/B-icy/Carthagent/releases/download/v${release.releaseVersion}`,
94
+ cacheRoot: options.runtimeCacheRoot,
95
+ fetchImpl: process.env.CTG_RUNTIME_GITHUB_TOKEN
96
+ ? createRuntimeGithubFetch({ token: process.env.CTG_RUNTIME_GITHUB_TOKEN, fetchImpl: options.fetchImpl ?? globalThis.fetch })
97
+ : options.fetchImpl,
98
+ signal: options.signal,
99
+ });
100
+ // Automatic acquisition never opts into development provenance, even
101
+ // when a caller supplies an acquisition implementation or cached root.
102
+ const binary = await resolvePinnedOwnedOpenCode(root, options, false, asset.executableSha256);
103
+ // Preserve the selection for runtimeIdentity(), but do not put it in
104
+ // ownedRuntime: that is an explicit developer/staging trust selector.
105
+ // Repeated resolution must pass through acquisition again so its pinned
106
+ // executable digest is rechecked even if the cache and SHA256SUMS changed.
107
+ Object.defineProperty(options, RESOLVED_OWNED_RUNTIME, {
108
+ configurable: true,
109
+ value: root,
110
+ });
111
+ return binary;
112
+ }
113
+ }
114
+ let packaged;
115
+ if (!explicit) {
116
+ try {
117
+ const packageJson = require.resolve('@opencode/cli/package.json');
118
+ const metadata = JSON.parse(await readFile(packageJson, 'utf8'));
119
+ const executable = typeof metadata.bin === 'string'
120
+ ? metadata.bin
121
+ : metadata.bin?.opencode;
122
+ if (typeof executable !== 'string') throw new Error('package has no opencode binary entry');
123
+ packaged = path.resolve(path.dirname(packageJson), executable);
124
+ } catch (error) {
125
+ throw new Error(`Packaged Carthagent runtime ${OPENCODE_VERSION} dependency could not be resolved from CTG: ${error.message}`);
126
+ }
127
+ }
39
128
  const candidates = explicit ? [explicit] : [packaged];
40
129
  for (const candidate of candidates) {
41
130
  if (candidate.includes(path.sep) || candidate.includes('/')) {
42
131
  try {
43
- const result = await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl });
132
+ const result = options.spawnImpl
133
+ ? await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl })
134
+ : await runCapture(candidate, ['--version'], { timeout: 5000 });
44
135
  const version = result.stdout.trim().match(/(?:opencode\s+)?v?(\d+\.\d+\.\d+)/i)?.[1];
45
- if (version !== OPENCODE_VERSION) throw new Error(`OpenCode ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} at ${candidate}`);
136
+ if (version !== OPENCODE_VERSION) throw new Error(`Carthagent runtime ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} at ${candidate}`);
46
137
  return abs(candidate);
47
138
  } catch (error) {
48
139
  if (explicit || /is required/.test(error.message)) throw error;
49
140
  }
50
- } else {
141
+ } else {
51
142
  try {
52
- const result = await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl });
143
+ const result = await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl, env: options.env });
53
144
  const version = result.stdout.trim().match(/(?:opencode\s+)?v?(\d+\.\d+\.\d+)/i)?.[1];
54
- if (version !== OPENCODE_VERSION) throw new Error(`OpenCode ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} on PATH`);
55
- return candidate;
145
+ if (version !== OPENCODE_VERSION) throw new Error(`Carthagent runtime ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} on PATH`);
146
+ const resolved = resolvePathCommand(candidate, options.env ?? process.env);
147
+ if (!resolved) throw new Error(`${candidate} was not found on PATH`);
148
+ return await realpath(resolved);
56
149
  } catch (error) {
57
150
  if (explicit || /is required/.test(error.message)) throw error;
58
151
  }
59
152
  }
60
153
  }
61
- throw new Error(`Packaged OpenCode ${OPENCODE_VERSION} was not found at ${packaged}. Reinstall CTG or set CTG_OPENCODE_BIN explicitly; CTG will not use or replace another OpenCode installation.`);
154
+ throw new Error(`Packaged Carthagent runtime ${OPENCODE_VERSION} was not found at ${packaged}. Reinstall CTG or set CTG_OPENCODE_BIN explicitly; CTG will not use or replace another Carthagent runtime installation.`);
155
+ }
156
+
157
+ /** Read and strictly validate the package-pinned release trust root. */
158
+ export async function readRuntimeReleaseManifest(file = RUNTIME_RELEASE_MANIFEST) {
159
+ let manifest;
160
+ try { manifest = JSON.parse(await readFile(file, 'utf8')); }
161
+ catch (error) {
162
+ if (error.code === 'ENOENT') return undefined;
163
+ throw new Error(`Invalid CTG Carthagent runtime release manifest at ${file}: ${error.message}`);
164
+ }
165
+ const invalid = () => { throw new Error(`Invalid or incompatible CTG Carthagent runtime release manifest at ${file}`); };
166
+ let packageMetadata;
167
+ try { packageMetadata = JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); }
168
+ catch (error) { throw new Error(`Could not verify CTG package version for runtime release manifest: ${error.message}`); }
169
+ if (!manifest || manifest.version !== 1
170
+ || !isReleaseVersion(manifest.releaseVersion)
171
+ || manifest.releaseVersion !== packageMetadata.version
172
+ || manifest.compatibilityVersion !== OPENCODE_VERSION
173
+ || manifest.runtimeVersion !== CTG_RUNTIME_VERSION
174
+ || manifest.sourceRevision !== CTG_RUNTIME_SOURCE
175
+ || manifest.patchSha256 !== CTG_RUNTIME_PATCH_SHA256
176
+ || manifest.lockSha256 !== CTG_RUNTIME_LOCK_SHA256
177
+ || !manifest.assets || typeof manifest.assets !== 'object' || Array.isArray(manifest.assets)) invalid();
178
+ const allowed = new Set(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']);
179
+ for (const [key, asset] of Object.entries(manifest.assets)) {
180
+ if (!allowed.has(key) || !asset || typeof asset !== 'object'
181
+ || asset.name !== `opencode-${key}.tar.gz`
182
+ || !isSha256(asset.archiveSha256) || !isSha256(asset.executableSha256)) invalid();
183
+ }
184
+ for (const key of allowed) if (!manifest.assets[key]) invalid();
185
+ if (Object.keys(manifest.assets).length !== allowed.size) invalid();
186
+ return manifest;
187
+ }
188
+
189
+ function isReleaseVersion(value) {
190
+ return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
191
+ }
192
+ function isSha256(value) { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); }
193
+
194
+ /** Explicit developer/staging selector: accept release or checked-in development provenance. */
195
+ export async function resolveOwnedOpenCode(directory, options = {}) {
196
+ return resolvePinnedOwnedOpenCode(directory, options, true);
197
+ }
198
+
199
+ // The trust choice is internal, not an option or adjacent-manifest override.
200
+ async function resolvePinnedOwnedOpenCode(directory, options, allowDevelopment, expectedExecutableSha256) {
201
+ const root = abs(directory);
202
+ const manifestPath = path.join(root, 'ctg-runtime.json');
203
+ let manifest;
204
+ try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); }
205
+ catch (error) { throw new Error(`Invalid CTG-owned Carthagent runtime manifest at ${manifestPath}: ${error.message}`); }
206
+ if (!manifest || manifest.owner !== 'Carthagent'
207
+ || manifest.sourceRepository !== 'https://github.com/anomalyco/opencode'
208
+ || manifest.sourceRevision !== CTG_RUNTIME_SOURCE
209
+ || manifest.lockSha256 !== CTG_RUNTIME_LOCK_SHA256
210
+ || (manifest.patchSha256 !== CTG_RUNTIME_PATCH_SHA256
211
+ && !(allowDevelopment && manifest.patchSha256 === CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256))
212
+ || manifest.compatibilityVersion !== OPENCODE_VERSION
213
+ || manifest.runtimeVersion !== CTG_RUNTIME_VERSION
214
+ || manifest.channel !== 'ctg.2.0.32'
215
+ || manifest.webUI !== false) {
216
+ throw new Error(`CTG-owned Carthagent runtime manifest is not compatible with the pinned CTG ${OPENCODE_VERSION} runtime at ${manifestPath}`);
217
+ }
218
+ const platformName = platform() === 'win32' ? 'windows' : platform();
219
+ const supportedPlatform = `${platformName}-${arch()}`;
220
+ if (manifest.platform !== supportedPlatform) {
221
+ throw new Error(`CTG-owned Carthagent runtime platform ${manifest.platform} does not match this host ${supportedPlatform}`);
222
+ }
223
+ const artifactPlatform = manifest.platform;
224
+ const executable = path.join(root, `cli-${artifactPlatform}`, 'bin', platform() === 'win32' ? 'opencode.exe' : 'opencode');
225
+ const checksumFile = path.join(root, 'SHA256SUMS');
226
+ const relativeExecutable = path.relative(root, executable).split(path.sep).join('/');
227
+ let sums;
228
+ try { sums = await readFile(checksumFile, 'utf8'); }
229
+ catch (error) { throw new Error(`CTG-owned Carthagent runtime checksums are missing at ${checksumFile}: ${error.message}`); }
230
+ const expected = sums.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(([hash, name]) => /^[a-f0-9]{64}$/i.test(hash || '') && name?.replace(/^\*?/, '') === relativeExecutable)?.[0];
231
+ if (!expected) throw new Error(`CTG-owned Carthagent runtime checksum does not list ${relativeExecutable}`);
232
+ let bytes;
233
+ try { bytes = await readFile(executable); }
234
+ catch (error) { throw new Error(`CTG-owned Carthagent runtime executable is missing at ${executable}: ${error.message}`); }
235
+ const actual = createHash('sha256').update(bytes).digest('hex');
236
+ if (actual !== expected.toLowerCase()) throw new Error(`CTG-owned Carthagent runtime executable SHA-256 mismatch at ${executable}`);
237
+ if (expectedExecutableSha256 !== undefined && actual !== expectedExecutableSha256) {
238
+ throw new Error(`CTG-owned Carthagent runtime pinned executable SHA-256 mismatch at ${executable}`);
239
+ }
240
+ const result = await runCapture(executable, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl });
241
+ if (result.stdout.trim() !== `opencode v${CTG_RUNTIME_VERSION}`) {
242
+ throw new Error(`CTG-owned Carthagent runtime identity mismatch at ${executable}: ${result.stdout.trim() || 'unknown version'}`);
243
+ }
244
+ return executable;
245
+ }
246
+
247
+ /** Describe the executable identity that a profile's persistent server must match. */
248
+ export async function runtimeIdentity(binary, options = {}) {
249
+ const executable = await realpath(binary.includes(path.sep) || binary.includes('/') ? binary : resolvePathCommand(binary, options.env ?? process.env) ?? binary);
250
+ const digest = createHash('sha256').update(await readFile(executable)).digest('hex');
251
+ const ownedDirectory = options.ownedRuntime ?? process.env.CTG_OWNED_OPENCODE_RUNTIME ?? options[RESOLVED_OWNED_RUNTIME];
252
+ if (!ownedDirectory || options.binary || process.env.CTG_OPENCODE_BIN) {
253
+ return Object.freeze({ kind: 'upstream-compatible', binaryPath: executable, binarySha256: digest, version: OPENCODE_VERSION });
254
+ }
255
+ const manifest = JSON.parse(await readFile(path.join(abs(ownedDirectory), 'ctg-runtime.json'), 'utf8'));
256
+ const artifactPlatform = manifest.platform;
257
+ const ownedPath = await realpath(path.join(abs(ownedDirectory), `cli-${artifactPlatform}`, 'bin', platform() === 'win32' ? 'opencode.exe' : 'opencode'));
258
+ if (ownedPath !== executable) {
259
+ throw new Error(`CTG-owned runtime selector resolved ${executable}, but the selected artifact manifest binds ${ownedPath}`);
260
+ }
261
+ return Object.freeze({
262
+ kind: 'ctg-owned', binaryPath: executable, binarySha256: digest,
263
+ version: manifest.runtimeVersion, sourceRevision: manifest.sourceRevision,
264
+ lockSha256: manifest.lockSha256, patchSha256: manifest.patchSha256,
265
+ platform: manifest.platform, capabilities: { shellOrigin: true },
266
+ });
62
267
  }
63
268
 
64
269
  /** Construct the environment for CTG-owned OpenCode children. */
@@ -68,7 +273,10 @@ export function runtimeEnvironment(options = {}) {
68
273
  // overrides. Workspace child environments are restored through the native
69
274
  // session.environment API, not through this host process environment.
70
275
  const inherited = { ...process.env, ...(options.env ?? {}) };
71
- const env = Object.fromEntries(Object.entries(inherited).filter(([key]) => !key.startsWith('OPENCODE_')));
276
+ for (const key of Object.keys(inherited)) {
277
+ if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete inherited[key];
278
+ }
279
+ const env = Object.fromEntries(Object.entries(inherited).filter(([key]) => !key.startsWith('OPENCODE_') && key !== 'CTG_NATIVE_GLYPHS' && key !== 'CTG_TUI_GLYPHS'));
72
280
  Object.assign(env, {
73
281
  XDG_CONFIG_HOME: paths.config,
74
282
  XDG_DATA_HOME: paths.data,
@@ -82,10 +290,9 @@ export function runtimeEnvironment(options = {}) {
82
290
  OPENCODE_DB: path.join(paths.data, 'opencode.db'),
83
291
  OPENCODE_CONFIG: paths.configFile,
84
292
  OPENCODE_CONFIG_DIR: paths.configDir,
85
- OPENCODE_CONFIG_PROJECT_DISABLE: 'true',
293
+ ...(options.isolate ? { OPENCODE_CONFIG_PROJECT_DISABLE: 'true' } : {}),
86
294
  OPENCODE_DISABLE_MODELS_FETCH: 'true',
87
- OPENCODE_DISABLE_CLAUDE_CODE: 'true',
88
- OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: 'true',
295
+ ...(options.isolate ? { OPENCODE_DISABLE_CLAUDE_CODE: 'true', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: 'true' } : {}),
89
296
  OPENCODE_DISABLE_AUTOUPDATE: 'true',
90
297
  CTG_OPENCODE_HOME: paths.root,
91
298
  });
@@ -105,18 +312,74 @@ export async function provisionRuntime(options = {}) {
105
312
  current = JSON.parse(await readFile(paths.configFile, 'utf8'));
106
313
  if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('expected a JSON object');
107
314
  } catch (error) {
108
- if (error.code !== 'ENOENT') throw new Error(`Cannot preserve CTG OpenCode profile at ${paths.configFile}: ${error.message}`);
315
+ if (error.code !== 'ENOENT') throw new Error(`Cannot preserve CTG Carthagent runtime profile at ${paths.configFile}: ${error.message}`);
109
316
  }
110
317
  const config = { ...current, ...(options.profile ?? {}), $schema: current.$schema ?? 'https://opencode.ai/config.json' };
111
318
  if (options.pluginPath) {
112
319
  const plugin = abs(options.pluginPath);
113
- config.plugins = [...new Set([...(Array.isArray(config.plugins) ? config.plugins : []), plugin])];
320
+ config.plugins = replaceManagedPlugin(config.plugins, plugin);
114
321
  }
115
322
  await atomicWrite(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, 0o600);
116
323
  });
117
324
  return paths;
118
325
  }
119
326
 
327
+ /** Old checkout/install references share one plugin ID, even when paths differ.
328
+ * Recognize the managed layout without requiring obsolete paths to still exist.
329
+ * Leave unrelated plugins (including their options and ordering) untouched. */
330
+ function replaceManagedPlugin(plugins, plugin) {
331
+ const entries = Array.isArray(plugins) ? plugins : [];
332
+ const managed = entry => {
333
+ let reference = typeof entry === 'string' ? entry : entry?.package;
334
+ if (typeof reference !== 'string') return false;
335
+ if (reference.startsWith('file:')) {
336
+ try { reference = fileURLToPath(reference); } catch { return false; }
337
+ }
338
+ if (abs(reference) === plugin) return true;
339
+ return /(?:^|\/)plugins\/carthagent(?:\/(?:src\/)?index\.ts)?$/.test(reference.replaceAll('\\', '/').replace(/\/+$/, ''));
340
+ };
341
+ const previous = entries.filter(managed);
342
+ // Prefer the selected installation's options, otherwise retain the latest
343
+ // managed entry's options when moving between installations.
344
+ const selected = previous.find(entry => typeof entry === 'object' && entry?.package === plugin)
345
+ ?? previous.findLast(entry => entry && typeof entry === 'object')
346
+ ?? previous.at(-1);
347
+ const replacement = selected && typeof selected === 'object' ? { ...selected, package: plugin } : plugin;
348
+ const result = [];
349
+ let replaced = false;
350
+ for (const entry of entries) {
351
+ if (!managed(entry)) result.push(entry);
352
+ else if (!replaced) { result.push(replacement); replaced = true; }
353
+ }
354
+ if (!replaced) result.push(replacement);
355
+ return result;
356
+ }
357
+
358
+ /** Remove only this file's atomicWrite staging entries without reading payloads.
359
+ * The caller must hold the same exclusive lock as every writer to this file;
360
+ * otherwise a staging entry could belong to a live write.
361
+ */
362
+ export async function cleanupAtomicWriteStaging(file) {
363
+ const directoryPath = path.dirname(file), prefix = `${path.basename(file)}.`;
364
+ const suffix = /^[1-9][0-9]*\.[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.tmp$/i;
365
+ // Windows cannot open directories as file handles; lstat still rejects a
366
+ // final-component symlink there. POSIX also persists the directory unlink.
367
+ const directory = process.platform === 'win32' ? undefined
368
+ : await open(directoryPath, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0));
369
+ try {
370
+ if (!(directory ? await directory.stat() : await lstat(directoryPath)).isDirectory()) throw new Error('Invalid atomicWrite staging directory');
371
+ // Stream entries rather than loading an unbounded directory listing.
372
+ let removed = false;
373
+ for await (const entry of await opendir(directoryPath)) {
374
+ if (!entry.name.startsWith(prefix) || !suffix.test(entry.name.slice(prefix.length))
375
+ || (!entry.isFile() && !entry.isSymbolicLink())) continue;
376
+ // unlink does not follow symlinks and never recursively deletes paths.
377
+ await unlink(path.join(directoryPath, entry.name)); removed = true;
378
+ }
379
+ if (removed) await directory?.sync();
380
+ } finally { await directory?.close(); }
381
+ }
382
+
120
383
  export async function atomicWrite(file, contents, mode = 0o600) {
121
384
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
122
385
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
@@ -210,15 +473,27 @@ function lockTimeoutError(file, owner) {
210
473
  }
211
474
 
212
475
  export function processAlive(pid) {
213
- try { process.kill(pid, 0); return true; }
476
+ try { process.kill(pid, 0); }
214
477
  catch (error) { return error.code === 'EPERM'; }
478
+ if (process.platform === 'linux') {
479
+ try {
480
+ // kill(pid, 0) also succeeds for exited children awaiting reaping by PID 1.
481
+ const status = readFileSync(`/proc/${pid}/stat`, 'utf8');
482
+ return !['Z', 'X', 'x'].includes(status.slice(status.lastIndexOf(')') + 2).split(' ')[0]);
483
+ } catch (error) { if (error.code === 'ENOENT') return false; }
484
+ }
485
+ return true;
215
486
  }
216
487
 
217
488
  /** Run a child process with bounded startup and captured diagnostic output. */
218
489
  export function runCapture(command, args, options = {}) {
219
490
  return new Promise((resolve, reject) => {
220
491
  const spawnImpl = options.spawnImpl ?? spawn;
221
- const child = spawnImpl(command, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] });
492
+ const env = { ...(options.env ?? process.env) };
493
+ for (const key of Object.keys(env)) {
494
+ if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete env[key];
495
+ }
496
+ const child = spawnImpl(command, args, { cwd: options.cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
222
497
  let stdout = '';
223
498
  let stderr = '';
224
499
  const cap = options.maxOutput ?? 256 * 1024;
@@ -236,7 +511,11 @@ export function runCapture(command, args, options = {}) {
236
511
 
237
512
  /** Preserve workspace authentication/environment: do not apply CTG host XDG overrides. */
238
513
  export function workspaceEnvironment(workspace, env = process.env) {
239
- return { ...env, CTG_WORKSPACE: abs(workspace) };
514
+ const child = { ...env, CTG_WORKSPACE: abs(workspace) };
515
+ for (const key of Object.keys(child)) {
516
+ if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete child[key];
517
+ }
518
+ return child;
240
519
  }
241
520
 
242
521
  export async function readRuntimeCredentials(options = {}) {
@@ -1,7 +1,9 @@
1
1
  import { readFile, rm } from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { randomBytes } from 'node:crypto';
4
- import { OPENCODE_VERSION, processAlive, provisionRuntime, resolveOpenCode, runtimeEnvironment, runtimePaths, withFileLock, atomicWrite, workspaceEnvironment } from './runtime.mjs';
4
+ import { processAlive, provisionRuntime, resolveOpenCode, runtimeEnvironment, runtimeIdentity, runtimePaths, withFileLock, atomicWrite, workspaceEnvironment } from './runtime.mjs';
5
+ import { withoutRuntimeGithubToken } from '../environment.mjs';
6
+ import { packageVersion } from '../update.mjs';
5
7
 
6
8
  function authHeaders(info) {
7
9
  if (!info.password) return {};
@@ -10,7 +12,7 @@ function authHeaders(info) {
10
12
 
11
13
  async function registration(file) {
12
14
  try { return JSON.parse(await readFile(file, 'utf8')); }
13
- catch (error) { if (error.code === 'ENOENT') return undefined; throw new Error(`Cannot read isolated OpenCode service registration: ${error.message}`); }
15
+ catch (error) { if (error.code === 'ENOENT') return undefined; throw new Error(`Cannot read isolated Carthagent runtime service registration: ${error.message}`); }
14
16
  }
15
17
 
16
18
  async function inspect(info, timeout = 1000) {
@@ -19,7 +21,9 @@ async function inspect(info, timeout = 1000) {
19
21
  if (!response.ok) return undefined;
20
22
  const payload = await response.json();
21
23
  const body = payload.data ?? payload;
22
- if (body.version !== OPENCODE_VERSION || (info.pid && body.pid !== info.pid)) return undefined;
24
+ if (body.version !== (info.runtimeIdentity?.version ?? info.version) || (info.pid && body.pid !== info.pid)) return undefined;
25
+ if (info.runtimeIdentity?.capabilities?.shellOrigin === true
26
+ && body.capabilities?.shellOrigin !== info.runtimeIdentity.capabilities.shellOrigin) return undefined;
23
27
  return { version: body.version, pid: body.pid, server: body, url: info.url };
24
28
  } catch { return undefined; }
25
29
  }
@@ -28,22 +32,46 @@ async function inspect(info, timeout = 1000) {
28
32
  export async function ensureService(options = {}) {
29
33
  const paths = await provisionRuntime(options);
30
34
  const binary = await resolveOpenCode(options);
35
+ const identity = await runtimeIdentity(binary, options);
31
36
  const env = runtimeEnvironment(options);
32
37
  return withFileLock(paths.lifecycleLock, async () => {
38
+ // Serialize profile provisioning with lifecycle startup so concurrent
39
+ // launchers cannot race while installing the same theme files.
40
+ const { provisionDefaultNativeTheme } = await import('../../plugins/carthagent/src/ui/theme-provision.mjs');
41
+ await provisionDefaultNativeTheme(paths.configDir);
33
42
  const info = await registration(paths.serviceFile);
34
43
  if (info) {
44
+ const sameIdentity = JSON.stringify(info.runtimeIdentity) === JSON.stringify(identity);
35
45
  const healthy = await inspect(info, options.healthTimeout ?? 1500);
36
- if (healthy) return Object.freeze({ ...healthy, info, headers: authHeaders(info), paths, binary });
37
- if (processAlive(info.pid)) {
38
- throw new Error(`CTG OpenCode service process ${info.pid} is alive but unhealthy at ${info.url}; registration was retained to avoid orphaning or duplicating it.`);
46
+ const sameLauncher = info.launcherVersion === packageVersion();
47
+ const ownedUpgrade = healthy && info.runtimeIdentity?.kind === 'ctg-owned' && identity.kind === 'ctg-owned';
48
+ if (healthy && sameIdentity && (sameLauncher || !ownedUpgrade)) return Object.freeze({ ...healthy, info, headers: authHeaders(info), paths, binary, runtimeIdentity: identity });
49
+ if (ownedUpgrade) {
50
+ // The lifecycle lock stays held through shutdown and replacement.
51
+ await stopRegisteredService(paths, info, options);
52
+ } else if (processAlive(info.pid)) {
53
+ if (!sameIdentity) {
54
+ throw serviceIdentityMismatch(info, identity, paths);
55
+ }
56
+ throw new Error(`CTG Carthagent runtime service process ${info.pid} is alive but unhealthy at ${info.url}; registration was retained to avoid orphaning or duplicating it.`);
39
57
  }
40
58
  const current = await registration(paths.serviceFile);
41
59
  if (current?.id === info.id && current?.pid === info.pid) await rm(paths.serviceFile, { force: true });
42
60
  }
43
- return startService({ paths, binary, env, options });
61
+ return startService({ paths, binary, env, options, identity });
44
62
  }, { timeout: options.lockTimeout ?? 20000 });
45
63
  }
46
64
 
65
+ function serviceIdentityMismatch(info, expected, paths) {
66
+ return new Error(
67
+ `CTG Carthagent runtime service profile is already running runtime ${info.runtimeIdentity?.version ?? 'with unknown identity'} `
68
+ + `(${info.runtimeIdentity?.kind ?? 'legacy'}). This invocation selected ${expected.version} (${expected.kind}). `
69
+ + `The active service was left running; no process was stopped or replaced. `
70
+ + `After finishing active sessions, run ctg native service stop with CTG_OPENCODE_HOME=${JSON.stringify(paths.root)}, `
71
+ + `then relaunch. Sessions and credentials are preserved. Alternatively, choose a separate CTG_OPENCODE_HOME profile.`,
72
+ );
73
+ }
74
+
47
75
  /** Stop only the process registered by this CTG profile; never signal a shared service. */
48
76
  export async function stopService(options = {}) {
49
77
  const paths = runtimePaths(options);
@@ -57,25 +85,31 @@ export async function stopService(options = {}) {
57
85
  if (current?.id === info.id && current?.pid === info.pid) await rm(paths.serviceFile, { force: true });
58
86
  return false;
59
87
  }
60
- throw new Error(`Refusing to stop CTG OpenCode PID ${info.pid}: the registered process is alive but its service identity cannot be verified. Registration was retained.`);
61
- }
62
- const current = await registration(paths.serviceFile);
63
- if (current?.id !== info.id || current?.pid !== info.pid || current?.url !== info.url) return false;
64
- try { process.kill(info.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
65
- const deadline = Date.now() + (options.stopTimeout ?? 5000);
66
- while (Date.now() < deadline) {
67
- if (!processAlive(info.pid)) {
68
- const latest = await registration(paths.serviceFile);
69
- if (latest?.id === info.id && latest?.pid === info.pid) await rm(paths.serviceFile, { force: true });
70
- return true;
71
- }
72
- await new Promise((resolve) => setTimeout(resolve, 50));
88
+ throw new Error(`Refusing to stop CTG Carthagent runtime PID ${info.pid}: the registered process is alive but its service identity cannot be verified. Registration was retained.`);
73
89
  }
74
- throw new Error(`CTG OpenCode service process ${info.pid} did not stop within ${options.stopTimeout ?? 5000}ms`);
90
+ return stopRegisteredService(paths, info, options);
75
91
  }, { timeout: options.lockTimeout ?? 20000 });
76
92
  }
77
93
 
78
- async function startService({ paths, binary, env: baseEnv, options }) {
94
+ async function stopRegisteredService(paths, info, options) {
95
+ const current = await registration(paths.serviceFile);
96
+ if (current?.id !== info.id || current?.pid !== info.pid || current?.url !== info.url) {
97
+ throw new Error('CTG runtime service registration changed before shutdown; no process was stopped.');
98
+ }
99
+ try { process.kill(info.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
100
+ const deadline = Date.now() + (options.stopTimeout ?? 5000);
101
+ while (Date.now() < deadline) {
102
+ if (!processAlive(info.pid)) {
103
+ const latest = await registration(paths.serviceFile);
104
+ if (latest?.id === info.id && latest?.pid === info.pid) await rm(paths.serviceFile, { force: true });
105
+ return true;
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, 50));
108
+ }
109
+ throw new Error(`CTG Carthagent runtime service process ${info.pid} did not stop within ${options.stopTimeout ?? 5000}ms`);
110
+ }
111
+
112
+ async function startService({ paths, binary, env: baseEnv, options, identity }) {
79
113
  const deadline = Date.now() + (options.startupTimeout ?? 20000);
80
114
  const password = randomBytes(32).toString('base64url');
81
115
  const env = { ...baseEnv, OPENCODE_SERVER_PASSWORD: password };
@@ -99,10 +133,10 @@ async function startService({ paths, binary, env: baseEnv, options }) {
99
133
  child.stderr.on('data', collect);
100
134
  child.once('error', (error) => { startupFailure = error; if (!settled) { settled = true; clearTimeout(startupTimer); reject(error); } });
101
135
  child.once('exit', (code, signal) => {
102
- if (!settled) { settled = true; clearTimeout(startupTimer); reject(new Error(`OpenCode exited ${code ?? signal} before listening: ${output.replaceAll(password, '[redacted]')}`)); }
136
+ if (!settled) { settled = true; clearTimeout(startupTimer); reject(new Error(`Carthagent runtime exited ${code ?? signal} before listening: ${output.replaceAll(password, '[redacted]')}`)); }
103
137
  });
104
138
  startupTimer = setTimeout(() => {
105
- if (!settled) { settled = true; reject(new Error(`OpenCode startup timed out after ${options.startupTimeout ?? 20000}ms`)); }
139
+ if (!settled) { settled = true; reject(new Error(`Carthagent runtime startup timed out after ${options.startupTimeout ?? 20000}ms`)); }
106
140
  }, Math.max(1, deadline - Date.now()));
107
141
  child.unref();
108
142
  });
@@ -112,17 +146,18 @@ async function startService({ paths, binary, env: baseEnv, options }) {
112
146
  id: `ctg-${child.pid}-${randomBytes(8).toString('hex')}`,
113
147
  pid: child.pid,
114
148
  state: 'starting',
115
- version: OPENCODE_VERSION,
149
+ version: identity.version,
150
+ runtimeIdentity: identity,
116
151
  })}\n`);
117
152
  }
118
153
  const url = await started;
119
154
  child.stdout.unref?.();
120
155
  child.stderr.unref?.();
121
- const info = { id: `ctg-${child.pid}-${randomBytes(8).toString('hex')}`, pid: child.pid, url, password, version: OPENCODE_VERSION };
156
+ const info = { id: `ctg-${child.pid}-${randomBytes(8).toString('hex')}`, pid: child.pid, url, password, version: identity.version, runtimeIdentity: identity, launcherVersion: packageVersion() };
122
157
  await atomicWrite(paths.serviceFile, `${JSON.stringify(info)}\n`);
123
158
  const healthy = await inspect(info, Math.max(250, deadline - Date.now()));
124
- if (!healthy) throw new Error('OpenCode listener started but authenticated service identity did not validate');
125
- return Object.freeze({ ...healthy, info, headers: authHeaders(info), paths, binary });
159
+ if (!healthy) throw new Error('Carthagent runtime listener started but authenticated service identity did not validate');
160
+ return Object.freeze({ ...healthy, info, headers: authHeaders(info), paths, binary, runtimeIdentity: identity });
126
161
  } catch (error) {
127
162
  let stopped = !child?.pid || !processAlive(child.pid);
128
163
  if (child?.pid && processAlive(child.pid)) {
@@ -137,9 +172,9 @@ async function startService({ paths, binary, env: baseEnv, options }) {
137
172
  const registered = await registration(paths.serviceFile).catch(() => undefined);
138
173
  if (stopped && registered?.pid === child?.pid) await rm(paths.serviceFile, { force: true });
139
174
  if (!stopped) {
140
- throw new Error(`CTG OpenCode startup failed and process ${child.pid} could not be stopped; ownership registration was retained at ${paths.serviceFile}.`, { cause: error });
175
+ throw new Error(`CTG Carthagent runtime startup failed and process ${child.pid} could not be stopped; ownership registration was retained at ${paths.serviceFile}.`, { cause: error });
141
176
  }
142
- throw new Error(`Unable to start CTG OpenCode service: ${startupFailure?.message ?? error.message}${output ? `\n${output.replaceAll(password, '[redacted]')}` : ''}`, { cause: error });
177
+ throw new Error(`Unable to start CTG Carthagent runtime service: ${startupFailure?.message ?? error.message}${output ? `\n${output.replaceAll(password, '[redacted]')}` : ''}`, { cause: error });
143
178
  }
144
179
  }
145
180
 
@@ -165,7 +200,7 @@ export async function serviceRequest(service, method, endpoint, options = {}) {
165
200
  let data;
166
201
  try { data = text ? JSON.parse(text) : undefined; } catch { data = text; }
167
202
  if (!response.ok) {
168
- const error = new Error(`OpenCode API ${method} ${url.pathname} failed (${response.status}): ${typeof data === 'string' ? data : JSON.stringify(data)}`);
203
+ const error = new Error(`Carthagent runtime API ${method} ${url.pathname} failed (${response.status}): ${typeof data === 'string' ? data : JSON.stringify(data)}`);
169
204
  error.status = response.status;
170
205
  error.body = data;
171
206
  throw error;
@@ -226,7 +261,7 @@ export async function setSessionEnvironment(service, sessionID, env, options = {
226
261
 
227
262
  function normalizeDirectory(location) {
228
263
  const directory = typeof location === 'string' ? location : location?.directory;
229
- if (typeof directory !== 'string' || !directory) throw new TypeError('OpenCode location must include a directory');
264
+ if (typeof directory !== 'string' || !directory) throw new TypeError('Carthagent runtime location must include a directory');
230
265
  return directory;
231
266
  }
232
267
 
@@ -234,13 +269,20 @@ export function launchTui(service, workspace, options = {}) {
234
269
  const args = ['--server', service.url, workspace];
235
270
  if (options.sessionID) args.push('--session', options.sessionID);
236
271
  if (options.continue) args.push('--continue');
272
+ const env = { ...runtimeEnvironment({ root: service.paths.root, env: options.env }) };
273
+ if (typeof options.theme === 'string' && options.theme.trim()) env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({ theme: { name: options.theme.trim(), mode: 'system' } });
274
+ const glyphs = options.glyphs ?? options.env?.CTG_TUI_GLYPHS;
275
+ if (glyphs === 'ascii' || glyphs === 'unicode') env.CTG_TUI_GLYPHS = glyphs;
276
+ for (const key of Object.keys(env)) {
277
+ if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete env[key];
278
+ }
237
279
  const child = (options.spawnImpl ?? spawn)(service.binary, args, {
238
280
  cwd: workspace,
239
- env: {
240
- ...runtimeEnvironment({ root: service.paths.root, env: options.env }),
281
+ env: withoutRuntimeGithubToken({
282
+ ...env,
241
283
  OPENCODE_PASSWORD: service.info?.password ?? service.password,
242
284
  CTG_WORKSPACE: workspaceEnvironment(workspace, options.env ?? process.env).CTG_WORKSPACE,
243
- },
285
+ }),
244
286
  stdio: 'inherit',
245
287
  });
246
288
  return child;
@@ -2,8 +2,8 @@ import { resolve } from 'node:path';
2
2
  import { validateBudgetOptions } from '../budget.mjs';
3
3
 
4
4
  export const SESSION_OPTIONS_KEY = 'carthagent.options';
5
- const booleans = ['delivery', 'guide', 'strict', 'isolate', 'protectExisting'];
6
- const integers = ['maxTools', 'maxSeconds', 'maxRepairs', 'codegraphMaxFiles', 'rewriteCap', 'toolOutputCap'];
5
+ const booleans = ['delivery', 'guide', 'strict', 'isolate', 'protectExisting', 'jev'];
6
+ const integers = ['maxTools', 'maxSeconds', 'maxRepairs', 'codegraphMaxFiles', 'rewriteCap', 'toolOutputCap', 'turnDelayMs'];
7
7
  const paths = ['validators', 'context'];
8
8
 
9
9
  /** Only delivery runtime settings belong here; credentials and UI choices do not. */
@@ -23,6 +23,10 @@ export function normalizeSessionOptions(input = {}, cwd = process.cwd()) {
23
23
  }
24
24
  result[key] = Number(input[key]);
25
25
  }
26
+ if (input.jevProvider !== undefined) {
27
+ if (!['ship', 'openrouter', 'typesafe'].includes(input.jevProvider)) throw new TypeError('jevProvider must be ship, openrouter or typesafe');
28
+ result.jevProvider = input.jevProvider;
29
+ }
26
30
  if (input.bashCap !== undefined) {
27
31
  const cap = Number(input.bashCap);
28
32
  if (!Number.isFinite(cap) || cap <= 0 || !/^\d+(?:\.\d+)?$/.test(String(input.bashCap))) throw new TypeError('bashCap must be a positive number');