carthagent 1.0.7 → 1.1.0-preview.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +336 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  58. package/docs/release-notes-native-draft.md +73 -0
  59. package/docs/release-notes-preview.10.md +121 -0
  60. package/docs/release-notes-preview.11.md +76 -0
  61. package/docs/release-notes-preview.12.md +55 -0
  62. package/docs/release-notes-preview.13.md +68 -0
  63. package/docs/release-notes-preview.9.md +130 -0
  64. package/docs/source-preview.md +120 -0
  65. package/evaluate.mjs +28 -1
  66. package/lib/budget.mjs +1 -1
  67. package/lib/codegraph.mjs +1 -1
  68. package/lib/delivery-review.mjs +11 -1
  69. package/lib/delivery.mjs +3 -2
  70. package/lib/environment.mjs +8 -0
  71. package/lib/guidance.mjs +1 -1
  72. package/lib/opencode/cli-runtime.mjs +1171 -0
  73. package/lib/opencode/cli.mjs +252 -0
  74. package/lib/opencode/credentials.mjs +63 -8
  75. package/lib/opencode/demo.mjs +53 -0
  76. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  77. package/lib/opencode/evaluation.mjs +32 -12
  78. package/lib/opencode/headless.mjs +8 -4
  79. package/lib/opencode/provider-policy.mjs +23 -0
  80. package/lib/opencode/review.mjs +37 -16
  81. package/lib/opencode/runtime-artifact.mjs +335 -0
  82. package/lib/opencode/runtime-github-transport.mjs +121 -0
  83. package/lib/opencode/runtime-release.json +36 -0
  84. package/lib/opencode/runtime.mjs +299 -20
  85. package/lib/opencode/service.mjs +77 -35
  86. package/lib/opencode/session-options.mjs +6 -2
  87. package/lib/opencode/update.mjs +222 -0
  88. package/lib/opencode/workspace.mjs +1 -1
  89. package/lib/planning-advice.mjs +2 -2
  90. package/lib/providers/experiential.mjs +2 -2
  91. package/lib/tui/framing.mjs +1 -80
  92. package/lib/update.mjs +10 -1
  93. package/package.json +33 -7
  94. package/plugins/carthagent/README.md +23 -0
  95. package/plugins/carthagent/src/bootstrap.ts +1 -1
  96. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  97. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  98. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  99. package/plugins/carthagent/src/index.ts +71 -24
  100. package/plugins/carthagent/src/rpc.ts +51 -6
  101. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  102. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  103. package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
  104. package/plugins/carthagent/src/server/delivery.ts +227 -112
  105. package/plugins/carthagent/src/server/framing.mjs +80 -0
  106. package/plugins/carthagent/src/server/policy.ts +1 -1
  107. package/plugins/carthagent/src/server/providers.ts +59 -31
  108. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  109. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  110. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  111. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  112. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  113. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  114. package/plugins/carthagent/src/testing.ts +5 -0
  115. package/plugins/carthagent/src/tui.tsx +496 -0
  116. package/plugins/carthagent/src/ui/client.ts +125 -0
  117. package/plugins/carthagent/src/ui/commands.ts +70 -0
  118. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  119. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  120. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  121. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  122. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  123. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  124. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  125. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  126. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  127. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  128. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  129. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  130. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  131. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  132. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  133. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  134. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  135. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  136. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  137. package/plugins/carthagent/src/ui/voyages.tsx +129 -0
  138. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  139. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  140. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  141. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  142. package/plugins/carthagent/tests/commands.test.ts +107 -0
  143. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  144. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  145. package/plugins/carthagent/themes/carthage.json +67 -0
  146. package/plugins/carthagent/tui.ts +26 -1
  147. package/script/ctg/build-owned-runtime.sh +166 -0
  148. package/tests/budget-engine.test.mjs +4 -3
  149. package/tests/cli.test.mjs +52 -96
  150. package/tests/daily-evidence.test.mjs +62 -0
  151. package/tests/delivery-review.test.mjs +11 -0
  152. package/tests/delivery.test.mjs +22 -1
  153. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  154. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  155. package/tests/installed-runtime-contract.test.mjs +43 -0
  156. package/tests/native-cli-host-smoke.mjs +457 -0
  157. package/tests/native-daily-editor-focus.test.mjs +213 -0
  158. package/tests/native-daily-editor.test.mjs +43 -0
  159. package/tests/native-daily-quests-layout.test.mjs +53 -0
  160. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  161. package/tests/native-daily-quests-ui.test.mjs +778 -0
  162. package/tests/native-daily-source.test.mjs +451 -0
  163. package/tests/native-demo-ui.test.mjs +48 -0
  164. package/tests/native-evaluation-admission.test.mjs +24 -0
  165. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  166. package/tests/native-fixture-runtime.test.mjs +111 -0
  167. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  168. package/tests/native-glyph-pty.test.mjs +107 -0
  169. package/tests/native-manual-projects.test.mjs +170 -0
  170. package/tests/native-model-command.test.mjs +67 -0
  171. package/tests/native-owned-runtime-env.test.mjs +140 -0
  172. package/tests/native-paired-guards.test.mjs +464 -0
  173. package/tests/native-provider-policy.test.mjs +31 -0
  174. package/tests/native-pty-invocation.test.mjs +114 -0
  175. package/tests/native-pty-runtime.test.mjs +45 -0
  176. package/tests/native-resume-session.test.mjs +46 -0
  177. package/tests/native-review-consumer.test.mjs +286 -0
  178. package/tests/native-runtime-identity.test.mjs +214 -0
  179. package/tests/native-select-mouse.test.mjs +23 -0
  180. package/tests/native-ship-parity.test.mjs +526 -0
  181. package/tests/native-ship-plan.test.mjs +85 -0
  182. package/tests/native-ship-rpc.test.mjs +71 -0
  183. package/tests/native-ui-commands.test.mjs +6 -0
  184. package/tests/native-voyages-layout.test.mjs +28 -0
  185. package/tests/native-voyages-navigation.test.mjs +169 -0
  186. package/tests/native-voyages-rpc.test.mjs +395 -0
  187. package/tests/native-voyages-ui.test.mjs +277 -0
  188. package/tests/opencode-atomic-write.test.mjs +55 -0
  189. package/tests/opencode-cli.test.mjs +1312 -0
  190. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  191. package/tests/opencode-delivery.test.mjs +16 -0
  192. package/tests/opencode-providers.test.mjs +245 -18
  193. package/tests/opencode-runtime.test.mjs +546 -8
  194. package/tests/opencode-session-controls.test.mjs +15 -0
  195. package/tests/opencode-ship-rpc.test.mjs +71 -0
  196. package/tests/opencode-update.test.mjs +475 -0
  197. package/tests/opencode-workspace.test.mjs +51 -2
  198. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  199. package/tests/release-workflow.test.mjs +46 -0
  200. package/tests/runtime-artifact.test.mjs +269 -0
  201. package/tests/runtime-development-trust.test.mjs +230 -0
  202. package/tests/runtime-github-transport.test.mjs +132 -0
  203. package/tests/runtime-token-env.test.mjs +23 -0
  204. package/tests/server.test.mjs +3 -1
  205. package/tests/ship-telemetry.test.mjs +1 -1
  206. package/tests/source-preview.test.mjs +253 -0
  207. package/tests/test_native_terminal_probe.py +76 -0
  208. package/tests/test_native_terminal_semantics.py +232 -0
  209. package/tests/update.test.mjs +42 -5
  210. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  211. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  212. package/tests/workspace-live.test.mjs +127 -0
  213. package/tools/benchmark/diagnose-request.mjs +59 -36
  214. package/tools/benchmark/eval-common.mjs +3 -3
  215. package/tools/benchmark/evaluators.test.mjs +4 -4
  216. package/tools/benchmark/guard.test.mjs +35 -2
  217. package/tools/benchmark/guard.ts +20 -7
  218. package/tools/benchmark/planning-startup.test.mjs +30 -34
  219. package/tools/benchmark/runner.mjs +12 -7
  220. package/tools/benchmark/runner.test.mjs +23 -7
  221. package/tools/benchmark/upstream-error.test.mjs +16 -2
  222. package/tools/generate-owned-runtime-release.mjs +217 -0
  223. package/tools/installed-runtime-contract.mjs +16 -0
  224. package/tools/manual-runtime-manifest.mjs +39 -0
  225. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  226. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  227. package/tools/native-fixture.mjs +199 -40
  228. package/tools/native-glyph-helpers.py +127 -0
  229. package/tools/native-glyph-pty.py +144 -0
  230. package/tools/native-manual-projects.mjs +259 -0
  231. package/tools/native-manual.mjs +338 -0
  232. package/tools/native-model-picker-acceptance.py +306 -0
  233. package/tools/native-owned-runtime-env.sh +47 -0
  234. package/tools/native-paired-guards.mjs +282 -0
  235. package/tools/native-paired-manifest.mjs +72 -0
  236. package/tools/native-paired-orchestration.sh +51 -0
  237. package/tools/native-plugin-readiness.mjs +33 -0
  238. package/tools/native-plugin-readiness.test.mjs +53 -0
  239. package/tools/native-pty-invocation.mjs +26 -0
  240. package/tools/native-pty-runtime.mjs +83 -0
  241. package/tools/native-pty-smoke.py +147 -24
  242. package/tools/native-public-repo-shell-result.mjs +13 -0
  243. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  244. package/tools/native-runtime-identity.mjs +201 -0
  245. package/tools/native-slash-acceptance-env.py +38 -0
  246. package/tools/native-terminal-semantics.py +876 -0
  247. package/tools/owned-shell-observer/index.ts +16 -0
  248. package/tools/owned-shell-observer/package.json +1 -0
  249. package/tools/owned-visual-clear-env.py +48 -0
  250. package/tools/owned-visual-clear-model.py +21 -0
  251. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  252. package/tools/prepare-native-manual.mjs +122 -0
  253. package/tools/run-native-terminal-paired.sh +403 -0
  254. package/tools/runtime-token-env.mjs +8 -0
  255. package/tools/seed-native-switch-fixture.mjs +79 -0
  256. package/tools/test-native-controls.mjs +3 -1
  257. package/tools/test-native-daily-quests.mjs +65 -0
  258. package/tools/test-native-default-cli.mjs +106 -0
  259. package/tools/test-native-delivery-host.mjs +102 -20
  260. package/tools/test-native-evaluation.mjs +7 -3
  261. package/tools/test-native-glyph-pty.mjs +94 -0
  262. package/tools/test-native-host.mjs +87 -63
  263. package/tools/test-native-outcomes.mjs +3 -1
  264. package/tools/test-native-package.mjs +206 -38
  265. package/tools/test-native-performance.mjs +26 -0
  266. package/tools/test-native-product.mjs +90 -0
  267. package/tools/test-native-pty.mjs +34 -9
  268. package/tools/test-native-public-repo.mjs +224 -0
  269. package/tools/test-native-refresh-reentry.mjs +106 -0
  270. package/tools/test-native-report.mjs +27 -20
  271. package/tools/test-native-review.mjs +75 -0
  272. package/tools/test-native-runtime.mjs +120 -0
  273. package/tools/test-native-ship-models.mjs +176 -0
  274. package/tools/test-native-ship-walkthrough.mjs +96 -0
  275. package/tools/test-native-slash-commands.mjs +125 -0
  276. package/tools/test-native-slash-commands.py +242 -0
  277. package/tools/test-native-swe-host.mjs +224 -55
  278. package/tools/test-native-tasks.mjs +77 -0
  279. package/tools/test-native-terminal-performance.py +887 -0
  280. package/tools/test-native-terminal-readiness.sh +85 -0
  281. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  282. package/tools/test-native-visual-clear-installed.mjs +48 -0
  283. package/tools/test-owned-host-controls.mjs +238 -0
  284. package/tools/test-owned-opencode-runtime.mjs +35 -0
  285. package/tools/test-owned-visual-clear-env.py +120 -0
  286. package/tools/test-owned-visual-clear.py +373 -0
  287. package/tools/verify-draft-runtime-gate.mjs +138 -0
  288. package/tools/verify-owned-runtime-release.mjs +114 -0
  289. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { OPENCODE_VERSION, CTG_RUNTIME_VERSION, readRuntimeReleaseManifest } from '../lib/opencode/runtime.mjs';
