carthagent 1.0.7 → 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,887 @@
1
+ #!/usr/bin/env python3
2
+ """Opt-in real-PTY input, warm-session-switch, idle-output and RSS probe.
3
+
4
+ Run on a POSIX host with the pinned 2.0.32 binary and installed dependencies.
5
+ This measures the interactive terminal (including CTG plugin work), not RPC.
6
+ Startup and initial warm-up are excluded from latency samples.
7
+ """
8
+ import argparse
9
+ import datetime
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import pathlib
14
+ import re
15
+ import signal
16
+ import select
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import threading
21
+ import time
22
+
23
+ SAMPLES = 30
24
+ TARGET_INPUT_MS = 50
25
+ TARGET_SWITCH_MS = 150
26
+ ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
27
+
28
+
29
+ def percentile95(values):
30
+ ordered = sorted(values)
31
+ return ordered[(len(ordered) * 95 + 99) // 100 - 1]
32
+
33
+
34
+ def host_conditions():
35
+ result = {"capturedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
36
+ "cpuCount": os.cpu_count()}
37
+ try:
38
+ result["loadAverage1m5m15m"] = [round(value, 2) for value in os.getloadavg()]
39
+ except (AttributeError, OSError, ValueError):
40
+ result["loadAverage1m5m15m"] = None
41
+ try:
42
+ values = {line.split(":", 1)[0]: int(line.split()[1])
43
+ for line in pathlib.Path("/proc/meminfo").read_text().splitlines()
44
+ if ":" in line and line.split()[1].isdigit()}
45
+ result["hostMemoryMb"] = {key: round(values[key] / 1024, 1)
46
+ for key in ("MemTotal", "MemAvailable") if key in values}
47
+ except OSError:
48
+ result["hostMemoryMb"] = None
49
+ return result
50
+
51
+
52
+ def utc_now():
53
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
54
+
55
+
56
+ class HostSampler:
57
+ """Capture host load and available memory across the timed measurement window."""
58
+ def __init__(self, interval):
59
+ self.interval = interval
60
+ self.started_ns = None
61
+ self.samples = []
62
+ self._stop = threading.Event()
63
+ self._thread = None
64
+
65
+ def start(self):
66
+ self.started_ns = time.monotonic_ns()
67
+ self._sample()
68
+ self._thread = threading.Thread(target=self._run, name="native-perf-host-sampler", daemon=True)
69
+ self._thread.start()
70
+
71
+ def _sample(self):
72
+ sample = host_conditions()
73
+ sample["elapsedNs"] = time.monotonic_ns() - self.started_ns
74
+ self.samples.append(sample)
75
+
76
+ def _run(self):
77
+ while not self._stop.wait(self.interval):
78
+ self._sample()
79
+
80
+ def stop(self):
81
+ self._stop.set()
82
+ if self._thread:
83
+ self._thread.join()
84
+ self._sample()
85
+ return self.samples
86
+
87
+
88
+ class Terminal:
89
+ def __init__(self, master, child):
90
+ self.master, self.child = master, child
91
+ self.transcript = bytearray()
92
+ self.screen = Screen()
93
+ self.pollCount = 0
94
+ self.pollWaitNs = 0
95
+ self.readNs = 0
96
+ self.screenFeedNs = 0
97
+ self.detectionNs = 0
98
+
99
+ def drain(self, timeout=0.05):
100
+ if self.child.poll() is not None:
101
+ tail = ANSI.sub(b"", bytes(self.transcript)).decode(errors="replace")[-2000:]
102
+ raise RuntimeError(f"native TUI exited with {self.child.returncode}; screen={self.screen.dump()[-1200:]}; output={tail}")
103
+ poll_started = time.perf_counter_ns()
104
+ ready = select.select([self.master], [], [], timeout)[0]
105
+ self.pollCount += 1
106
+ self.pollWaitNs += time.perf_counter_ns() - poll_started
107
+ if not ready:
108
+ return b""
109
+ try:
110
+ read_started = time.perf_counter_ns()
111
+ chunk = os.read(self.master, 65536)
112
+ self.readNs += time.perf_counter_ns() - read_started
113
+ except OSError:
114
+ return b""
115
+ self.transcript.extend(chunk)
116
+ feed_started = time.perf_counter_ns()
117
+ self.screen.feed(chunk)
118
+ self.screenFeedNs += time.perf_counter_ns() - feed_started
119
+ # Reply to terminal capability probes in a headless PTY.
120
+ if b"\x1b[6n" in chunk:
121
+ os.write(self.master, b"\x1b[1;1R")
122
+ if b"\x1b[c" in chunk:
123
+ os.write(self.master, b"\x1b[?1;2c")
124
+ return chunk
125
+
126
+ def collect_until(self, predicate, timeout, description, poll_interval=0.05):
127
+ deadline = time.monotonic() + timeout
128
+ seen = bytearray()
129
+ while time.monotonic() < deadline:
130
+ seen.extend(self.drain(min(poll_interval, deadline - time.monotonic())))
131
+ predicate_started = time.perf_counter_ns()
132
+ matched = predicate(bytes(seen))
133
+ self.detectionNs += time.perf_counter_ns() - predicate_started
134
+ if matched:
135
+ return bytes(seen)
136
+ plain = ANSI.sub(b"", bytes(seen)).decode(errors="replace")
137
+ raise TimeoutError(f"Timed out waiting for {description}; parsed screen: {self.screen.dump()[-1800:]}; output tail: {plain[-1500:]}")
138
+
139
+ def send(self, data):
140
+ os.write(self.master, data)
141
+
142
+ def observer_snapshot(self):
143
+ return {"pollCount": self.pollCount, "pollWaitNs": self.pollWaitNs,
144
+ "readNs": self.readNs, "screenFeedNs": self.screenFeedNs,
145
+ "detectionNs": self.detectionNs}
146
+
147
+ def observer_delta(self, before):
148
+ after = self.observer_snapshot()
149
+ return {key: after[key] - before[key] for key in after}
150
+
151
+
152
+ class Screen:
153
+ """Small deterministic VT screen model for rendered-cell assertions."""
154
+ def __init__(self, rows=36, cols=120):
155
+ self.rows, self.cols = rows, cols
156
+ self.cells = [[" "] * cols for _ in range(rows)]
157
+ self.row = self.col = 0
158
+ self.state = "text"
159
+ self.sequence = ""
160
+ self.pending = b""
161
+
162
+ def _put(self, char):
163
+ if char == "\n":
164
+ self.row = min(self.rows - 1, self.row + 1)
165
+ elif char == "\r":
166
+ self.col = 0
167
+ elif char == "\b":
168
+ self.col = max(0, self.col - 1)
169
+ elif char == "\t":
170
+ self.col = min(self.cols - 1, (self.col // 8 + 1) * 8)
171
+ elif char >= " " and char != "\x7f":
172
+ if self.col >= self.cols:
173
+ self.col = 0
174
+ self.row = min(self.rows - 1, self.row + 1)
175
+ self.cells[self.row][self.col] = char
176
+ self.col += 1
177
+
178
+ def _csi(self, final):
179
+ raw = self.sequence[2:-1]
180
+ params = [int(part) if part.isdigit() else 0 for part in raw.split(";")]
181
+ n = params[0] or 1
182
+ if final in "Hf":
183
+ self.row = max(0, min(self.rows - 1, (params[0] or 1) - 1))
184
+ self.col = max(0, min(self.cols - 1, (params[1] if len(params) > 1 and params[1] else 1) - 1))
185
+ elif final == "A": self.row = max(0, self.row - n)
186
+ elif final == "B": self.row = min(self.rows - 1, self.row + n)
187
+ elif final == "C": self.col = min(self.cols - 1, self.col + n)
188
+ elif final == "D": self.col = max(0, self.col - n)
189
+ elif final == "G": self.col = max(0, min(self.cols - 1, n - 1))
190
+ elif final == "d": self.row = max(0, min(self.rows - 1, n - 1))
191
+ elif final == "K":
192
+ mode = params[0] if params else 0
193
+ start = 0 if mode == 1 else self.col
194
+ end = self.col + 1 if mode == 1 else self.cols if mode == 2 else self.cols
195
+ for col in range(start, end): self.cells[self.row][col] = " "
196
+ elif final == "J":
197
+ mode = params[0] if params else 0
198
+ if mode == 2 or mode == 3:
199
+ self.cells = [[" "] * self.cols for _ in range(self.rows)]
200
+ elif mode == 0:
201
+ for col in range(self.col, self.cols): self.cells[self.row][col] = " "
202
+ for row in range(self.row + 1, self.rows): self.cells[row] = [" "] * self.cols
203
+ elif final == "X":
204
+ for col in range(self.col, min(self.cols, self.col + n)): self.cells[self.row][col] = " "
205
+ elif final == "s":
206
+ self.saved = (self.row, self.col)
207
+ elif final == "u" and hasattr(self, "saved"):
208
+ self.row, self.col = self.saved
209
+
210
+ def feed(self, data):
211
+ decoded = self.pending + data
212
+ text = decoded.decode("utf-8", errors="replace")
213
+ self.pending = b""
214
+ # Retain an incomplete trailing UTF-8 sequence for the next PTY chunk.
215
+ encoded = text.encode("utf-8")
216
+ if encoded != decoded:
217
+ for length in range(1, min(4, len(decoded)) + 1):
218
+ try:
219
+ text = decoded[:-length].decode("utf-8")
220
+ self.pending = decoded[-length:]
221
+ break
222
+ except UnicodeDecodeError:
223
+ continue
224
+ for char in text:
225
+ if self.state == "esc":
226
+ if char == "[": self.state, self.sequence = "csi", "\x1b["
227
+ elif char == "]": self.state = "osc"
228
+ elif char == "7": self.state = "text"
229
+ else: self.state = "text"
230
+ elif self.state == "osc":
231
+ if char == "\x07": self.state = "text"
232
+ elif char == "\x1b": self.state = "osc-esc"
233
+ elif self.state == "osc-esc":
234
+ self.state = "text" if char == "\\" else "osc"
235
+ elif self.state == "csi":
236
+ self.sequence += char
237
+ if "@" <= char <= "~":
238
+ self._csi(char)
239
+ self.state, self.sequence = "text", ""
240
+ elif char == "\x1b": self.state = "esc"
241
+ else: self._put(char)
242
+
243
+ def lines(self):
244
+ return ["".join(row).rstrip() for row in self.cells]
245
+
246
+ def contains(self, text):
247
+ return any(text in line for line in self.lines())
248
+
249
+ def dump(self):
250
+ return "\n".join(self.lines())
251
+
252
+
253
+ def locate_text(screen, text):
254
+ for row, line in enumerate(screen.lines()):
255
+ col = line.find(text)
256
+ if col >= 0:
257
+ return row, col
258
+ return None
259
+
260
+
261
+ NATIVE_EDITOR_PLACEHOLDER = "Message carthagent…"
262
+ WORKSPACE_RENDER_SENTINELS = ("CARTHAGENT · WORKSPACE", "Workspace native sessions")
263
+
264
+
265
+ def editor_contains_typed_cell(screen, marker, before, row, editor_col):
266
+ # The native placeholder's starting cell is the input origin. Restrict the
267
+ # assertion to that exact cell; other changes on the editor row do not count.
268
+ return (screen.cells[row][editor_col] == marker and
269
+ before[row][editor_col] != marker)
270
+
271
+
272
+ def workspace_render_completed(screen, before_lines):
273
+ """Require fresh, visible plugin route and data-backed workspace section."""
274
+ current = screen.lines()
275
+ previous = "\n".join(before_lines)
276
+ rendered = "\n".join(current)
277
+ return all(sentinel in rendered and sentinel not in previous
278
+ for sentinel in WORKSPACE_RENDER_SENTINELS)
279
+
280
+
281
+ def workspace_command_staged(screen, editor_origin):
282
+ rendered = "\n".join(screen.lines())
283
+ row, column = editor_origin
284
+ staged = "".join(screen.cells[row][column:column + len("/ctg-workspace")])
285
+ return (staged == "/ctg-workspace" and
286
+ "Carthagent commands" not in rendered and
287
+ "Search commands" not in rendered and
288
+ NATIVE_EDITOR_PLACEHOLDER not in rendered)
289
+
290
+
291
+ def main():
292
+ parser = argparse.ArgumentParser(description=__doc__)
293
+ parser.add_argument("--binary", default=os.environ.get("CTG_OPENCODE_BIN") or shutil.which("opencode"))
294
+ parser.add_argument("--plugin", default=str(pathlib.Path(__file__).resolve().parents[1] / "plugins/carthagent"))
295
+ parser.add_argument("--switch-fixture", help="JSON emitted by seed-native-switch-fixture.mjs; enables native Open Sessions switching")
296
+ parser.add_argument("--readiness-only", action="store_true",
297
+ help="bounded, non-timed real-PTY editor echo/backspace check; no performance samples")
298
+ parser.add_argument("--no-plugin", action="store_true",
299
+ help="measure the native baseline without registering the CTG plugin")
300
+ parser.add_argument("--samples", type=int, default=SAMPLES)
301
+ parser.add_argument("--host-sample-interval", type=float, default=1.0,
302
+ help="seconds between load/memory observations during measured samples")
303
+ parser.add_argument("--idle-seconds", type=float, default=10,
304
+ help="bounded idle observation window (default: 10s; max: 300s)")
305
+ parser.add_argument("--idle-byte-limit", type=int, default=65536,
306
+ help="report idle-output budget; status-clock bytes are reported, not presumed a defect")
307
+ parser.add_argument("--memory-growth-limit-mb", type=int, default=None,
308
+ help="optional operator-approved RSS growth threshold; no product threshold is currently defined")
309
+ parser.add_argument("--report", help="write machine-readable report JSON")
310
+ args = parser.parse_args()
311
+ if os.name != "posix":
312
+ parser.error("real PTY performance probe requires POSIX")
313
+ if not args.binary:
314
+ parser.error("provide --binary or CTG_OPENCODE_BIN for the owned 2.0.32 executable")
315
+ if args.samples < 30 and not args.readiness_only:
316
+ parser.error("at least 30 samples are required")
317
+ if not 1 <= args.idle_seconds <= 300:
318
+ parser.error("idle window must be between 1 and 300 seconds")
319
+ if not 0.1 <= args.host_sample_interval <= 30:
320
+ parser.error("host sample interval must be between 0.1 and 30 seconds")
321
+ binary = str(pathlib.Path(args.binary).resolve())
322
+ fixture = json.loads(pathlib.Path(args.switch_fixture).read_text()) if args.switch_fixture else None
323
+ if fixture:
324
+ observed = fixture.get("pluginObservation", {}).get("observed")
325
+ native_inventory = fixture.get("pluginInventory")
326
+ inventory_ids = [item.get("id") for item in native_inventory if isinstance(item, dict)] if isinstance(native_inventory, list) else None
327
+ expected = os.environ.get("CTG_PERF_EXPECT_PLUGIN")
328
+ if observed not in ("active", "absent") or expected != observed:
329
+ parser.error("fixture must match the expected observed native plugin inventory state")
330
+ if inventory_ids is None:
331
+ parser.error("fixture must include the native plugin.list inventory observation")
332
+ if observed == "active" and not any(isinstance(item, dict) and item.get("id") == "carthagent" and
333
+ isinstance(item.get("state"), dict) and item["state"].get("status") == "active"
334
+ for item in native_inventory):
335
+ parser.error("native plugin.list inventory does not show Carthagent active")
336
+ if observed == "absent" and any(isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].startswith("carthagent") for item in native_inventory):
337
+ parser.error("baseline native plugin.list inventory contains a Carthagent plugin")
338
+ runtime_identity = fixture.get("runtimeIdentity")
339
+ if not isinstance(runtime_identity, dict) or runtime_identity.get("compatibilityVersion") != "2.0.32":
340
+ parser.error("fixture lacks verified native runtime compatibility identity")
341
+ if runtime_identity.get("binary") != str(pathlib.Path(binary).resolve()):
342
+ parser.error("fixture runtime executable identity does not match the measured executable")
343
+ if runtime_identity.get("binarySha256") and runtime_identity.get("binarySha256") != hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest():
344
+ parser.error("fixture runtime binary hash does not match the measured executable")
345
+ runtime_version = runtime_identity.get("runtimeVersion")
346
+ if not isinstance(runtime_version, str) or not runtime_version:
347
+ parser.error("fixture lacks exact verified native runtime version")
348
+ if observed == "active" and (fixture.get("pluginHealth", {}).get("hostVersion") != runtime_version or
349
+ fixture.get("pluginHealth", {}).get("protocol") != 1 or
350
+ fixture.get("pluginHealth", {}).get("directoryMatches") is not True):
351
+ parser.error("CTG fixture is missing a successful native health RPC verification")
352
+ if fixture.get("provenance", {}).get("binarySha256") != hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest():
353
+ parser.error("fixture binary hash does not match the executable being measured")
354
+ if fixture.get("provenance", {}).get("binaryVersion") != runtime_identity.get("compatibilityVersion"):
355
+ parser.error("fixture provenance compatibility version does not match verified runtime identity")
356
+ if fixture.get("provenance", {}).get("runtimeVersion", runtime_identity.get("runtimeVersion")) != runtime_identity.get("runtimeVersion"):
357
+ parser.error("fixture provenance runtime version does not match verified runtime identity")
358
+ if fixture.get("provenance", {}).get("binarySha256") != runtime_identity.get("binarySha256"):
359
+ parser.error("fixture provenance binary hash does not match verified runtime identity")
360
+ if not os.environ.get("CTG_PERF_SOURCE_COMMIT") or fixture.get("provenance", {}).get("sourceCommit") != os.environ["CTG_PERF_SOURCE_COMMIT"]:
361
+ parser.error("fixture source commit does not match selected immutable execution snapshot")
362
+ if fixture.get("provenance", {}).get("sourceSnapshotSha256") != os.environ.get("CTG_PERF_SOURCE_SNAPSHOT_SHA256"):
363
+ parser.error("fixture execution snapshot hash does not match runner snapshot")
364
+ if observed == "active" and not fixture.get("provenance", {}).get("sourceSnapshotSha256"):
365
+ parser.error("active CTG fixture is missing immutable source snapshot provenance")
366
+ if not fixture.get("roots", {}).get("alpha") or not fixture.get("sessions", {}).get("alpha", {}).get("id") or not fixture.get("sessions", {}).get("bravo", {}).get("id"):
367
+ parser.error("paired fixture must include both disposable session destinations")
368
+ owns_root = fixture is None
369
+ root = pathlib.Path(tempfile.mkdtemp(prefix="ctg-native-terminal-perf-")) if owns_root else pathlib.Path(fixture["temp"])
370
+ if owns_root:
371
+ for name in ("home", "project", "config/opencode", "data", "state", "cache"):
372
+ (root / name).mkdir(parents=True, exist_ok=True)
373
+ config = {"update": "disable"}
374
+ if not args.no_plugin:
375
+ config["plugins"] = [str(pathlib.Path(args.plugin).resolve())]
376
+ (root / "config/opencode/opencode.json").write_text(json.dumps(config))
377
+ env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL") if key in os.environ}
378
+ env.update({
379
+ "HOME": str(root / "home"), "USERPROFILE": str(root / "home"),
380
+ "TERM": "xterm-256color", "COLORTERM": "truecolor",
381
+ "XDG_CONFIG_HOME": str(root / "config"), "XDG_DATA_HOME": str(root / "data"),
382
+ "XDG_STATE_HOME": str(root / "state"), "XDG_CACHE_HOME": str(root / "cache"),
383
+ "OPENCODE_DB": str(root / "database.sqlite"), "OPENCODE_CONFIG_PROJECT_DISABLE": "true",
384
+ })
385
+ if fixture:
386
+ env["OPENCODE_PASSWORD"] = fixture["password"]
387
+ env["CARTHAGENT_CODING_AGENT_DIR"] = str(root / "home/.carthagent/agent")
388
+ env["PI_CODING_AGENT_DIR"] = str(root / "home/.carthagent/agent")
389
+ env["HOME"] = str(root / "home")
390
+ env["NODE_PATH"] = os.environ.get("NODE_PATH", "")
391
+ cwd = pathlib.Path(fixture["roots"]["alpha"])
392
+ startup_session = (fixture.get("readinessSession", {}).get("id")
393
+ if args.readiness_only and fixture.get("pluginObservation", {}).get("observed") == "active"
394
+ else fixture["sessions"]["alpha"]["id"])
395
+ cwd.mkdir(parents=True, exist_ok=True)
396
+ # The seeder has already started the pinned server once, so the host's
397
+ # plugin/config/profile layout and database are all initialized here.
398
+ else:
399
+ cwd = root / "project"
400
+ startup_session = None
401
+ # Keep pure screen/editor/workspace helpers importable on Windows. The
402
+ # real probe remains POSIX-only and loads its PTY APIs only when executing.
403
+ import fcntl
404
+ import pty
405
+ import struct
406
+ import termios
407
+
408
+ master, slave = pty.openpty()
409
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0))
410
+ command = [binary, "--standalone"]
411
+ if fixture:
412
+ command = [binary, "--server", fixture["baseUrl"], "--session", startup_session]
413
+ try:
414
+ child = subprocess.Popen(command, cwd=cwd, env=env,
415
+ stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
416
+ except BaseException:
417
+ os.close(master)
418
+ os.close(slave)
419
+ if owns_root:
420
+ shutil.rmtree(root, ignore_errors=True)
421
+ raise
422
+ os.close(slave)
423
+ terminal = Terminal(master, child)
424
+ previous_sigterm = signal.getsignal(signal.SIGTERM)
425
+ def handle_sigterm(_signum, _frame):
426
+ raise KeyboardInterrupt("received SIGTERM")
427
+ signal.signal(signal.SIGTERM, handle_sigterm)
428
+ try:
429
+ result = {"binary": binary, "expectedHostVersion": "2.0.32",
430
+ "compatibilityVersion": runtime_identity["compatibilityVersion"] if fixture else "2.0.32",
431
+ "runtimeVersion": runtime_identity["runtimeVersion"] if fixture else None,
432
+ "measurement": "real POSIX PTY; native plugin inventory observed active" if fixture and fixture.get("pluginObservation", {}).get("observed") == "active" else "real POSIX PTY; native plugin inventory observed absent",
433
+ "startupIncludedInSamples": False, "samples": args.samples,
434
+ "measurementScope": "terminal interaction through select/read/VT parse and parsed PTY screen evidence; not compositor/GPU presentation; observer overhead is reported and never subtracted",
435
+ "hostConditions": host_conditions()}
436
+ if fixture:
437
+ result["switchFixture"] = str(pathlib.Path(args.switch_fixture).resolve())
438
+ result["pluginObservation"] = fixture.get("pluginObservation")
439
+ result["pluginInventory"] = fixture.get("pluginInventory")
440
+ result["pluginHealth"] = fixture.get("pluginHealth")
441
+ result["provenance"] = fixture.get("provenance")
442
+ result["runtimeIdentity"] = runtime_identity
443
+ result["runtimeVersion"] = runtime_identity["runtimeVersion"]
444
+ result["compatibilityVersion"] = runtime_identity["compatibilityVersion"]
445
+ result["sourceProvenance"] = {"commit": os.environ.get("CTG_PERF_SOURCE_COMMIT"),
446
+ "snapshotSha256": os.environ.get("CTG_PERF_SOURCE_SNAPSHOT_SHA256"),
447
+ "sourceDirectory": os.environ.get("CTG_PERF_SOURCE_DIR")}
448
+ if os.environ.get("CTG_PERF_SOURCE_COMMIT") and not fixture.get("pluginObservation", {}).get("observed"):
449
+ raise RuntimeError("Fixture lacks verified native plugin condition; refusing performance artifact")
450
+ except BaseException:
451
+ signal.signal(signal.SIGTERM, previous_sigterm)
452
+ if child.poll() is None:
453
+ os.killpg(child.pid, signal.SIGTERM)
454
+ try:
455
+ child.wait(timeout=5)
456
+ except subprocess.TimeoutExpired:
457
+ os.killpg(child.pid, signal.SIGKILL)
458
+ child.wait()
459
+ os.close(master)
460
+ if owns_root:
461
+ shutil.rmtree(root, ignore_errors=True)
462
+ raise
463
+ try:
464
+ # Wait on actual TUI readiness; never charge fixed sleeps to an input latency.
465
+ terminal.collect_until(
466
+ (lambda _output: terminal.screen.contains(fixture["readinessSession"]["title"]))
467
+ if args.readiness_only and fixture and fixture.get("pluginObservation", {}).get("observed") == "active" else
468
+ (lambda _output: terminal.screen.contains(fixture["sessions"]["alpha"]["marker"])) if fixture else
469
+ (lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None),
470
+ 90, "native editor placeholder/session")
471
+ # Allow the first genuine frame/plugin setup to settle, outside all sample clocks.
472
+ terminal.drain(0.5)
473
+ if (args.readiness_only and fixture and
474
+ fixture.get("pluginObservation", {}).get("observed") == "absent"):
475
+ readiness = {"readinessOnly": True, "timedSamplesRun": False,
476
+ "readinessType": "baseline-native-plugin-inventory",
477
+ "pluginObservation": fixture.get("pluginObservation"),
478
+ "pluginAbsentVerified": fixture.get("pluginObservation", {}).get("observed") == "absent",
479
+ "fixtureSessionRendered": terminal.screen.contains(fixture["sessions"]["alpha"]["marker"]),
480
+ "measurementScope": "bounded disposable fixture/plugin absence verification; no latency samples or performance claim"}
481
+ if not readiness["pluginAbsentVerified"] or not readiness["fixtureSessionRendered"]:
482
+ raise RuntimeError("Baseline plugin-absence/session readiness was not verified")
483
+ text = json.dumps(readiness, indent=2)
484
+ print(text)
485
+ if args.report:
486
+ pathlib.Path(args.report).write_text(text + "\n")
487
+ return
488
+ if args.readiness_only and fixture:
489
+ # Attach directly to a separately seeded empty session; do not
490
+ # infer or navigate the native new-session keybinding here.
491
+ terminal.collect_until(lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None,
492
+ 90, "native editor placeholder in fresh session")
493
+ switch_ms = []
494
+ switch_observations = []
495
+ input_observations = []
496
+ input_warmup_observation = None
497
+ sampler = HostSampler(args.host_sample_interval)
498
+ switch_note = "not measured: provide --switch-fixture with two preloaded native sessions"
499
+ if fixture and not args.readiness_only:
500
+ sampler.start()
501
+ def select_native_session(session, timed):
502
+ title = session["title"]
503
+ if terminal.screen.contains(session["marker"]):
504
+ if not timed: return None
505
+ raise RuntimeError(f"Switch sample targeted already-active session {session['id']}")
506
+ # In the pinned 2.0.32 TUI Ctrl+O opens Open Sessions. Search
507
+ # by the exact fixture title, then time Enter-to-history render.
508
+ terminal.send(b"\x0f") # open.menu
509
+ terminal.collect_until(lambda _output: locate_text(terminal.screen, "Search sessions and projects") is not None or
510
+ locate_text(terminal.screen, "Recent Sessions") is not None or
511
+ locate_text(terminal.screen, "Open") is not None,
512
+ 10, "native Open Sessions picker")
513
+ terminal.send(title.encode())
514
+ terminal.collect_until(lambda _output: locate_text(terminal.screen, title) is not None,
515
+ 10, f"native session row {title}")
516
+ if terminal.screen.contains(session["marker"]):
517
+ raise RuntimeError(f"Destination marker {session['marker']} visible before selection")
518
+ observer_before = terminal.observer_snapshot()
519
+ started_at = utc_now()
520
+ started = time.perf_counter_ns()
521
+ started_monotonic_ns = time.monotonic_ns()
522
+ terminal.send(b"\r")
523
+ try:
524
+ terminal.collect_until(lambda _output: terminal.screen.contains(session["marker"]), 10,
525
+ f"unique rendered destination marker {session['marker']}", poll_interval=0.001)
526
+ except Exception as error:
527
+ failed_ns = time.perf_counter_ns()
528
+ failed_monotonic_ns = time.monotonic_ns()
529
+ if timed:
530
+ switch_observations.append({"sample": len(switch_observations) + 1,
531
+ "sessionId": session["id"], "marker": session["marker"],
532
+ "status": "failed", "startedAt": started_at,
533
+ "failedAt": utc_now(),
534
+ "startedMonotonicNs": started_monotonic_ns,
535
+ "failedMonotonicNs": failed_monotonic_ns,
536
+ "elapsedToFailureNs": failed_ns - started,
537
+ "failure": f"{type(error).__name__}: {error}",
538
+ "destinationMarkerVerified": terminal.screen.contains(session["marker"]),
539
+ "observerWork": terminal.observer_delta(observer_before),
540
+ "timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
541
+ raise
542
+ completed_ns = time.perf_counter_ns()
543
+ completed_monotonic_ns = time.monotonic_ns()
544
+ completed_at = utc_now()
545
+ elapsed_ns = completed_ns - started
546
+ if timed:
547
+ switch_observations.append({"sessionId": session["id"], "marker": session["marker"],
548
+ "startedAt": started_at, "completedAt": completed_at,
549
+ "startedMonotonicNs": started_monotonic_ns,
550
+ "completedMonotonicNs": completed_monotonic_ns,
551
+ "elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
552
+ "destinationMarkerVerified": terminal.screen.contains(session["marker"]),
553
+ "observerWork": terminal.observer_delta(observer_before),
554
+ "timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
555
+ return elapsed_ns / 1_000_000
556
+ return None
557
+
558
+ # Visit both targets before the measured 30 alternations so their
559
+ # session files and destination render state have been loaded.
560
+ select_native_session(fixture["sessions"]["bravo"], False)
561
+ select_native_session(fixture["sessions"]["alpha"], False)
562
+ for index in range(args.samples):
563
+ destination = fixture["sessions"]["bravo" if index % 2 == 0 else "alpha"]
564
+ try:
565
+ switch_ms.append(select_native_session(destination, True))
566
+ except Exception as error:
567
+ if not any(sample.get("sample") == index + 1 for sample in switch_observations):
568
+ switch_observations.append({"sample": index + 1, "sessionId": destination["id"],
569
+ "marker": destination["marker"], "status": "failed",
570
+ "failure": f"{type(error).__name__}: {error}",
571
+ "failedAt": utc_now(),
572
+ "timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
573
+ switch_ms.append(None)
574
+ try:
575
+ terminal.send(b"\x1b")
576
+ terminal.drain(0.1)
577
+ except Exception:
578
+ pass
579
+ switch_note = f"{args.samples} alternating native Open Sessions menu selections; Enter-to-unique-assistant-history-marker render; both destinations explicitly prewarmed"
580
+ if args.readiness_only:
581
+ terminal.collect_until(lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None,
582
+ 10, "native editor placeholder")
583
+ editor_anchor = locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER)
584
+ editor_origin = None
585
+ if editor_anchor is None:
586
+ raise RuntimeError(f"native editor placeholder {NATIVE_EDITOR_PLACEHOLDER!r} was not present; parsed screen: {terminal.screen.dump()[-1800:]}")
587
+ else:
588
+ editor_row, editor_col = editor_anchor
589
+ editor_origin = (editor_row, editor_col)
590
+ editor_empty_cell = terminal.screen.cells[editor_origin[0]][editor_origin[1]]
591
+ if args.readiness_only:
592
+ if fixture:
593
+ sampler.start()
594
+ before = [row[:] for row in terminal.screen.cells]
595
+ terminal.send(b"z")
596
+ terminal.collect_until(lambda _output: editor_contains_typed_cell(terminal.screen, "z", before, *editor_origin),
597
+ 2, "changed typed cell at native editor input origin", poll_interval=0.001)
598
+ typed_cell = terminal.screen.cells[editor_origin[0]][editor_origin[1]]
599
+ terminal.send(b"\x7f")
600
+ terminal.collect_until(lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell and
601
+ locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) == editor_origin,
602
+ 1, "native editor placeholder restored after backspace", poll_interval=0.001)
603
+ readiness = {"readinessOnly": True, "timedSamplesRun": False,
604
+ "nativeEditorPlaceholder": NATIVE_EDITOR_PLACEHOLDER,
605
+ "inputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
606
+ "placeholderCell": editor_empty_cell, "typedCell": typed_cell,
607
+ "changedCellAtInputOrigin": before[editor_origin[0]][editor_origin[1]] != typed_cell,
608
+ "restoredPlaceholderVerified": True,
609
+ "pluginObservation": fixture.get("pluginObservation") if fixture else None,
610
+ "measurementScope": "bounded real-PTY readiness/echo only; no latency samples or performance claim"}
611
+ if fixture:
612
+ readiness["hostConditionsDuringReadiness"] = sampler.stop()
613
+ text = json.dumps(readiness, indent=2)
614
+ print(text)
615
+ if args.report:
616
+ pathlib.Path(args.report).write_text(text + "\n")
617
+ return
618
+ input_ms = []
619
+
620
+ def send_and_verify_input(marker, sample_index, timed):
621
+ before = [row[:] for row in terminal.screen.cells]
622
+ observer_before = terminal.observer_snapshot()
623
+ started_at = utc_now()
624
+ started = time.perf_counter_ns()
625
+ started_monotonic_ns = time.monotonic_ns()
626
+ terminal.send(marker.encode())
627
+ deadline = time.monotonic() + 2
628
+ while time.monotonic() < deadline:
629
+ try:
630
+ terminal.drain(0.001)
631
+ except Exception as error:
632
+ if timed:
633
+ input_observations.append({"sample": sample_index, "key": marker, "status": "failed",
634
+ "startedAt": started_at, "failedAt": utc_now(),
635
+ "startedMonotonicNs": started_monotonic_ns,
636
+ "failedMonotonicNs": time.monotonic_ns(),
637
+ "failure": f"{type(error).__name__}: {error}"})
638
+ raise
639
+ detect_started = time.perf_counter_ns()
640
+ detected = editor_contains_typed_cell(terminal.screen, marker, before, *editor_origin)
641
+ terminal.detectionNs += time.perf_counter_ns() - detect_started
642
+ if detected:
643
+ completed_ns = time.perf_counter_ns()
644
+ completed_monotonic_ns = time.monotonic_ns()
645
+ completed_at = utc_now()
646
+ elapsed_ns = completed_ns - started
647
+ observation = {"sample": sample_index, "key": marker, "status": "passed",
648
+ "startedAt": started_at, "completedAt": completed_at,
649
+ "startedMonotonicNs": started_monotonic_ns,
650
+ "completedMonotonicNs": completed_monotonic_ns,
651
+ "elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
652
+ "editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
653
+ "beforeCell": before[editor_origin[0]][editor_origin[1]],
654
+ "afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
655
+ "changedCellAtInputOrigin": True,
656
+ "observerWork": terminal.observer_delta(observer_before),
657
+ "timingScope": "keypress write through select/read/VT parse and changed-cell observation; not compositor presentation"}
658
+ if timed:
659
+ input_ms.append(elapsed_ns / 1_000_000)
660
+ input_observations.append(observation)
661
+ else:
662
+ nonlocal input_warmup_observation
663
+ input_warmup_observation = observation
664
+ terminal.send(b"\x7f")
665
+ terminal.collect_until(
666
+ lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell,
667
+ 1, "editor restored to empty placeholder after backspace", poll_interval=0.001)
668
+ return True
669
+ if timed:
670
+ input_observations.append({"sample": sample_index, "key": marker, "status": "failed",
671
+ "startedAt": started_at, "failedAt": utc_now(),
672
+ "startedMonotonicNs": started_monotonic_ns,
673
+ "failedMonotonicNs": time.monotonic_ns(),
674
+ "failure": "TimeoutError: no changed cell at parsed editor input origin within 2 seconds",
675
+ "editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
676
+ "beforeCell": before[editor_origin[0]][editor_origin[1]],
677
+ "afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
678
+ "observerWork": terminal.observer_delta(observer_before)})
679
+ else:
680
+ input_warmup_observation = {"sample": 0, "key": marker, "status": "failed",
681
+ "startedAt": started_at, "failedAt": utc_now(),
682
+ "startedMonotonicNs": started_monotonic_ns,
683
+ "failedMonotonicNs": time.monotonic_ns(),
684
+ "failure": "TimeoutError: warmup key not observed at parsed editor input origin",
685
+ "editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
686
+ "beforeCell": before[editor_origin[0]][editor_origin[1]],
687
+ "afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
688
+ "observerWork": terminal.observer_delta(observer_before)}
689
+ # Attempt to restore the input field after an undetected echo;
690
+ # this cleanup is outside any successful sample clock.
691
+ terminal.send(b"\x7f")
692
+ terminal.collect_until(
693
+ lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell,
694
+ 1, "editor restored to empty placeholder after failed key", poll_interval=0.001)
695
+ return False
696
+
697
+ # Warm the editor/input path once; only the following N key echoes count.
698
+ input_warmup_passed = send_and_verify_input("z", 0, False)
699
+ for index in range(args.samples):
700
+ try:
701
+ send_and_verify_input(chr(ord("a") + index % 26), index + 1, True)
702
+ except Exception as error:
703
+ if not any(item.get("sample") == index + 1 and item.get("status") == "failed"
704
+ for item in input_observations):
705
+ input_observations.append({"sample": index + 1, "status": "failed",
706
+ "failedAt": utc_now(), "failure": f"{type(error).__name__}: {error}"})
707
+ input_ms.append(None)
708
+ if not input_warmup_passed:
709
+ result_warmup_failure = "warmup key not verified at editor input origin"
710
+ else:
711
+ result_warmup_failure = None
712
+ input_editor_restored_verified = (
713
+ locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) == editor_origin and
714
+ terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell)
715
+ workspace_render = {"status": "not-run", "timedSamplesRun": 0,
716
+ "targetMs": 300, "targetMet": False,
717
+ "failure": "native CTG fixture required"}
718
+ if fixture and fixture.get("pluginObservation", {}).get("observed") == "active":
719
+ # First Enter accepts slash autocomplete. Require the command to
720
+ # remain visible as staged editor text before starting the clock.
721
+ terminal.send(b"/ctg-workspace")
722
+ terminal.collect_until(lambda _output: terminal.screen.contains("/ctg-workspace"),
723
+ 5, "submitted workspace command visible before Enter")
724
+ terminal.send(b"\r")
725
+ terminal.collect_until(lambda _output: workspace_command_staged(terminal.screen, editor_origin),
726
+ 5, "selected workspace command after autocomplete dismissal")
727
+ before_workspace = terminal.screen.lines()
728
+ observer_before = terminal.observer_snapshot()
729
+ started_at = utc_now()
730
+ started = time.perf_counter_ns()
731
+ terminal.send(b"\r")
732
+ try:
733
+ terminal.collect_until(lambda _output: workspace_render_completed(terminal.screen, before_workspace),
734
+ 15, "fresh rendered CTG workspace route and sessions section", poll_interval=0.001)
735
+ elapsed_ns = time.perf_counter_ns() - started
736
+ workspace_render = {"status": "complete", "timedSamplesRun": 1,
737
+ "startedAt": started_at, "completedAt": utc_now(),
738
+ "elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
739
+ "targetMs": 300, "targetMet": elapsed_ns / 1_000_000 <= 300,
740
+ "sentinels": list(WORKSPACE_RENDER_SENTINELS),
741
+ "observerWork": terminal.observer_delta(observer_before),
742
+ "timingScope": "submission Enter write through PTY select/read, VT parse and fresh parsed route/data render; autocomplete acceptance excluded; observer work is not subtracted; not compositor presentation"}
743
+ except Exception as error:
744
+ workspace_render = {"status": "failed", "timedSamplesRun": 0,
745
+ "startedAt": started_at, "failedAt": utc_now(),
746
+ "targetMs": 300, "targetMet": False,
747
+ "failure": f"{type(error).__name__}: {error}",
748
+ "sentinels": list(WORKSPACE_RENDER_SENTINELS),
749
+ "observerWork": terminal.observer_delta(observer_before),
750
+ "finalScreen": terminal.screen.lines()}
751
+ result["workspaceRender"] = workspace_render
752
+ if fixture:
753
+ host_samples = sampler.stop()
754
+ else:
755
+ host_samples = []
756
+
757
+ successful_input_ms = [value for value in input_ms if value is not None]
758
+ successful_switch_ms = [value for value in switch_ms if value is not None]
759
+ input_failed_samples = sum(item.get("status") == "failed" for item in input_observations)
760
+ result["input"] = {"samples": args.samples, "successfulSamples": len(successful_input_ms),
761
+ "failedSamples": input_failed_samples,
762
+ "p95Ms": round(percentile95(successful_input_ms), 3) if successful_input_ms else None,
763
+ "maxMs": round(max(successful_input_ms), 3) if successful_input_ms else None, "targetMs": TARGET_INPUT_MS,
764
+ "targetMet": (result_warmup_failure is None and len(successful_input_ms) == args.samples and input_failed_samples == 0 and
765
+ percentile95(successful_input_ms) <= TARGET_INPUT_MS),
766
+ "basis": "keypress write to changed cell at native editor placeholder origin",
767
+ "warmupSamplesExcluded": 1,
768
+ "warmupPassed": result_warmup_failure is None,
769
+ "warmupFailure": result_warmup_failure,
770
+ "warmupObservation": input_warmup_observation,
771
+ "editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
772
+ "finalScreen": terminal.screen.lines(),
773
+ "finalEditorCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
774
+ "finalEditorCellExpected": editor_empty_cell,
775
+ "finalEditorPlaceholderVerified": input_editor_restored_verified,
776
+ "rawSamples": input_observations}
777
+ switch_failed_samples = sum(item.get("status") == "failed" for item in switch_observations)
778
+ result["warmSessionSwitch"] = {"samples": args.samples if fixture else 0, "successfulSamples": len(successful_switch_ms),
779
+ "failedSamples": switch_failed_samples,
780
+ "p95Ms": round(percentile95(successful_switch_ms), 3) if successful_switch_ms else None,
781
+ "maxMs": round(max(successful_switch_ms), 3) if successful_switch_ms else None,
782
+ "targetMs": TARGET_SWITCH_MS,
783
+ "targetMet": percentile95(successful_switch_ms) <= TARGET_SWITCH_MS if len(successful_switch_ms) == args.samples and switch_failed_samples == 0 else False,
784
+ "note": switch_note,
785
+ "sequenceMs": [round(value, 3) if value is not None else None for value in switch_ms],
786
+ "rawSamples": switch_observations}
787
+ if fixture:
788
+ result["hostConditionsDuringMeasurement"] = host_samples
789
+ result["observerTotals"] = terminal.observer_snapshot()
790
+ # Bounded idle observation; report all bytes and RSS trend. Native status
791
+ # clocks may legitimately redraw, so byte activity alone is not a failure.
792
+ def process_tree_rss_kb(root_pid):
793
+ # Include plugin/runtime descendants when Linux exposes process stats.
794
+ try:
795
+ root_pid = int(root_pid)
796
+ if not pathlib.Path(f"/proc/{root_pid}/status").exists():
797
+ return None
798
+ parent = {root_pid}
799
+ rss_total = 0
800
+ while parent:
801
+ discovered = set()
802
+ for proc in pathlib.Path("/proc").iterdir():
803
+ if not proc.name.isdecimal():
804
+ continue
805
+ try:
806
+ status = (proc / "status").read_text()
807
+ ppid = int(status.split("PPid:")[1].split()[0])
808
+ if ppid in parent:
809
+ discovered.add(int(proc.name))
810
+ if int(proc.name) in parent:
811
+ rss_total += int(status.split("VmRSS:")[1].split()[0])
812
+ except (OSError, IndexError, ValueError):
813
+ continue
814
+ parent = discovered
815
+ return rss_total
816
+ except OSError:
817
+ return None
818
+ idle_start_bytes = len(terminal.transcript)
819
+ rss = []
820
+ tui_rss = []
821
+ server_rss = []
822
+ idle_deadline = time.monotonic() + args.idle_seconds
823
+ while time.monotonic() < idle_deadline:
824
+ terminal.drain(0.1)
825
+ tui_sample = process_tree_rss_kb(child.pid)
826
+ server_pid = fixture.get("serverPid") if fixture else None
827
+ server_sample = process_tree_rss_kb(server_pid) if server_pid else None
828
+ sample = ((tui_sample or 0) + (server_sample or 0)
829
+ if tui_sample is not None or server_sample is not None else None)
830
+ if sample is not None:
831
+ rss.append(sample)
832
+ if tui_sample is not None:
833
+ tui_rss.append(tui_sample)
834
+ if server_sample is not None:
835
+ server_rss.append(server_sample)
836
+ idle_bytes = len(terminal.transcript) - idle_start_bytes
837
+ growth_mb = ((max(rss) - min(rss)) / 1024) if rss else None
838
+ result["idle"] = {"windowSeconds": args.idle_seconds, "ptyBytes": idle_bytes,
839
+ "byteBudget": args.idle_byte_limit, "byteBudgetMet": idle_bytes <= args.idle_byte_limit,
840
+ "rssSamples": len(rss), "rssMinMb": round(min(rss) / 1024, 2) if rss else None,
841
+ "rssMaxMb": round(max(rss) / 1024, 2) if rss else None,
842
+ "rssGrowthMb": round(growth_mb, 2) if growth_mb is not None else None,
843
+ "tuiProcessTreeRssMinMb": round(min(tui_rss) / 1024, 2) if tui_rss else None,
844
+ "tuiProcessTreeRssMaxMb": round(max(tui_rss) / 1024, 2) if tui_rss else None,
845
+ "ownedServerProcessTreeRssMinMb": round(min(server_rss) / 1024, 2) if server_rss else None,
846
+ "ownedServerProcessTreeRssMaxMb": round(max(server_rss) / 1024, 2) if server_rss else None,
847
+ "serverPid": fixture.get("serverPid") if fixture else None,
848
+ "rssGrowthBudgetMb": args.memory_growth_limit_mb,
849
+ "rssGrowthBudgetMet": (growth_mb <= args.memory_growth_limit_mb
850
+ if growth_mb is not None and args.memory_growth_limit_mb is not None else None),
851
+ "rssScope": "native process plus descendants where /proc is available",
852
+ "acceptance": "diagnostic only; no product-defined sustained-memory ceiling exists",
853
+ "interpretation": "PTY bytes and short-window RSS are observations only; native status redraws are not assumed plugin defects"}
854
+ result["hostConditionsAtRunEnd"] = host_conditions()
855
+ result["overallTargetsMet"] = bool(result["input"]["targetMet"] and
856
+ result["warmSessionSwitch"]["targetMet"] is True and
857
+ result["input"]["finalEditorPlaceholderVerified"] and
858
+ (not fixture or fixture.get("pluginObservation", {}).get("observed") != "active" or
859
+ result["workspaceRender"]["targetMet"] is True))
860
+ text = json.dumps(result, indent=2)
861
+ print(text)
862
+ if args.report:
863
+ pathlib.Path(args.report).write_text(text + "\n")
864
+ if not result["overallTargetsMet"]:
865
+ raise SystemExit(1)
866
+ except BaseException as error:
867
+ if args.report:
868
+ result["runStatus"] = "failed"
869
+ result["runFailure"] = f"{type(error).__name__}: {error}"
870
+ pathlib.Path(args.report).write_text(json.dumps(result, indent=2) + "\n")
871
+ raise
872
+ finally:
873
+ signal.signal(signal.SIGTERM, previous_sigterm)
874
+ if child.poll() is None:
875
+ os.killpg(child.pid, signal.SIGTERM)
876
+ try:
877
+ child.wait(timeout=5)
878
+ except subprocess.TimeoutExpired:
879
+ os.killpg(child.pid, signal.SIGKILL)
880
+ child.wait()
881
+ os.close(master)
882
+ if owns_root:
883
+ shutil.rmtree(root, ignore_errors=True)
884
+
885
+
886
+ if __name__ == "__main__":
887
+ main()