5
+ import { REQUIRED_RUNTIME_TARGETS, validateRuntimeBuildInputs } from './verify-owned-runtime-release.mjs';
6
+
7
+ const root = resolve(import.meta.dirname, '..');
8
+
9
+ export async function readManualRuntimeManifest({ runtimeManifest, manifestSha256: expectedDigest } = {}) {
10
+ if (runtimeManifest && !/^[a-f0-9]{64}$/.test(expectedDigest ?? ''))
11
+ throw Error('An explicit runtime manifest requires its independently supplied SHA-256 digest');
12
+ const manifestPath = runtimeManifest ? resolve(runtimeManifest) : join(root, 'lib/opencode/runtime-release.json');
13
+ const bytes = await readFile(manifestPath);
14
+ const manifestSha256 = createHash('sha256').update(bytes).digest('hex');
15
+ if (expectedDigest && manifestSha256 !== expectedDigest)
16
+ throw Error('Runtime manifest SHA-256 checksum mismatch');
17
+ let manifest;
18
+ if (!runtimeManifest) manifest = await readRuntimeReleaseManifest(manifestPath);
19
+ else {
20
+ manifest = JSON.parse(bytes);
21
+ const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
22
+ if (manifest?.version !== 1 || manifest.releaseVersion !== version
23
+ || manifest.compatibilityVersion !== OPENCODE_VERSION || manifest.runtimeVersion !== CTG_RUNTIME_VERSION
24
+ || !manifest.assets || typeof manifest.assets !== 'object' || Array.isArray(manifest.assets)
25
+ || !Object.keys(manifest.assets).length)
26
+ throw Error('Invalid or incompatible manual candidate manifest');
27
+ // Explicit candidates can contain only platforms actually built, never invented release digests.
28
+ for (const [target, asset] of Object.entries(manifest.assets)) {
29
+ if (!Object.hasOwn(REQUIRED_RUNTIME_TARGETS, target) || asset?.name !== REQUIRED_RUNTIME_TARGETS[target][2]
30
+ || !/^[a-f0-9]{64}$/.test(asset.archiveSha256 ?? '') || !/^[a-f0-9]{64}$/.test(asset.executableSha256 ?? ''))
31
+ throw Error(`Invalid manual candidate asset: ${target}`);
32
+ }
33
+ }
34
+ if (!manifest) throw Error('Runtime release manifest is missing');
35
+ // Published kits keep their immutable release trust root when development
36
+ // advances the recipe. Explicit candidates must still match that recipe.
37
+ if (runtimeManifest) await validateRuntimeBuildInputs(manifest, root);
38
+ return { manifest, manifestPath, manifestSha256 };
39
+ }
@@ -0,0 +1,59 @@
1
+ // Only the randomized fixture URL grants authority to stop a browser tree.
2
+ export const stopFixtureBrowser = String.raw`
3
+ $ErrorActionPreference = 'Stop'
4
+ if ([string]::IsNullOrEmpty($env:CTG_NATIVE_FIXTURE_URL)) { throw 'Missing fixture browser URL' }
5
+ function Same-Identity($a, $b) {
6
+ return $a.ProcessId -eq $b.ProcessId -and $null -ne $a.CreationDate -and $a.CreationDate -eq $b.CreationDate
7
+ }
8
+ function Is-FixtureRoot($p) {
9
+ return $p.Name -ieq 'msedge.exe' -and $p.CommandLine -and $p.CommandLine.Contains($env:CTG_NATIVE_FIXTURE_URL)
10
+ }
11
+ $snapshot = @(Get-CimInstance Win32_Process)
12
+ $roots = @($snapshot | Where-Object { Is-FixtureRoot $_ })
13
+ $captured = @{}
14
+ foreach ($root in $roots) {
15
+ if ($null -eq $root.CreationDate) { throw "Missing creation identity for fixture browser PID $($root.ProcessId)" }
16
+ $captured[$root.ProcessId] = $root
17
+ }
18
+ # Capture every transitive descendant, including those without the URL.
19
+ do {
20
+ $added = $false
21
+ foreach ($p in $snapshot) {
22
+ if (!$captured.ContainsKey($p.ProcessId) -and $captured.ContainsKey($p.ParentProcessId)) {
23
+ $parent = $captured[$p.ParentProcessId]
24
+ if ($null -eq $p.CreationDate) { throw "Missing creation identity for descendant PID $($p.ProcessId)" }
25
+ # An older process cannot be a child of this incarnation of its parent PID.
26
+ if ($p.CreationDate -ge $parent.CreationDate) {
27
+ $captured[$p.ProcessId] = $p
28
+ $added = $true
29
+ }
30
+ }
31
+ }
32
+ } while ($added)
33
+ $diagnostics = @()
34
+ foreach ($root in $roots) {
35
+ $current = @(Get-CimInstance Win32_Process | Where-Object { Same-Identity $_ $root })
36
+ if ($current.Count -eq 0) { continue }
37
+ if (!(Is-FixtureRoot $current[0])) { throw "Fixture browser URL changed for PID $($root.ProcessId)" }
38
+ # Capture native stderr as diagnostics, not a PowerShell terminating error.
39
+ $savedPreference = $ErrorActionPreference
40
+ $ErrorActionPreference = 'Continue'
41
+ $output = @(& taskkill.exe /PID $root.ProcessId /T /F 2>&1)
42
+ $exitCode = $LASTEXITCODE
43
+ $ErrorActionPreference = $savedPreference
44
+ $diagnostics += "PID $($root.ProcessId), taskkill exit $exitCode : $($output -join '; ')"
45
+ }
46
+ # Exit status alone is insufficient: taskkill can report a child error after
47
+ # stopping the root. Conversely, root disappearance does not prove child exit.
48
+ $deadline = [DateTime]::UtcNow.AddSeconds(5)
49
+ do {
50
+ $alive = @(Get-CimInstance Win32_Process | Where-Object {
51
+ $captured.ContainsKey($_.ProcessId) -and (Same-Identity $_ $captured[$_.ProcessId])
52
+ })
53
+ if ($alive.Count -eq 0) { break }
54
+ if ([DateTime]::UtcNow -ge $deadline) {
55
+ throw "Fixture browser identities still alive: $($alive.ProcessId -join ', '); $($diagnostics -join ' | ')"
56
+ }
57
+ Start-Sleep -Milliseconds 100
58
+ } while ($true)
59
+ `;
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ // Never emit free-form log text: even an otherwise harmless line can contain
5
+ // credentials, headers, or a partial secret at the bounded buffer's edge.
6
+ export function sanitizedNativeOutputTail(output) {
7
+ return String(output).slice(-16384).split(/\r?\n/).slice(-20).map(line => ({
8
+ level: line.match(/\b(INFO|WARN|ERROR|DEBUG|TRACE)\b/)?.[1] ?? 'unknown',
9
+ markers: ['shutdown', 'stopping', 'stopped', 'closed', 'exit', 'signal', 'timeout', 'error', 'dispose', 'disposing']
10
+ .filter(marker => new RegExp(`\\b${marker}\\b`, 'i').test(line)),
11
+ }));
12
+ }
13
+
14
+ export async function fixtureStopDiagnostics({ child, output, elapsedMs }, { run = promisify(execFile), platform = process.platform } = {}) {
15
+ // Snapshot before the read-only OS query; the child may exit during it.
16
+ const evidence = {
17
+ elapsedMs: Math.round(elapsedMs),
18
+ child: { pid: child.pid, exitCode: child.exitCode, signalCode: child.signalCode, killed: child.killed },
19
+ outputTail: sanitizedNativeOutputTail(output),
20
+ };
21
+ if (!Number.isSafeInteger(child.pid) || child.pid <= 0) {
22
+ evidence.processState = { status: 'no-child-pid' };
23
+ return evidence;
24
+ }
25
+ const windows = platform === 'win32';
26
+ const command = windows ? 'powershell.exe' : 'ps';
27
+ const args = windows
28
+ ? ['-NoProfile', '-NonInteractive', '-Command', `$p = Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue; if ($null -ne $p) { Write-Output ("pid={0} responding={1}" -f $p.Id, $p.Responding) }`]
29
+ : ['-p', String(child.pid), '-o', 'pid=,ppid=,stat=,etime=,time='];
30
+ try {
31
+ const { stdout } = await run(command, args, { timeout: 2000, maxBuffer: 4096, windowsHide: true });
32
+ const state = stdout.trim();
33
+ // Only numeric process metadata and fixed state codes; never argv, names,
34
+ // environment, stderr, database contents, or OS command error messages.
35
+ const valid = windows
36
+ ? new RegExp(`^pid=${child.pid} responding=(True|False)$`).test(state)
37
+ : new RegExp(`^${child.pid}\\s+\\d+\\s+[A-Za-z<+Ns]+\\s+[\\d:.-]+\\s+[\\d:.]+$`).test(state);
38
+ evidence.processState = { status: !state ? 'not-found' : valid ? 'observed' : 'unrecognized-output', ...(valid ? { state } : {}) };
39
+ } catch (error) {
40
+ evidence.processState = { status: 'query-failed', exitCode: Number.isInteger(error.code) ? error.code : null, timedOut: error.killed === true };
41
+ }
42
+ return evidence;
43
+ }
@@ -1,13 +1,13 @@
1
- /** Offline OpenAI-compatible SSE fixture used by the real OpenCode host test. */
1
+ /** Disposable real native host and loopback provider for acceptance probes. */
2
2
  import { createServer } from 'node:http';
3
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
3
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { once } from 'node:events';
8
- import { randomBytes } from 'node:crypto';
9
- import { OpenCode } from '@opencode/client';
10
- import { resolveOpenCode } from '../lib/opencode/runtime.mjs';
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { assertCompletePluginSource, assertPluginCondition, resolveFixtureSourceRoot, verifySnapshotIdentity } from './native-paired-guards.mjs';
10
+ import { loadNativeTestRuntime, NATIVE_TEST_COMPATIBILITY_VERSION } from './native-runtime-identity.mjs';
11
11
 
12
12
  const root = resolve(import.meta.dirname, '..');
13
13
 
@@ -59,16 +59,104 @@ export function assistantText(response, text) {
59
59
  })}\n\n`);
60
60
  }
61
61
 
62
- /** Disposable native OpenCode host and loopback provider for acceptance probes. */
63
- export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'), plugins: additionalPlugins = [], extraPlugins = [], respond = (_body, res) => streamText(res), env: extraEnv = {} } = {}) {
64
- const pluginPaths = [plugin, ...additionalPlugins, ...extraPlugins].filter(Boolean).map(path => resolve(path));
62
+ export function streamText(res, text = 'Fixture response complete.') {
63
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
64
+ const chunk = (delta, finish_reason = null) => `data: ${JSON.stringify({
65
+ id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'fixture',
66
+ choices: [{ index: 0, delta, finish_reason }],
67
+ ...(finish_reason ? { usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } } : {}),
68
+ })}\n\n`;
69
+ res.write(chunk({ role: 'assistant', content: text }));
70
+ res.end(`${chunk({}, 'stop')}data: [DONE]\n\n`);
71
+ }
72
+
73
+ export async function resolveFixtureRuntime({ productRuntime, options = {}, env = process.env, packageRoot = root, testRuntimeLoader = loadNativeTestRuntime } = {}) {
74
+ const ownedRoot = env.CTG_OWNED_OPENCODE_RUNTIME;
75
+ const trustedManifestPath = env.CTG_TEST_RUNTIME_RELEASE_MANIFEST ?? join(packageRoot, 'lib/opencode/runtime-release.json');
76
+
77
+ // A developer-selected staging directory is checked by bytes and trusted
78
+ // release metadata before the product resolver can execute --version.
79
+ if (ownedRoot) {
80
+ const expectedManifest = JSON.parse(await readFile(trustedManifestPath, 'utf8'));
81
+ const executableName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
82
+ const explicitBinary = env.CTG_OPENCODE_BIN ?? join(resolve(ownedRoot), `cli-${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`, 'bin', executableName);
83
+ await testRuntimeLoader({ binary: explicitBinary, ownedRoot, expectedManifest, env });
84
+ }
85
+
86
+ if (typeof productRuntime?.resolveOpenCode === 'function' && typeof productRuntime?.runtimeIdentity === 'function') {
87
+ const binary = await productRuntime.resolveOpenCode(options);
88
+ const selected = await productRuntime.runtimeIdentity(binary, options);
89
+ const selectedRoot = ownedRoot ?? inferOwnedRoot(binary);
90
+ if (selected.kind === 'ctg-owned' && !selectedRoot) {
91
+ throw new Error('Product selected an owned runtime without exposing its owned artifact root to the test identity verifier');
92
+ }
93
+ if (selected.kind === 'upstream-compatible' && (selected.version !== NATIVE_TEST_COMPATIBILITY_VERSION || env.CTG_TEST_RUNTIME_SHA256)) {
94
+ throw new Error('Upstream-compatible native test runtime must be exactly 2.0.32 and cannot use CTG_TEST_RUNTIME_SHA256');
95
+ }
96
+ const expectedManifest = selectedRoot ? JSON.parse(await readFile(trustedManifestPath, 'utf8')) : undefined;
97
+ const verified = await testRuntimeLoader({ binary, ownedRoot: selectedRoot, expectedManifest, env });
98
+ const productIdentity = {
99
+ binary: selected.binaryPath,
100
+ binarySha256: selected.binarySha256,
101
+ runtimeVersion: selected.version,
102
+ sourceRevision: selected.sourceRevision,
103
+ patchSha256: selected.patchSha256,
104
+ lockSha256: selected.lockSha256,
105
+ };
106
+ for (const [key, actual] of Object.entries(productIdentity)) {
107
+ const expected = key === 'sourceRevision' ? verified.sourceRevision
108
+ : key === 'patchSha256' ? verified.patchSha256
109
+ : key === 'lockSha256' ? verified.lockSha256 : verified[key];
110
+ if (expected !== undefined && actual !== expected) {
111
+ throw new Error(`Product runtime identity ${key} disagrees with trusted native-test identity: ${actual} != ${expected}`);
112
+ }
113
+ }
114
+ return { binary, runtimeIdentity: Object.freeze({
115
+ ...verified,
116
+ kind: selected.kind,
117
+ compatibilityVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
118
+ runtimeVersion: selected.version,
119
+ binarySha256: verified.binarySha256 ?? selected.binarySha256 ?? createHash('sha256').update(await readFile(binary)).digest('hex'),
120
+ ...(selected.capabilities ? { capabilities: selected.capabilities } : {}),
121
+ }) };
122
+ }
123
+
124
+ const binary = env.CTG_OPENCODE_BIN ?? (ownedRoot
125
+ ? join(resolve(ownedRoot), `cli-${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode')
126
+ : join(packageRoot, 'node_modules/@opencode/cli/bin/opencode.exe'));
127
+ const expectedManifest = ownedRoot ? JSON.parse(await readFile(trustedManifestPath, 'utf8')) : undefined;
128
+ const identity = await testRuntimeLoader({ binary, ownedRoot, expectedManifest, env });
129
+ return { binary: identity.binary, runtimeIdentity: Object.freeze({
130
+ ...identity,
131
+ compatibilityVersion: identity.compatibilityVersion,
132
+ runtimeVersion: identity.runtimeVersion,
133
+ kind: ownedRoot ? 'ctg-owned' : 'upstream-compatible',
134
+ }) };
135
+ }
136
+
137
+ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'), pluginOptions, plugins: additionalPlugins = [], pluginExpected = plugin ? 'active' : 'absent', verifyInventory = false, provenance, respond = (_body, res) => streamText(res), env: extraEnv = {}, preserveTemp = false } = {}) {
138
+ const { OpenCode } = await import('@opencode/client');
139
+ const pluginEntries = [
140
+ ...(plugin ? [pluginOptions ? { package: resolve(plugin), options: pluginOptions } : resolve(plugin)] : []),
141
+ ...additionalPlugins.map(entry => typeof entry === 'string' ? resolve(entry) : { ...entry, package: resolve(entry.package) }),
142
+ ];
143
+ const productRuntime = await import('../lib/opencode/runtime.mjs');
144
+ const { binary: runtimeBinary, runtimeIdentity } = await resolveFixtureRuntime({ productRuntime });
65
145
  const temp = await mkdtemp(join(tmpdir(), 'ctg-native-fixture-'));
66
146
  const cwd = join(temp, 'project');
147
+ const pluginProbeCwd = verifyInventory ? join(temp, 'project-alpha') : cwd;
67
148
  const config = join(temp, 'config/opencode');
68
149
  const requests = [];
69
150
  const providerErrors = [];
151
+ const hostErrors = [];
70
152
  let child;
153
+ let childDetached = false;
71
154
  let client;
155
+ let baseUrl;
156
+ let password;
157
+ let pluginObservation;
158
+ let pluginInventory;
159
+ let pluginHealth;
72
160
  let output = '';
73
161
  const provider = createServer((req, res) => {
74
162
  void (async () => {
@@ -83,19 +171,27 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
83
171
  res.end('Fixture provider failed');
84
172
  });
85
173
  });
86
- const password = randomBytes(24).toString('hex');
174
+ password = randomBytes(24).toString('hex');
87
175
  const stop = async () => {
88
176
  if (!child || child.exitCode !== null || child.signalCode !== null) return;
89
177
  const exited = once(child, 'exit');
90
- child.kill('SIGTERM');
91
- const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
178
+ try {
179
+ if (childDetached) process.kill(-child.pid, 'SIGTERM');
180
+ else child.kill('SIGTERM');
181
+ } catch { child.kill('SIGTERM'); }
182
+ const timer = setTimeout(() => {
183
+ try {
184
+ if (childDetached) process.kill(-child.pid, 'SIGKILL');
185
+ else child.kill('SIGKILL');
186
+ } catch { child.kill('SIGKILL'); }
187
+ }, 5000);
92
188
  try { await exited; } finally { clearTimeout(timer); }
93
189
  };
94
- const close = async () => {
190
+ const close = async ({ preserveTemp: keepTemp = preserveTemp } = {}) => {
95
191
  await stop();
96
192
  provider.closeAllConnections();
97
193
  if (provider.listening) await new Promise(done => provider.close(done));
98
- await rm(temp, { recursive: true, force: true });
194
+ if (!keepTemp) await rm(temp, { recursive: true, force: true });
99
195
  };
100
196
  const request = (milliseconds = 30_000) => ({ signal: AbortSignal.timeout(milliseconds) });
101
197
  const start = async () => {
@@ -103,16 +199,26 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
103
199
  output = '';
104
200
  const env = Object.fromEntries(['PATH', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'LANG', 'LC_ALL']
105
201
  .filter(key => process.env[key]).map(key => [key, process.env[key]]));
202
+ if (process.env.NODE_OPTIONS) env.NODE_OPTIONS = process.env.NODE_OPTIONS;
106
203
  Object.assign(env, {
107
- HOME: join(temp, 'home'), USERPROFILE: join(temp, 'home'), APPDATA: join(temp, 'appdata'), LOCALAPPDATA: join(temp, 'localappdata'),
204
+ ...extraEnv,
205
+ HOME: join(temp, 'home'), USERPROFILE: join(temp, 'home'),
206
+ CARTHAGENT_CODING_AGENT_DIR: join(temp, 'home/.carthagent/agent'),
207
+ PI_CODING_AGENT_DIR: join(temp, 'home/.carthagent/agent'),
208
+ APPDATA: join(temp, 'appdata'), LOCALAPPDATA: join(temp, 'localappdata'),
108
209
  XDG_CONFIG_HOME: join(temp, 'config'), XDG_DATA_HOME: join(temp, 'data'),
109
210
  XDG_CACHE_HOME: join(temp, 'cache'), XDG_STATE_HOME: join(temp, 'state'),
110
211
  OPENCODE_DB: join(temp, 'database.sqlite'), OPENCODE_SERVER_PASSWORD: password,
111
- ...extraEnv,
212
+ OPENCODE_CONFIG_PROJECT_DISABLE: 'true',
112
213
  });
113
- child = spawn(await resolveOpenCode(),
114
- ['serve', '--hostname', '127.0.0.1', '--port', '0', '--print-logs', '--log-level', 'debug'], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
115
- const baseUrl = await new Promise((done, reject) => {
214
+ if (verifyInventory) {
215
+ env.NODE_PATH = join(root, 'node_modules');
216
+ }
217
+ childDetached = !verifyInventory;
218
+ child = spawn(runtimeBinary, ['serve', '--hostname', '127.0.0.1', '--port', '0',
219
+ ...(extraEnv.CTG_NATIVE_DEBUG === '1' ? ['--print-logs', '--log-level', 'debug'] : [])],
220
+ { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: childDetached });
221
+ baseUrl = await new Promise((done, reject) => {
116
222
  const timer = setTimeout(() => reject(Error(`Host startup timeout: ${output}`)), 30_000);
117
223
  const collect = chunk => {
118
224
  output = (output + chunk).slice(-16384);
@@ -122,20 +228,77 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
122
228
  child.stdout.on('data', collect);
123
229
  child.stderr.on('data', collect);
124
230
  child.once('error', error => { clearTimeout(timer); reject(error); });
125
- child.once('exit', code => { clearTimeout(timer); reject(Error(`Host exited ${code}: ${output}`)); });
231
+ child.once('exit', (code, signal) => {
232
+ if (code !== 0) hostErrors.push({ code, signal, output });
233
+ clearTimeout(timer);
234
+ reject(Error(`Host exited ${code ?? signal}: ${output}`));
235
+ });
126
236
  });
127
237
  client = OpenCode.make({ baseUrl, headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` } });
128
238
  const version = (await client.server.info(request())).version;
129
- if (version !== '2.0.32') throw Error(`Expected pinned host 2.0.32, got ${version}`);
239
+ if (version !== runtimeIdentity.runtimeVersion) {
240
+ throw Error(`Native fixture runtime identity mismatch: expected ${runtimeIdentity.runtimeVersion}, got ${version}`);
241
+ }
242
+ const location = { directory: pluginProbeCwd };
243
+ if (verifyInventory) {
244
+ await client.session.create({ location, model: { providerID: 'ctg-fixture', id: 'fixture' }, title: 'native plugin activation probe' }, request());
245
+ if (pluginExpected === 'active') {
246
+ assertCompletePluginSource(root);
247
+ const { assertWorkspacePluginResolution } = await import('./native-paired-guards.mjs');
248
+ assertWorkspacePluginResolution(root);
249
+ }
250
+ const deadline = Date.now() + 15_000;
251
+ let inventory;
252
+ while (Date.now() < deadline) {
253
+ inventory = await client.plugin.list({ location }, request());
254
+ pluginInventory = inventory.data;
255
+ try {
256
+ pluginObservation = assertPluginCondition(inventory.data, pluginExpected === 'active');
257
+ break;
258
+ } catch (error) {
259
+ if (inventory.data.some(item => (item?.id === 'carthagent' ||
260
+ (typeof item?.id === 'string' && item.id.startsWith('carthagent.'))) && item.state?.status === 'error') ||
261
+ pluginExpected === 'absent' || Date.now() + 100 >= deadline) throw error;
262
+ await new Promise(resolve => setTimeout(resolve, 100));
263
+ }
264
+ }
265
+ if (!pluginObservation) throw new Error('Timed out waiting for native plugin inventory condition');
266
+ if (pluginExpected === 'active') {
267
+ const probe = await client.rpc.call({ rpcID: 'carthagent', method: 'health', input: {}, location }, request());
268
+ if (probe.output?.hostVersion !== runtimeIdentity.runtimeVersion || probe.output?.protocol !== 1) {
269
+ throw Error(`Native Carthagent health probe failed: ${JSON.stringify(probe.output)}`);
270
+ }
271
+ pluginHealth = { protocol: probe.output.protocol, hostVersion: probe.output.hostVersion, directoryMatches: probe.output.directory === pluginProbeCwd };
272
+ if (!pluginHealth.directoryMatches) throw Error('Native Carthagent health RPC reported an unexpected fixture directory');
273
+ }
274
+ if (provenance) {
275
+ const { assertDependencyPackageIdentity, hashDependencyInstall, hashDependencyPackages } = await import('./native-paired-guards.mjs');
276
+ verifySnapshotIdentity(provenance, provenance, { verifyArtifactFiles: false });
277
+ const binary = runtimeIdentity.binary;
278
+ if (provenance.binarySha256 !== createHash('sha256').update(await readFile(binary)).digest('hex')) {
279
+ throw new Error('Native fixture binary hash does not match selected executable');
280
+ }
281
+ // Baseline fixtures intentionally pass plugin=null; resolve dependency
282
+ // provenance from this immutable execution snapshot in that condition.
283
+ const sourceRoot = resolveFixtureSourceRoot(plugin, root);
284
+ const dependencyRoot = provenance.dependencyNodeModulesRealpath;
285
+ const dependencies = hashDependencyPackages(dependencyRoot, join(sourceRoot, 'package.json'), sourceRoot);
286
+ if (JSON.stringify(dependencies) !== JSON.stringify(provenance.dependencyPackages)) throw new Error('Native fixture dependency package identity mismatch');
287
+ const installIdentity = hashDependencyInstall(dependencyRoot);
288
+ assertDependencyPackageIdentity({ realpath: provenance.dependencyInstallRealpath, treeSha256: provenance.dependencyInstallSha256 }, installIdentity);
289
+ }
290
+ }
130
291
  return client;
131
292
  };
132
293
  try {
133
- await Promise.all(['project', 'home', 'config/opencode', 'data', 'state', 'cache', 'appdata', 'localappdata']
294
+ await Promise.all(['project', 'project-alpha', 'home', 'config/opencode', 'data', 'state', 'cache', 'appdata', 'localappdata']
134
295
  .map(directory => mkdir(join(temp, directory), { recursive: true })));
135
296
  provider.listen(0, '127.0.0.1');
136
297
  await once(provider, 'listening');
137
298
  await writeFile(join(config, 'opencode.json'), JSON.stringify({
138
- ...(pluginPaths.length ? { plugins: pluginPaths } : {}), update: 'disable', model: 'ctg-fixture/fixture', default_agent: 'build',
299
+ plugins: pluginEntries,
300
+ update: 'disable', model: 'ctg-fixture/fixture',
301
+ default_agent: 'build',
139
302
  providers: { 'ctg-fixture': {
140
303
  package: '@opencode/ai/providers/openai-compatible',
141
304
  settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: 'offline-fixture' },
@@ -144,11 +307,13 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
144
307
  }));
145
308
  await start();
146
309
  } catch (error) {
147
- await close();
310
+ await close({ preserveTemp: false });
148
311
  throw error;
149
312
  }
150
313
  return {
151
- temp, cwd, requests, providerErrors, request, start, stop, close,
314
+ temp, cwd, baseUrl, password, requests, providerErrors, hostErrors, request, start, stop, close,
315
+ serverPid: () => child?.pid ?? null,
316
+ pluginObservation, pluginInventory, pluginHealth, provenance, runtimeIdentity,
152
317
  model: { providerID: 'ctg-fixture', id: 'fixture' },
153
318
  location: { directory: cwd },
154
319
  get client() { return client; },
@@ -156,6 +321,7 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
156
321
  async outcome(sessionID) {
157
322
  await client.session.wait({ sessionID }, request());
158
323
  const deadline = Date.now() + 5000;
324
+ // wait resolves before the durable session projection always catches up.
159
325
  while (true) {
160
326
  const session = await client.session.get({ sessionID }, request());
161
327
  if (session.outcome) return session.outcome;
@@ -163,18 +329,16 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
163
329
  await new Promise(done => setTimeout(done, 20));
164
330
  }
165
331
  },
166
- async active(pluginID = 'carthagent', location = { directory: cwd }) {
167
- if (pluginID && typeof pluginID === 'object') {
168
- location = pluginID;
169
- pluginID = 'carthagent';
170
- }
171
- await client.session.create({ location, model:{providerID:'ctg-fixture',id:'fixture'}, title:`${pluginID} activation probe` }, request());
332
+ async active(pluginOrLocation = { directory: cwd }, at = { directory: cwd }) {
333
+ const pluginID = typeof pluginOrLocation === 'string' ? pluginOrLocation : 'carthagent';
334
+ const location = typeof pluginOrLocation === 'string' ? at : pluginOrLocation;
335
+ if (pluginID !== 'carthagent') await client.session.create({ location, model:{providerID:'ctg-fixture',id:'fixture'}, title:`${pluginID} activation probe` }, request());
172
336
  const deadline = Date.now() + 15_000;
173
337
  while (true) {
174
338
  const inventory = await client.plugin.list({ location }, request());
175
339
  const plugin = inventory.data.find(item => item.id === pluginID);
176
340
  if (plugin?.state.status === 'active') return;
177
- if (plugin?.state.status === 'failed') throw Error(`Native fixture plugin failed: ${JSON.stringify(plugin)}\n${output}`);
341
+ if (plugin?.state.status === 'error') throw Error(`Native fixture plugin failed: ${JSON.stringify(plugin)}\n${output}`);
178
342
  if (Date.now() >= deadline) throw Error(`Native plugin ${pluginID} activation timed out: ${JSON.stringify(plugin)}\n${output}`);
179
343
  await new Promise(done => setTimeout(done, 50));
180
344
  }
@@ -182,13 +346,8 @@ export async function nativeFixture({ plugin = join(root, 'plugins/carthagent'),
182
346
  };
183
347
  }
184
348
 
185
- export function streamText(response, text = 'Fixture response complete.') {
186
- response.writeHead(200, { 'content-type': 'text/event-stream' });
187
- const chunk = (delta, finish_reason = null) => `data: ${JSON.stringify({
188
- id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'fixture',
189
- choices: [{ index: 0, delta, finish_reason }],
190
- ...(finish_reason ? { usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } } : {}),
191
- })}\n\n`;
192
- response.write(chunk({ role: 'assistant', content: text }));
193
- response.end(`${chunk({}, 'stop')}data: [DONE]\n\n`);
349
+ function inferOwnedRoot(binary) {
350
+ const resolved = resolve(binary);
351
+ const match = resolved.match(/^(.*)[\\/]cli-[a-z]+-[a-z0-9]+[\\/]bin[\\/]opencode(?:\.exe)?$/i);
352
+ return match?.[1];
194
353
  }
@@ -0,0 +1,127 @@
1
+ """Portable glyph PTY screen and assertion helpers; no OS PTY dependencies."""
2
+ import codecs
3
+ import os
4
+ import re
5
+ import unicodedata
6
+ from pathlib import Path
7
+
8
+
9
+ USER_MARKER = 'CTG_GLYPH_PAYLOAD'
10
+ USER_TEXT = USER_MARKER + ' 漢字 café → • − …'
11
+ CHROME_PAIRS = [('▄▀▀▀ ▄▀▀▄', 'C A R T H')]
12
+
13
+
14
+ class GlyphScreen:
15
+ """Small VT screen used by glyph acceptance, with Unicode cell widths."""
16
+
17
+ def __init__(self, rows=32, columns=100):
18
+ self.rows, self.columns = rows, columns
19
+ self.cells = [[' '] * columns for _ in range(rows)]
20
+ self.x = self.y = 0
21
+ self.mode = 'text'
22
+ self.csi = ''
23
+ self.osc = ''
24
+ self.osc_escape = False
25
+ self.title_updates = []
26
+ self.decoder = codecs.getincrementaldecoder('utf8')('replace')
27
+ self.wrapCount = 0
28
+
29
+ @staticmethod
30
+ def char_width(char):
31
+ if unicodedata.combining(char):
32
+ return 0
33
+ return 2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
34
+
35
+ def feed(self, data):
36
+ for char in self.decoder.decode(data):
37
+ if self.mode == 'osc':
38
+ if char == '\x07' or (self.osc_escape and char == '\\'):
39
+ command, _, title = self.osc.partition(';')
40
+ if command in ('0', '2'):
41
+ self.title_updates.append(title)
42
+ self.osc = ''
43
+ self.mode = 'text'
44
+ elif char != '\x1b':
45
+ self.osc += char
46
+ self.osc_escape = char == '\x1b'
47
+ continue
48
+ if self.mode == 'csi':
49
+ if '@' <= char <= '~':
50
+ self._csi(char, self.csi)
51
+ self.mode, self.csi = 'text', ''
52
+ else:
53
+ self.csi += char
54
+ continue
55
+ if self.mode == 'esc':
56
+ if char == '[':
57
+ self.mode, self.csi = 'csi', ''
58
+ elif char == ']':
59
+ self.mode = 'osc'
60
+ else:
61
+ self.mode = 'text'
62
+ continue
63
+ if char == '\x1b':
64
+ self.mode = 'esc'
65
+ elif char == '\r':
66
+ self.x = 0
67
+ elif char == '\n':
68
+ self.y = min(self.y + 1, self.rows - 1)
69
+ elif char == '\b':
70
+ self.x = max(0, self.x - 1)
71
+ elif char >= ' ':
72
+ width = self.char_width(char)
73
+ if width == 0:
74
+ if self.x > 0:
75
+ previous = min(self.columns - 1, self.x - 1)
76
+ while previous > 0 and self.cells[self.y][previous] == '':
77
+ previous -= 1
78
+ self.cells[self.y][previous] += char
79
+ continue
80
+ if self.x + width > self.columns:
81
+ self.wrapCount += 1
82
+ self.x, self.y = 0, min(self.y + 1, self.rows - 1)
83
+ self.cells[self.y][self.x] = char
84
+ if width == 2 and self.x + 1 < self.columns:
85
+ self.cells[self.y][self.x + 1] = ''
86
+ self.x += width
87
+
88
+ def _csi(self, final, params):
89
+ values = [int(value) if value.isdigit() else 0 for value in params.lstrip('?').split(';')]
90
+ n = values[0] if values and values[0] else 1
91
+ if final in ('H', 'f'):
92
+ self.y = max(0, min(self.rows - 1, (values[0] if values and values[0] else 1) - 1))
93
+ self.x = max(0, min(self.columns - 1, (values[1] if len(values) > 1 and values[1] else 1) - 1))
94
+ elif final == 'A': self.y = max(0, self.y - n)
95
+ elif final == 'B': self.y = min(self.rows - 1, self.y + n)
96
+ elif final == 'C': self.x = min(self.columns - 1, self.x + n)
97
+ elif final == 'D': self.x = max(0, self.x - n)
98
+ elif final == 'K': self.cells[self.y][self.x:] = [' '] * (self.columns - self.x)
99
+ elif final == 'J' and values and values[0] == 2:
100
+ self.cells = [[' '] * self.columns for _ in range(self.rows)]
101
+ self.x = self.y = 0
102
+
103
+ def visible(self):
104
+ return '\n'.join(''.join(row).rstrip() for row in self.cells)
105
+
106
+
107
+ def assert_composer_payload(screen_text, expected=USER_TEXT):
108
+ """Require the complete ordered payload on a rendered prompt row."""
109
+ pattern = re.compile(r'^\s*[┃|]\s*' + re.escape(expected) + r'\s*$')
110
+ rows = [row for row in screen_text.splitlines() if pattern.fullmatch(row)]
111
+ if not rows:
112
+ raise AssertionError('exact staged user payload is not preserved in the rendered composer row')
113
+ return rows[0]
114
+
115
+
116
+ def child_environment(root, config, mode, source=None):
117
+ """Build a portable allowlisted child environment, excluding release secrets."""
118
+ source = source if source is not None else os.environ
119
+ root, config = Path(root), Path(config)
120
+ env = {key: source[key] for key in ('PATH', 'LANG', 'LC_ALL') if key in source}
121
+ env.update({'HOME': str(root / 'home'), 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor',
122
+ 'XDG_CONFIG_HOME': str(root / 'config'), 'XDG_DATA_HOME': str(root / 'data'),
123
+ 'XDG_STATE_HOME': str(root / 'state'), 'XDG_CACHE_HOME': str(root / 'cache'),
124
+ 'OPENCODE_DB': str(root / 'db.sqlite'), 'OPENCODE_CONFIG_DIR': str(config),
125
+ 'OPENCODE_CONFIG': str(config / 'opencode.json'),
126
+ 'OPENCODE_CONFIG_PROJECT_DISABLE': 'true', 'CTG_TUI_GLYPHS': mode})
127
+ return env