carthagent 1.0.7 → 1.1.0-preview.12

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 (275) hide show
  1. package/README.md +80 -79
  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 +314 -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.patch +5137 -0
  57. package/docs/release-notes-native-draft.md +73 -0
  58. package/docs/release-notes-preview.10.md +121 -0
  59. package/docs/release-notes-preview.11.md +76 -0
  60. package/docs/release-notes-preview.12.md +55 -0
  61. package/docs/release-notes-preview.9.md +130 -0
  62. package/evaluate.mjs +28 -1
  63. package/lib/budget.mjs +1 -1
  64. package/lib/codegraph.mjs +1 -1
  65. package/lib/delivery.mjs +3 -2
  66. package/lib/environment.mjs +8 -0
  67. package/lib/guidance.mjs +1 -1
  68. package/lib/opencode/cli-runtime.mjs +1171 -0
  69. package/lib/opencode/cli.mjs +252 -0
  70. package/lib/opencode/credentials.mjs +63 -8
  71. package/lib/opencode/demo.mjs +53 -0
  72. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  73. package/lib/opencode/evaluation.mjs +32 -12
  74. package/lib/opencode/headless.mjs +8 -4
  75. package/lib/opencode/provider-policy.mjs +23 -0
  76. package/lib/opencode/review.mjs +37 -16
  77. package/lib/opencode/runtime-artifact.mjs +335 -0
  78. package/lib/opencode/runtime-github-transport.mjs +121 -0
  79. package/lib/opencode/runtime-release.json +36 -0
  80. package/lib/opencode/runtime.mjs +298 -20
  81. package/lib/opencode/service.mjs +77 -35
  82. package/lib/opencode/session-options.mjs +6 -2
  83. package/lib/opencode/update.mjs +222 -0
  84. package/lib/opencode/workspace.mjs +1 -1
  85. package/lib/planning-advice.mjs +2 -2
  86. package/lib/tui/framing.mjs +1 -80
  87. package/lib/update.mjs +10 -1
  88. package/package.json +33 -7
  89. package/plugins/carthagent/README.md +23 -0
  90. package/plugins/carthagent/src/bootstrap.ts +1 -1
  91. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  92. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  93. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  94. package/plugins/carthagent/src/index.ts +71 -24
  95. package/plugins/carthagent/src/rpc.ts +51 -6
  96. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  97. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  98. package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
  99. package/plugins/carthagent/src/server/delivery.ts +217 -106
  100. package/plugins/carthagent/src/server/framing.mjs +80 -0
  101. package/plugins/carthagent/src/server/policy.ts +1 -1
  102. package/plugins/carthagent/src/server/providers.ts +58 -30
  103. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  104. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  105. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  106. package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
  107. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  108. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  109. package/plugins/carthagent/src/testing.ts +5 -0
  110. package/plugins/carthagent/src/tui.tsx +492 -0
  111. package/plugins/carthagent/src/ui/client.ts +125 -0
  112. package/plugins/carthagent/src/ui/commands.ts +70 -0
  113. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  114. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  115. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  116. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  117. package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
  118. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  119. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  120. package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
  121. package/plugins/carthagent/src/ui/native-review.mjs +154 -0
  122. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  123. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  124. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  125. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  126. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  127. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  128. package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
  129. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  130. package/plugins/carthagent/src/ui/voyages.tsx +127 -0
  131. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  132. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  133. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  134. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  135. package/plugins/carthagent/tests/commands.test.ts +107 -0
  136. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  137. package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
  138. package/plugins/carthagent/themes/carthage.json +67 -0
  139. package/plugins/carthagent/tui.ts +26 -1
  140. package/script/ctg/build-owned-runtime.sh +166 -0
  141. package/tests/budget-engine.test.mjs +4 -3
  142. package/tests/cli.test.mjs +52 -96
  143. package/tests/daily-evidence.test.mjs +62 -0
  144. package/tests/delivery.test.mjs +22 -1
  145. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  146. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  147. package/tests/installed-runtime-contract.test.mjs +43 -0
  148. package/tests/native-cli-host-smoke.mjs +457 -0
  149. package/tests/native-daily-editor-focus.test.mjs +213 -0
  150. package/tests/native-daily-editor.test.mjs +43 -0
  151. package/tests/native-daily-quests-layout.test.mjs +53 -0
  152. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  153. package/tests/native-daily-quests-ui.test.mjs +778 -0
  154. package/tests/native-daily-source.test.mjs +426 -0
  155. package/tests/native-demo-ui.test.mjs +48 -0
  156. package/tests/native-evaluation-admission.test.mjs +24 -0
  157. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  158. package/tests/native-fixture-runtime.test.mjs +111 -0
  159. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  160. package/tests/native-glyph-pty.test.mjs +107 -0
  161. package/tests/native-manual-projects.test.mjs +170 -0
  162. package/tests/native-owned-runtime-env.test.mjs +140 -0
  163. package/tests/native-paired-guards.test.mjs +464 -0
  164. package/tests/native-provider-policy.test.mjs +31 -0
  165. package/tests/native-pty-invocation.test.mjs +114 -0
  166. package/tests/native-pty-runtime.test.mjs +45 -0
  167. package/tests/native-resume-session.test.mjs +46 -0
  168. package/tests/native-review-consumer.test.mjs +267 -0
  169. package/tests/native-runtime-identity.test.mjs +214 -0
  170. package/tests/native-ship-parity.test.mjs +526 -0
  171. package/tests/native-ship-plan.test.mjs +75 -0
  172. package/tests/native-ship-rpc.test.mjs +71 -0
  173. package/tests/native-ui-commands.test.mjs +6 -0
  174. package/tests/native-voyages-layout.test.mjs +28 -0
  175. package/tests/native-voyages-navigation.test.mjs +169 -0
  176. package/tests/native-voyages-rpc.test.mjs +395 -0
  177. package/tests/native-voyages-ui.test.mjs +277 -0
  178. package/tests/opencode-atomic-write.test.mjs +55 -0
  179. package/tests/opencode-cli.test.mjs +1312 -0
  180. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  181. package/tests/opencode-providers.test.mjs +245 -18
  182. package/tests/opencode-runtime.test.mjs +546 -8
  183. package/tests/opencode-session-controls.test.mjs +15 -0
  184. package/tests/opencode-ship-rpc.test.mjs +71 -0
  185. package/tests/opencode-update.test.mjs +475 -0
  186. package/tests/opencode-workspace.test.mjs +51 -2
  187. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  188. package/tests/release-workflow.test.mjs +46 -0
  189. package/tests/runtime-artifact.test.mjs +269 -0
  190. package/tests/runtime-development-trust.test.mjs +230 -0
  191. package/tests/runtime-github-transport.test.mjs +132 -0
  192. package/tests/runtime-token-env.test.mjs +23 -0
  193. package/tests/test_native_terminal_probe.py +76 -0
  194. package/tests/test_native_terminal_semantics.py +232 -0
  195. package/tests/update.test.mjs +36 -0
  196. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  197. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  198. package/tests/workspace-live.test.mjs +127 -0
  199. package/tools/benchmark/diagnose-request.mjs +59 -36
  200. package/tools/benchmark/eval-common.mjs +3 -3
  201. package/tools/benchmark/evaluators.test.mjs +4 -4
  202. package/tools/benchmark/guard.test.mjs +35 -2
  203. package/tools/benchmark/guard.ts +20 -7
  204. package/tools/benchmark/planning-startup.test.mjs +30 -34
  205. package/tools/benchmark/runner.mjs +12 -7
  206. package/tools/benchmark/runner.test.mjs +23 -7
  207. package/tools/benchmark/upstream-error.test.mjs +16 -2
  208. package/tools/generate-owned-runtime-release.mjs +217 -0
  209. package/tools/installed-runtime-contract.mjs +16 -0
  210. package/tools/manual-runtime-manifest.mjs +39 -0
  211. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  212. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  213. package/tools/native-fixture.mjs +199 -40
  214. package/tools/native-glyph-helpers.py +127 -0
  215. package/tools/native-glyph-pty.py +144 -0
  216. package/tools/native-manual-projects.mjs +259 -0
  217. package/tools/native-manual.mjs +338 -0
  218. package/tools/native-model-picker-acceptance.py +306 -0
  219. package/tools/native-owned-runtime-env.sh +47 -0
  220. package/tools/native-paired-guards.mjs +282 -0
  221. package/tools/native-paired-manifest.mjs +72 -0
  222. package/tools/native-paired-orchestration.sh +51 -0
  223. package/tools/native-plugin-readiness.mjs +33 -0
  224. package/tools/native-plugin-readiness.test.mjs +53 -0
  225. package/tools/native-pty-invocation.mjs +26 -0
  226. package/tools/native-pty-runtime.mjs +83 -0
  227. package/tools/native-pty-smoke.py +147 -24
  228. package/tools/native-public-repo-shell-result.mjs +13 -0
  229. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  230. package/tools/native-runtime-identity.mjs +201 -0
  231. package/tools/native-slash-acceptance-env.py +38 -0
  232. package/tools/native-terminal-semantics.py +876 -0
  233. package/tools/owned-shell-observer/index.ts +16 -0
  234. package/tools/owned-shell-observer/package.json +1 -0
  235. package/tools/owned-visual-clear-env.py +48 -0
  236. package/tools/owned-visual-clear-model.py +21 -0
  237. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  238. package/tools/prepare-native-manual.mjs +122 -0
  239. package/tools/run-native-terminal-paired.sh +403 -0
  240. package/tools/runtime-token-env.mjs +8 -0
  241. package/tools/seed-native-switch-fixture.mjs +79 -0
  242. package/tools/test-native-controls.mjs +3 -1
  243. package/tools/test-native-daily-quests.mjs +65 -0
  244. package/tools/test-native-default-cli.mjs +106 -0
  245. package/tools/test-native-delivery-host.mjs +100 -18
  246. package/tools/test-native-evaluation.mjs +7 -3
  247. package/tools/test-native-glyph-pty.mjs +94 -0
  248. package/tools/test-native-host.mjs +83 -61
  249. package/tools/test-native-outcomes.mjs +3 -1
  250. package/tools/test-native-package.mjs +206 -38
  251. package/tools/test-native-performance.mjs +26 -0
  252. package/tools/test-native-product.mjs +90 -0
  253. package/tools/test-native-pty.mjs +34 -9
  254. package/tools/test-native-public-repo.mjs +224 -0
  255. package/tools/test-native-refresh-reentry.mjs +106 -0
  256. package/tools/test-native-report.mjs +27 -20
  257. package/tools/test-native-review.mjs +75 -0
  258. package/tools/test-native-runtime.mjs +120 -0
  259. package/tools/test-native-ship-models.mjs +176 -0
  260. package/tools/test-native-ship-walkthrough.mjs +96 -0
  261. package/tools/test-native-slash-commands.mjs +125 -0
  262. package/tools/test-native-slash-commands.py +242 -0
  263. package/tools/test-native-swe-host.mjs +224 -55
  264. package/tools/test-native-tasks.mjs +77 -0
  265. package/tools/test-native-terminal-performance.py +887 -0
  266. package/tools/test-native-terminal-readiness.sh +85 -0
  267. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  268. package/tools/test-native-visual-clear-installed.mjs +48 -0
  269. package/tools/test-owned-host-controls.mjs +238 -0
  270. package/tools/test-owned-opencode-runtime.mjs +35 -0
  271. package/tools/test-owned-visual-clear-env.py +120 -0
  272. package/tools/test-owned-visual-clear.py +373 -0
  273. package/tools/verify-draft-runtime-gate.mjs +138 -0
  274. package/tools/verify-owned-runtime-release.mjs +114 -0
  275. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env python3
2
+ """Real PTY terminal-semantics probe for the pinned native OpenCode host.
3
+
4
+ The fixture provider is loopback-only. This intentionally tests an actual TUI
5
+ input/submit round trip, not the JavaScript key parser in isolation.
6
+ """
7
+ import argparse
8
+ import base64
9
+ import codecs
10
+ import fcntl
11
+ import errno
12
+ import hashlib
13
+ import http.server
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import pty
18
+ import re
19
+ import select
20
+ import signal
21
+ import subprocess
22
+ import socketserver
23
+ import struct
24
+ import tempfile
25
+ import termios
26
+ import threading
27
+ import time
28
+ from datetime import datetime, timezone
29
+
30
+
31
+ class Provider(http.server.BaseHTTPRequestHandler):
32
+ received = []
33
+ reply = 'FIXTURE_RESPONSE_OK'
34
+
35
+ def do_POST(self):
36
+ length = int(self.headers.get('content-length', '0'))
37
+ body = json.loads(self.rfile.read(length) or b'{}')
38
+ self.received.append(body)
39
+ def event(delta, finish_reason=None):
40
+ item = {'id': 'offline-fixture', 'object': 'chat.completion.chunk', 'created': 1,
41
+ 'model': 'fixture', 'choices': [{'index': 0, 'delta': delta,
42
+ 'finish_reason': finish_reason}]}
43
+ return b'data: ' + json.dumps(item).encode() + b'\n\n'
44
+ payload = (event({'role': 'assistant', 'content': self.reply}) +
45
+ event({}, 'stop') + b'data: [DONE]\n\n')
46
+ self.send_response(200)
47
+ self.send_header('content-type', 'text/event-stream')
48
+ self.send_header('cache-control', 'no-cache')
49
+ self.send_header('connection', 'close')
50
+ self.end_headers()
51
+ self.wfile.write(payload)
52
+
53
+ def log_message(self, *_args):
54
+ pass
55
+
56
+
57
+ class Screen:
58
+ """Small terminal host model for screen, title-stack, and OSC 52 state."""
59
+ ORIGINAL_TITLE = 'CTG-ORIGINAL-TITLE'
60
+ ORIGINAL_CLIPBOARD = 'CTG-SEEDED-CLIPBOARD'
61
+
62
+ def __init__(self, rows=32, columns=100, title=ORIGINAL_TITLE, clipboard=ORIGINAL_CLIPBOARD):
63
+ self.rows, self.columns = rows, columns
64
+ self.decoder = codecs.getincrementaldecoder('utf8')(errors='replace')
65
+ self.sync_open = False
66
+ self.queries = []
67
+ self.cells = [[' '] * columns for _ in range(rows)]
68
+ self.x = self.y = 0
69
+ self.mode = 'text'
70
+ self.csi = ''
71
+ self.csi_sequences = []
72
+ self.osc_escape = False
73
+ self.osc = ''
74
+ self.title = title
75
+ self.title_stack = []
76
+ self.title_history = []
77
+ self.title_controls = []
78
+ self.title_control_sequences = []
79
+ self.osc_title_updates = []
80
+ self.clipboard = clipboard
81
+ self.clipboard_history = []
82
+ self.clipboard_queries = []
83
+ self.rejected_clipboard_payloads = []
84
+ self.clipboard_readbacks = []
85
+ self.wrapCount = 0
86
+
87
+ def resize(self, rows, columns):
88
+ self.rows, self.columns = rows, columns
89
+ self.cells = [[' '] * columns for _ in range(rows)]
90
+ self.x = min(self.x, columns - 1)
91
+ self.y = min(self.y, rows - 1)
92
+
93
+ def feed(self, data):
94
+ text = self.decoder.decode(data)
95
+ for char in text:
96
+ if self.mode == 'osc':
97
+ if char == '\x07' or (self.osc_escape and char == '\\'):
98
+ self._osc(self.osc)
99
+ self.osc = ''
100
+ self.mode = 'text'
101
+ elif char != '\x1b':
102
+ self.osc += char
103
+ self.osc_escape = char == '\x1b'
104
+ continue
105
+ if self.mode == 'csi':
106
+ if '@' <= char <= '~':
107
+ if char == 't':
108
+ self.csi_sequences.append((self.csi, char))
109
+ self._csi(char, self.csi)
110
+ self.mode, self.csi = 'text', ''
111
+ else:
112
+ self.csi += char
113
+ continue
114
+ if self.mode == 'esc':
115
+ if char == '[':
116
+ self.mode, self.csi = 'csi', ''
117
+ elif char == ']':
118
+ self.mode = 'osc'
119
+ else:
120
+ self.mode = 'text'
121
+ continue
122
+ if char == '\x1b':
123
+ self.mode = 'esc'
124
+ elif char == '\r':
125
+ self.x = 0
126
+ elif char == '\n':
127
+ self.y = min(self.y + 1, self.rows - 1)
128
+ elif char == '\b':
129
+ self.x = max(0, self.x - 1)
130
+ elif char >= ' ':
131
+ if self.x >= self.columns:
132
+ self.wrapCount += 1
133
+ self.x, self.y = 0, min(self.y + 1, self.rows - 1)
134
+ self.cells[self.y][self.x] = char
135
+ self.x += 1
136
+
137
+ def _csi(self, final, params):
138
+ if params == '?2026' and final in ('h', 'l'):
139
+ self.sync_open = final == 'h'
140
+ if final == 'n' and params == '6':
141
+ self.queries.append(b'\x1b[1;1R')
142
+ if final == 'c' and params == '':
143
+ self.queries.append(b'\x1b[?1;2c')
144
+ values = [int(value) if value.isdigit() else 0 for value in params.lstrip('?').split(';')]
145
+ n = values[0] if values and values[0] else 1
146
+ if final in ('H', 'f'):
147
+ self.y = max(0, min(self.rows - 1, (values[0] if values and values[0] else 1) - 1))
148
+ self.x = max(0, min(self.columns - 1, (values[1] if len(values) > 1 and values[1] else 1) - 1))
149
+ elif final == 'A': self.y = max(0, self.y - n)
150
+ elif final == 'B': self.y = min(self.rows - 1, self.y + n)
151
+ elif final == 'C': self.x = min(self.columns - 1, self.x + n)
152
+ elif final == 'D': self.x = max(0, self.x - n)
153
+ elif final == 'K': self.cells[self.y][self.x:] = [' '] * (self.columns - self.x)
154
+ elif final == 'J' and values and values[0] == 2:
155
+ self.cells = [[' '] * self.columns for _ in range(self.rows)]
156
+ self.x = self.y = 0
157
+ elif final == 't' and values:
158
+ if values[0] == 22:
159
+ self.title_stack.append(self.title)
160
+ self.title_controls.append('save')
161
+ self.title_control_sequences.append(params)
162
+ elif values[0] == 23 and self.title_stack:
163
+ self.title = self.title_stack.pop()
164
+ self.title_history.append(self.title)
165
+ self.title_controls.append('restore')
166
+ self.title_control_sequences.append(params)
167
+
168
+ def _osc(self, value):
169
+ fields = value.split(';', 2)
170
+ if len(fields) >= 2 and fields[0] in ('0', '2'):
171
+ self.title = fields[-1]
172
+ self.title_history.append(self.title)
173
+ self.osc_title_updates.append({'kind': fields[0], 'title': self.title})
174
+ elif len(fields) == 3 and fields[0] == '52':
175
+ selection, encoded = fields[1], fields[2]
176
+ if selection != 'c':
177
+ self.rejected_clipboard_payloads.append({'reason': 'unsupported-selection', 'value': value})
178
+ return
179
+ if encoded == '?':
180
+ self.clipboard_queries.append(selection)
181
+ reply = f'\x1b]52;{selection};{base64.b64encode(self.clipboard.encode("utf8")).decode("ascii")}\x07'
182
+ self.clipboard_readbacks.append(reply)
183
+ return
184
+ try:
185
+ decoded = base64.b64decode(encoded, validate=True).decode('utf8')
186
+ except (ValueError, UnicodeDecodeError) as error:
187
+ self.rejected_clipboard_payloads.append({'reason': type(error).__name__, 'value': value})
188
+ return
189
+ self.clipboard = decoded
190
+ self.clipboard_history.append(decoded)
191
+
192
+ def query_clipboard(self):
193
+ before = len(self.clipboard_readbacks)
194
+ self.feed(b'\x1b]52;c;?\x07')
195
+ if len(self.clipboard_readbacks) != before + 1:
196
+ raise AssertionError('controlled host did not answer its OSC 52 clipboard query')
197
+ reply = self.clipboard_readbacks[-1]
198
+ fields = reply[2:-1].split(';', 2)
199
+ if len(fields) != 3 or fields[:2] != ['52', 'c']:
200
+ raise AssertionError('controlled host produced malformed OSC 52 clipboard query response')
201
+ try:
202
+ return base64.b64decode(fields[2], validate=True).decode('utf8')
203
+ except (ValueError, UnicodeDecodeError) as error:
204
+ raise AssertionError('controlled host produced invalid OSC 52 clipboard query payload') from error
205
+
206
+ def visible(self):
207
+ return '\n'.join(''.join(row).rstrip() for row in self.cells)
208
+
209
+
210
+ def controlled_terminal_model_fixture():
211
+ """Self-test host state semantics; this is not physical OS clipboard proof."""
212
+ host = Screen()
213
+ host.feed(b'\x1b]52;c;a\x07') # invalid base64 padding
214
+ host.feed(b'\x1b]52;c;@@@\x07')
215
+ host.feed(b'\x1b]52;c;/w==\x07') # valid base64, invalid UTF-8
216
+ rejected_before_valid = len(host.rejected_clipboard_payloads)
217
+ if host.clipboard != Screen.ORIGINAL_CLIPBOARD or rejected_before_valid != 3:
218
+ raise AssertionError('controlled host accepted malformed OSC 52 or modified seeded clipboard')
219
+
220
+ expected_selection = 'SNIP'
221
+ expected_response = 'FINAL[SNIP]END'
222
+ for text in (expected_selection, expected_response):
223
+ sequence = f'\x1b]52;c;{base64.b64encode(text.encode()).decode()}\x07'
224
+ host.feed(sequence.encode())
225
+ if host.clipboard != text:
226
+ raise AssertionError('controlled host did not apply a valid OSC 52 clipboard write')
227
+ if host.query_clipboard() != text:
228
+ raise AssertionError('controlled host OSC 52 query did not read back the exact clipboard value')
229
+ if host.clipboard_queries != ['c', 'c']:
230
+ raise AssertionError('controlled host did not record both clipboard readback queries')
231
+
232
+ host.feed(b'\x1b[22;0t\x1b]0;TEMP-TUI-TITLE\x07\x1b]2;ANOTHER-TUI-TITLE\x07\x1b[23;0t')
233
+ if host.title != Screen.ORIGINAL_TITLE or host.title_history[-1] != Screen.ORIGINAL_TITLE:
234
+ raise AssertionError('controlled host did not restore the seeded title from its title stack')
235
+ return {
236
+ 'status': 'MODEL_SELFTEST_PASS_NOT_NATIVE_ACCEPTANCE',
237
+ 'scope': 'parser/state-model self-test only; does not launch OpenCode or exercise native PTY input',
238
+ 'nativeAcceptance': 'NOT_RUN',
239
+ 'physicalOSClipboardVerified': False,
240
+ 'seedClipboardPreservedOnInvalidPayloads': True,
241
+ 'invalidPayloadsRejected': rejected_before_valid,
242
+ 'exactSelectionRoundTrip': expected_selection,
243
+ 'exactLatestCopyRoundTrip': expected_response,
244
+ 'clipboardQueries': len(host.clipboard_queries),
245
+ 'titleRestoredFromStack': host.title,
246
+ 'titleSaveRestoreOrder': host.title_controls,
247
+ }
248
+
249
+
250
+ def sha256_file(path):
251
+ digest = hashlib.sha256()
252
+ with open(path, 'rb') as source:
253
+ for chunk in iter(lambda: source.read(1024 * 1024), b''):
254
+ digest.update(chunk)
255
+ return digest.hexdigest()
256
+
257
+
258
+ def write_pty_input(master, data, deadline, on_output, child):
259
+ """Deliver every byte while draining output; never block past the phase deadline.
260
+
261
+ PTYs have bounded queues in both directions. A blocking large paste can
262
+ deadlock against the host's paint writes, even inside a timed outer loop.
263
+ Restore descriptor flags on every exit so the normal read/drain paths retain
264
+ their existing EOF handling. The caller owns child termination and fd cleanup.
265
+ """
266
+ flags = fcntl.fcntl(master, fcntl.F_GETFL)
267
+ pending = memoryview(data)
268
+ written = 0
269
+ try:
270
+ fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
271
+ while written < len(pending):
272
+ remaining = deadline - time.monotonic()
273
+ if remaining <= 0:
274
+ raise TimeoutError(f'PTY input deadline exceeded: {written}/{len(pending)} bytes written; '
275
+ f'child status={child.poll()}')
276
+ if child.poll() is not None:
277
+ raise RuntimeError(f'native host exited during PTY input: {child.returncode}; '
278
+ f'{written}/{len(pending)} bytes written')
279
+ try:
280
+ readable, writable, _ = select.select([master], [master], [], min(.05, remaining))
281
+ if readable:
282
+ try:
283
+ chunk = os.read(master, 65536)
284
+ except BlockingIOError:
285
+ chunk = None
286
+ if chunk == b'':
287
+ raise RuntimeError('PTY reached EOF during input')
288
+ if chunk:
289
+ on_output(chunk)
290
+ if writable:
291
+ try:
292
+ count = os.write(master, pending[written:])
293
+ except BlockingIOError:
294
+ continue
295
+ if count == 0:
296
+ raise RuntimeError('PTY input write made no progress')
297
+ written += count
298
+ except InterruptedError:
299
+ continue
300
+ except OSError as error:
301
+ if error.errno == errno.EIO:
302
+ raise RuntimeError(f'PTY closed during input: {written}/{len(pending)} bytes written; '
303
+ f'child status={child.poll()}') from error
304
+ raise
305
+ finally:
306
+ fcntl.fcntl(master, fcntl.F_SETFL, flags)
307
+
308
+
309
+ def drain_terminal_snapshot(master, screen, deadline, on_output, write_input, child):
310
+ """Consume queued output and finish an active paint before observing state.
311
+
312
+ No idle sleep or new timeout: an incomplete synchronized paint/control/UTF-8
313
+ sequence must complete within the caller's existing input deadline. Queries
314
+ parsed from every read (including duplex input reads) are answered exactly
315
+ once; chunk boundaries are not terminal protocol boundaries.
316
+ """
317
+ while True:
318
+ remaining = deadline - time.monotonic()
319
+ if remaining <= 0:
320
+ raise TimeoutError('coherent terminal snapshot exceeded existing input deadline')
321
+ if child.poll() is not None:
322
+ raise RuntimeError(f'native host exited during terminal snapshot: {child.returncode}')
323
+ if screen.queries:
324
+ write_input(screen.queries.pop(0))
325
+ continue
326
+ incomplete = screen.sync_open or screen.mode != 'text' or bool(screen.decoder.getstate()[0])
327
+ if not select.select([master], [], [], min(.05, remaining) if incomplete else 0)[0]:
328
+ if incomplete:
329
+ continue
330
+ return
331
+ chunk = os.read(master, 65536)
332
+ if not chunk:
333
+ raise RuntimeError('PTY reached EOF during terminal snapshot')
334
+ on_output(chunk)
335
+
336
+
337
+ def main():
338
+ parser = argparse.ArgumentParser()
339
+ parser.add_argument('--binary', default=os.environ.get('CTG_OPENCODE_BIN'))
340
+ parser.add_argument('--timeout', type=int, default=75)
341
+ parser.add_argument('--host-model-only', action='store_true',
342
+ help='run controlled terminal-host fixture without launching OpenCode')
343
+ parser.add_argument('--focused-host-roundtrip', action='store_true',
344
+ help='test short distinct selection/copy payloads and title restoration only')
345
+ parser.add_argument('--assertion-regression-only', action='store_true',
346
+ help='test picker return state and rendered narrow layout without large paste')
347
+ parser.add_argument('--picker-regression-only', action='store_true',
348
+ help='test Escape dismissal and restored home composer without sending a prompt')
349
+ parser.add_argument('--artifact', help='write the JSON result to this path as well as stdout')
350
+ args = parser.parse_args()
351
+ if args.host_model_only:
352
+ model_result = controlled_terminal_model_fixture()
353
+ rendered = json.dumps(model_result, ensure_ascii=False, indent=2) + '\n'
354
+ print(rendered, end='')
355
+ if args.artifact:
356
+ artifact_path = Path(args.artifact).resolve()
357
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
358
+ artifact_path.write_text(rendered, encoding='utf8')
359
+ return 0
360
+ if not args.binary:
361
+ parser.error('pass --binary or set CTG_OPENCODE_BIN to an owned native executable')
362
+ binary = os.path.abspath(args.binary)
363
+ if not os.path.isfile(binary):
364
+ parser.error(f'native binary does not exist: {binary}')
365
+ binary = os.path.realpath(binary)
366
+ binary_version = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=20)
367
+ if binary_version.returncode != 0 or '2.0.32' not in (binary_version.stdout + binary_version.stderr):
368
+ parser.error(f'owned executable must identify the OpenCode 2.0.32 line; got {binary_version.stdout.strip()!r}')
369
+ with open(binary, 'rb') as executable:
370
+ if executable.read(256).startswith(b'echo "Error: @opencode/cli'):
371
+ parser.error('native binary is the package postinstall placeholder, not an owned executable')
372
+
373
+ server = socketserver.ThreadingTCPServer(('127.0.0.1', 0), Provider)
374
+ threading.Thread(target=server.serve_forever, daemon=True).start()
375
+ port = server.server_address[1]
376
+ repo_root = Path(__file__).resolve().parents[1]
377
+ source_status = subprocess.run(['git', '-C', str(repo_root), 'status', '--porcelain', '--untracked-files=no'],
378
+ capture_output=True, text=True).stdout.strip()
379
+ source_commit = subprocess.run(['git', '-C', str(repo_root), 'rev-parse', 'HEAD'],
380
+ capture_output=True, text=True).stdout.strip()
381
+ binary_sha = sha256_file(binary)
382
+ result = {'startedAtUtc': datetime.now(timezone.utc).isoformat(),
383
+ 'case': ('focused-host-roundtrip' if args.focused_host_roundtrip else
384
+ 'layout-regression' if args.assertion_regression_only else
385
+ 'picker-regression' if args.picker_regression_only else 'full-native-probe'),
386
+ 'binary': binary, 'binaryResolvedPath': os.path.realpath(binary),
387
+ 'binarySha256': binary_sha, 'buildHash': binary_sha,
388
+ 'binaryVersion': (binary_version.stdout + binary_version.stderr).strip(),
389
+ 'probeSha256': sha256_file(__file__),
390
+ 'pluginSha256': sha256_file(repo_root / 'plugins' / 'carthagent' / 'src' / 'tui.tsx'),
391
+ 'sourceCommit': source_commit,
392
+ 'workingTreeDirty': bool(source_status),
393
+ 'sourceStatus': source_status,
394
+ 'sourceTreeSha256': hashlib.sha256(subprocess.run(
395
+ ['git', '-C', str(repo_root), 'diff', 'HEAD'], capture_output=True).stdout).hexdigest(),
396
+ 'sourceDiffDirty': subprocess.run(['git', '-C', str(repo_root), 'diff', '--quiet'],
397
+ capture_output=True).returncode != 0,
398
+ 'provider': 'loopback offline fixture',
399
+ 'terminal': {'initialRows': 32, 'initialColumns': 100,
400
+ 'resizeRows': None if args.focused_host_roundtrip or args.picker_regression_only else 12,
401
+ 'resizeColumns': None if args.focused_host_roundtrip or args.picker_regression_only else 40,
402
+ 'TERM': 'xterm-256color'},
403
+ 'controlledHostClipboardModel': True,
404
+ 'physicalOSClipboardVerified': False,
405
+ 'checks': {}}
406
+ with tempfile.TemporaryDirectory(prefix='ctg-terminal-semantics-') as temp:
407
+ root = os.path.abspath(temp)
408
+ project = os.path.join(root, 'project')
409
+ config = os.path.join(root, 'config', 'opencode')
410
+ for path in [project, config, os.path.join(root, 'home'), os.path.join(root, 'data'),
411
+ os.path.join(root, 'state'), os.path.join(root, 'cache')]:
412
+ os.makedirs(path, exist_ok=True)
413
+ with open(os.path.join(config, 'opencode.json'), 'w', encoding='utf8') as handle:
414
+ json.dump({'update': 'disable', 'model': 'ctgfixture/fixture',
415
+ 'providers': {'ctgfixture': {'name': 'Offline Fixture',
416
+ 'package': '@opencode/ai/providers/openai-compatible',
417
+ 'settings': {'baseURL': f'http://127.0.0.1:{port}/v1'},
418
+ 'models': {'fixture': {'name': 'Offline fixture', 'limit': {
419
+ 'context': 32000, 'output': 1024}}}}}}, handle)
420
+ env = {key: os.environ[key] for key in ('PATH', 'LANG', 'LC_ALL') if key in os.environ}
421
+ env.update({'HOME': os.path.join(root, 'home'), 'TERM': 'xterm-256color',
422
+ 'COLORTERM': 'truecolor', 'XDG_CONFIG_HOME': os.path.join(root, 'config'),
423
+ 'XDG_DATA_HOME': os.path.join(root, 'data'), 'XDG_STATE_HOME': os.path.join(root, 'state'),
424
+ 'XDG_CACHE_HOME': os.path.join(root, 'cache'), 'OPENCODE_DB': os.path.join(root, 'db.sqlite'),
425
+ 'OPENCODE_CONFIG_DIR': config, 'OPENCODE_CONFIG': os.path.join(config, 'opencode.json'),
426
+ 'OPENCODE_CONFIG_PROJECT_DISABLE': 'true'})
427
+ master, slave = pty.openpty()
428
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 32, 100, 0, 0))
429
+ child = subprocess.Popen([binary, '--standalone'], cwd=project, env=env,
430
+ stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
431
+ os.close(slave)
432
+ output = bytearray()
433
+ screen = Screen()
434
+ initial_title = screen.title
435
+ errors = bytearray()
436
+ start = time.monotonic()
437
+ input_deadline = start + args.timeout
438
+
439
+ def capture_output(chunk):
440
+ output.extend(chunk)
441
+ screen.feed(chunk)
442
+
443
+ def write_input(data):
444
+ write_pty_input(master, data, input_deadline, capture_output, child)
445
+
446
+ sent = False
447
+ resized = False
448
+ picker_started = False
449
+ picker_cancelled = False
450
+ picker_home_before = False
451
+ picker_home_restored = False
452
+ copy_invoked = False
453
+ copy_confirmed = False
454
+ selection_sent = False
455
+ selection_confirmed = False
456
+ selected_clipboard_count = 0
457
+ copy_clipboard_count = 0
458
+ auth_dismissed = False
459
+ # Text is intentionally multi-line, Unicode, and large enough to expose
460
+ # truncation/byte-boundary bugs. It is sent only as bracketed paste.
461
+ regression_case = args.assertion_regression_only
462
+ picker_only = args.picker_regression_only
463
+ layout_only = regression_case and not picker_only
464
+ focused_case = args.focused_host_roundtrip or regression_case or picker_only
465
+ resize_case = layout_only or (not focused_case and not args.picker_regression_only)
466
+ exact = ('focused-host-request' if focused_case else
467
+ 'native-pty: café 日本語 😀\n' + 'large-paste-λ-' * 1200 + '\nEND-PASTE-終')
468
+ fixture_response = 'FINAL[SNIP]END'
469
+ selection_target = 'SNIP'
470
+ payload = exact.encode('utf8')
471
+ Provider.received.clear()
472
+ Provider.reply = fixture_response
473
+ try:
474
+ while time.monotonic() - start < args.timeout:
475
+ if child.poll() is not None:
476
+ raise RuntimeError(f'native host exited early: {child.returncode}; {bytes(output[-3000:])!r}')
477
+ if select.select([master], [], [], .05)[0]:
478
+ try:
479
+ chunk = os.read(master, 65536)
480
+ except OSError as error:
481
+ if error.errno == errno.EIO and child.poll() is not None:
482
+ raise RuntimeError(f'native host exited {child.returncode}; '
483
+ f'transcript={bytes(output[-5000:])!r}') from error
484
+ raise
485
+ capture_output(chunk)
486
+ drain_terminal_snapshot(master, screen, input_deadline, capture_output, write_input, child)
487
+ elapsed = time.monotonic() - start
488
+ visible = screen.visible().lower()
489
+ visible_original = screen.visible()
490
+ if 'connection lost' in visible or 'reconnecting to the server' in visible:
491
+ raise RuntimeError(f'native host reconnect overlay during terminal acceptance: {visible_original!r}')
492
+ home_ready = 'shift+tab agents' in visible and 'ctrl+p commands' in visible
493
+ onboarding_visible = ('connect opencode go' in visible or
494
+ 'connect an integration to' in visible or
495
+ 'send prompts' in visible)
496
+ if focused_case and not auth_dismissed and onboarding_visible:
497
+ write_input(b'\x1b')
498
+ auth_dismissed = True
499
+ auth_dismissed_at = time.monotonic()
500
+ if focused_case and auth_dismissed and time.monotonic() - auth_dismissed_at > .4:
501
+ visible = screen.visible().lower()
502
+ home_ready = 'shift+tab agents' in visible and 'ctrl+p commands' in visible
503
+ if not focused_case and not picker_started and elapsed > 2 and home_ready:
504
+ # Ctrl+O opens OpenCode's recent sessions/projects menu.
505
+ # Ctrl+P is command completion on this runtime and must not
506
+ # be mistaken for a session picker.
507
+ write_input(b'\x0f')
508
+ picker_started = True
509
+ picker_home_before = home_ready
510
+ picker_at = time.monotonic()
511
+ if picker_started and not picker_cancelled and (
512
+ 'recent sessions' in visible or 'sessions and projects' in visible or
513
+ 'select session' in visible):
514
+ write_input(b'\x1b')
515
+ picker_cancelled = True
516
+ picker_heading = next(heading for heading in
517
+ ('recent sessions', 'sessions and projects', 'select session')
518
+ if heading in visible)
519
+ picker_cancelled_at = time.monotonic()
520
+ result['checks']['pickerEscapeCancel'] = {
521
+ 'status': 'PENDING', 'pickerObserved': True,
522
+ 'cancelKey': 'Escape', 'providerRequestsBeforePrompt': len(Provider.received)}
523
+ if picker_cancelled and result['checks'].get('pickerEscapeCancel', {}).get('status') == 'PENDING':
524
+ if time.monotonic() - picker_cancelled_at > .5:
525
+ still_open = picker_heading in screen.visible().lower()
526
+ picker_home_restored = ('shift+tab agents' in screen.visible().lower() and
527
+ 'ctrl+p commands' in screen.visible().lower())
528
+ no_provider_request = len(Provider.received) == 0
529
+ result['checks']['pickerEscapeCancel'] = {
530
+ 'status': 'PASS' if picker_home_before and picker_home_restored and not still_open and no_provider_request else 'FAIL',
531
+ 'pickerObserved': True, 'cancelKey': 'Escape',
532
+ 'dismissed': not still_open, 'composerRestored': picker_home_restored,
533
+ 'providerRequestsBeforePrompt': len(Provider.received),
534
+ 'noPromptProviderRequestBeforeAcceptance': no_provider_request}
535
+ if picker_only:
536
+ break
537
+ if picker_started and not picker_cancelled and time.monotonic() - picker_at > 3:
538
+ # Escape regardless, so a command completion/menu cannot
539
+ # linger into the subsequent prompt entry.
540
+ write_input(b'\x1b')
541
+ picker_cancelled = True
542
+ result['checks']['pickerEscapeCancel'] = {
543
+ 'status': 'UNSUPPORTED',
544
+ 'reason': 'native model picker did not expose a recognizable visible heading; '
545
+ 'Escape was sent to dismiss any command menu',
546
+ 'providerRequestsBeforePrompt': len(Provider.received)}
547
+ if not sent and not picker_only and (((not focused_case) and
548
+ picker_cancelled and
549
+ result['checks'].get('pickerEscapeCancel', {}).get('status') != 'PENDING' and
550
+ elapsed > 5 and home_ready) or
551
+ (args.focused_host_roundtrip and elapsed > 2 and home_ready) or
552
+ (layout_only and elapsed > 2 and home_ready)):
553
+ # Finish picker acceptance on the original home screen before
554
+ # resizing/pasting: its footer remains visible under the picker.
555
+ # Narrow terminal stress is an actual resize on the PTY.
556
+ if resize_case:
557
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0))
558
+ screen.resize(12, 40)
559
+ output_before_resize = len(output)
560
+ os.kill(child.pid, signal.SIGWINCH)
561
+ resized = True
562
+ time.sleep(.25)
563
+ write_input(b'\x1b[200~' + payload + b'\x1b[201~')
564
+ sent = True
565
+ pasted_at = time.monotonic()
566
+ if sent and time.monotonic() - pasted_at > 1.5 and not Provider.received:
567
+ # Home prompt's first Enter opens a session; submit staged
568
+ # prompt only after the session composer is visible.
569
+ write_input(b'\r')
570
+ first_enter_at = time.monotonic()
571
+ pasted_at = time.monotonic() + 1e6
572
+ if sent and not Provider.received and 'first_enter_at' in locals() and time.monotonic() - first_enter_at > .75:
573
+ write_input(b'\r')
574
+ first_enter_at = time.monotonic() + 1e6
575
+ if Provider.received and 'provider_received_at' not in locals():
576
+ provider_received_at = time.monotonic()
577
+ visible_lines = screen.visible().splitlines()
578
+ mouse_mode = (b'\x1b[?1000h' in output or b'\x1b[?1002h' in output or
579
+ b'\x1b[?1006h' in output)
580
+ # Native Markdown renders [SNIP] as SNIP in the response body;
581
+ # the literal bracketed fixture also appears in the session
582
+ # header, which must not be mistaken for selectable response.
583
+ response_rows = [index for index, line in enumerate(visible_lines, 1)
584
+ if ('FINAL' + selection_target + 'END' in line and
585
+ 1 <= index <= screen.rows - 4)]
586
+ response_settled = ('provider_received_at' in locals() and
587
+ time.monotonic() - provider_received_at > 1.0)
588
+ if (not selection_sent and response_rows and mouse_mode and response_settled):
589
+ target_row = response_rows[-1]
590
+ target_line = visible_lines[target_row - 1]
591
+ # Derive terminal cells from the original VT paint stream:
592
+ # the simple display model strips ANSI/does not model wide cells.
593
+ # For the ASCII fixture response on a single row, find the last
594
+ # matching occurrence and use the rendered cell substring.
595
+ start_col = target_line.rfind(selection_target) + 1
596
+ end_col = start_col + len(selection_target) - 1
597
+ selected_text = target_line[start_col - 1:end_col]
598
+ selected_clipboard_count = len(screen.clipboard_history)
599
+ selection_coordinates = {'startColumn': start_col, 'endColumn': end_col,
600
+ 'row': target_row, 'screenLine': target_line,
601
+ 'coordinateBasis': '1-based displayed terminal cells'}
602
+ # Send a real SGR press, held-button drag, and release as three
603
+ # separate PTY events, allowing the TUI event loop to process each.
604
+ for event in (f'\x1b[<0;{start_col};{target_row}M',
605
+ f'\x1b[<32;{end_col};{target_row}M',
606
+ f'\x1b[<0;{end_col};{target_row}m'):
607
+ write_input(event.encode())
608
+ time.sleep(.12)
609
+ selection_sent = True
610
+ selection_sent_at = time.monotonic()
611
+ response_observed = fixture_response.encode() in output
612
+ selection_values_now = screen.clipboard_history[selected_clipboard_count:]
613
+ selection_observed = (selection_sent and selection_confirmed and
614
+ selection_values_now == [selected_text])
615
+ if selection_observed and 'selection_readback_at_selection' not in locals():
616
+ selection_readback_at_selection = screen.query_clipboard()
617
+ if (Provider.received and not copy_invoked and
618
+ (response_observed or (args.focused_host_roundtrip and response_settled)) and
619
+ selection_observed and
620
+ not args.assertion_regression_only):
621
+ copy_clipboard_count = len(screen.clipboard_history)
622
+ write_input(b'/copy\r')
623
+ copy_invoked = True
624
+ copy_sent_at = time.monotonic()
625
+ if selection_sent and not selection_confirmed and time.monotonic() - selection_sent_at > .3:
626
+ selection_confirmed = True
627
+ if copy_invoked and not copy_confirmed and time.monotonic() - copy_sent_at > .75:
628
+ copy_confirmed = True
629
+ if Provider.received and copy_confirmed and time.monotonic() - provider_received_at > 2:
630
+ break
631
+ if not Provider.received and not picker_only:
632
+ raise RuntimeError('loopback provider received no request; TUI may not have accepted prompt; '
633
+ f'pickerStarted={picker_started}, pickerCancelled={picker_cancelled}, '
634
+ f'visible={screen.visible()[-1500:]!r}, transcript={bytes(output[-4000:])!r}')
635
+ bodies = Provider.received
636
+ if picker_only:
637
+ result['checks']['pickerEscapeCancel'] = result['checks'].get('pickerEscapeCancel', {
638
+ 'status': 'UNVERIFIED', 'reason': 'picker case did not complete observable Escape dismissal'})
639
+ result['checks']['promptSubmission'] = {
640
+ 'status': 'PASS' if not bodies and result['checks']['pickerEscapeCancel']['status'] == 'PASS' else 'FAIL',
641
+ 'providerRequests': len(bodies),
642
+ 'noPromptSentDuringPickerAcceptance': not bodies}
643
+ result['checks']['narrowLayout'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not resize terminal'}
644
+ result['checks']['focusedPromptTransport'] = {'status': 'NOT_RUN', 'reason': 'picker-only case intentionally submits no prompt'}
645
+ result['checks']['terminalTitleRestoration'] = {'status': 'NOT_RUN', 'reason': 'picker-only case exits without title acceptance'}
646
+ result['checks']['selectionClipboard'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not exercise selection'}
647
+ result['checks']['clipboardOSC52'] = {'status': 'NOT_RUN', 'reason': 'picker-only case does not exercise /copy'}
648
+ incomplete = [name for name, check in result['checks'].items()
649
+ if check['status'] not in ('PASS', 'NOT_RUN')]
650
+ result['status'] = 'INCOMPLETE' if incomplete else 'PASS'
651
+ result['incompleteChecks'] = incomplete
652
+ rendered = json.dumps(result, ensure_ascii=False, indent=2) + '\n'
653
+ print(rendered, end='')
654
+ if args.artifact:
655
+ artifact_path = Path(args.artifact).resolve()
656
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
657
+ artifact_path.write_text(rendered, encoding='utf8')
658
+ return 2 if incomplete else 0
659
+ else:
660
+ messages = bodies[-1].get('messages', [])
661
+ received_text = '\n'.join(str(part.get('text', '')) for message in messages
662
+ for part in (message.get('content', []) if isinstance(message.get('content'), list)
663
+ else [{'text': message.get('content', '')}]))
664
+ if not picker_only and exact not in received_text:
665
+ raise AssertionError(f'provider prompt differs: expected {len(exact)} chars, '
666
+ f'got {len(received_text)} chars')
667
+ matching_bodies = []
668
+ for body in bodies:
669
+ request_messages = body.get('messages', [])
670
+ request_text = '\n'.join(str(part.get('text', '')) for message in request_messages
671
+ for part in (message.get('content', []) if isinstance(message.get('content'), list)
672
+ else [{'text': message.get('content', '')}]))
673
+ if any(message.get('role') == 'user' and exact in str(message.get('content', ''))
674
+ for message in request_messages):
675
+ matching_bodies.append(body)
676
+ if not picker_only and not matching_bodies:
677
+ details = [[(message.get('role'), len(str(message.get('content', ''))),
678
+ exact in str(message.get('content', '')))
679
+ for message in body.get('messages', [])] for body in bodies]
680
+ raise AssertionError(f'expected exact user prompt, provider received {len(matching_bodies)} '
681
+ f'matching requests (total fixture requests {len(bodies)}): {details}')
682
+ if picker_only:
683
+ pass
684
+ elif focused_case:
685
+ result['checks']['focusedPromptTransport'] = {
686
+ 'status': 'PASS' if exact in received_text else 'FAIL',
687
+ 'exactProviderUserMessage': exact in received_text,
688
+ 'providerRequests': len(bodies)}
689
+ if args.focused_host_roundtrip:
690
+ result['checks']['pickerEscapeCancel'] = {'status': 'NOT_RUN', 'reason': 'focused terminal-host case intentionally skips picker scenario'}
691
+ result['checks']['narrowLayout'] = {'status': 'NOT_RUN', 'reason': 'focused terminal-host case intentionally skips resize scenario'}
692
+ else:
693
+ result['checks']['pickerEscapeCancel'] = result['checks'].get('pickerEscapeCancel', {
694
+ 'status': 'NOT_RUN', 'reason': 'focused layout regression case does not open picker'})
695
+ visible_after_resize = screen.visible()
696
+ narrow_content = fixture_response in visible_after_resize and '┃' in visible_after_resize
697
+ picker_absent = not any(heading in visible_after_resize.lower()
698
+ for heading in ('select model', 'models', 'search models'))
699
+ result['checks']['narrowLayout'] = {
700
+ 'status': 'PASS' if resized and screen.rows == 12 and screen.columns == 40 and
701
+ child.poll() is None and narrow_content and picker_absent and
702
+ all(len(line) <= screen.columns for line in visible_after_resize.splitlines()) else 'UNVERIFIED',
703
+ 'rows': screen.rows, 'columns': screen.columns, 'resizeRequested': resized,
704
+ 'hostAlive': child.poll() is None, 'fixtureResponseVisible': fixture_response in visible_after_resize,
705
+ 'composerVisible': '┃' in visible_after_resize, 'pickerAbsent': picker_absent,
706
+ 'visibleRowsWithinWidth': all(len(row) <= screen.columns for row in screen.cells),
707
+ 'visibleScreenExcerpt': visible_after_resize[-600:]}
708
+ else:
709
+ result['checks']['unicodeAndLargeBracketedPaste'] = {
710
+ 'status': 'PASS' if exact in received_text else 'FAIL', 'exactProviderText': exact in received_text,
711
+ 'characters': len(exact), 'utf8Bytes': len(payload),
712
+ 'matchingPromptRequests': len(matching_bodies), 'totalFixtureRequests': len(bodies)}
713
+ visible_after_resize = screen.visible()
714
+ narrow_content = fixture_response in visible_after_resize and '┃' in visible_after_resize
715
+ picker_absent = 'select model' not in visible_after_resize.lower()
716
+ result['checks']['narrowLayout'] = {
717
+ 'status': 'PASS' if resized and screen.rows == 12 and screen.columns == 40 and
718
+ child.poll() is None and narrow_content and picker_absent and
719
+ all(len(line) <= screen.columns for line in visible_after_resize.splitlines()) else 'UNVERIFIED',
720
+ 'rows': screen.rows, 'columns': screen.columns, 'resizeRequested': resized,
721
+ 'hostAlive': child.poll() is None, 'fixtureResponseVisible': fixture_response in visible_after_resize,
722
+ 'composerVisible': '┃' in visible_after_resize, 'pickerAbsent': picker_absent,
723
+ 'visibleRowsWithinWidth': all(len(row) <= screen.columns for row in screen.cells),
724
+ 'observedTerminalOutputBytes': len(output) - output_before_resize,
725
+ 'visibleScreenExcerpt': visible_after_resize[-600:]}
726
+ if child.poll() is not None:
727
+ raise AssertionError('host exited during narrow-layout probe')
728
+ # Selection is exercised by an SGR mouse drag. Screen applies OSC
729
+ # clipboard writes as a controlled host model, not an OS clipboard.
730
+ mouse_mode = b'\x1b[?1000h' in output or b'\x1b[?1002h' in output or b'\x1b[?1006h' in output
731
+ result['checks'].setdefault('pickerEscapeCancel', {
732
+ 'status': 'UNSUPPORTED',
733
+ 'reason': 'native home screen did not become interactive; no picker was opened'})
734
+ # Ask the TUI to exit through its own quit command so screen cleanup
735
+ # and title restoration run before inspecting final output.
736
+ exit_deadline = time.monotonic() + 5
737
+ input_deadline = exit_deadline
738
+ if child.poll() is None:
739
+ # Clear any remaining command/editor text, then request a normal
740
+ # TUI exit. Do not use a signal as a success-path substitute.
741
+ write_input(b'\x15/quit\r')
742
+ second_enter_sent = False
743
+ ctrl_c_sent = False
744
+ exit_actions = ['ctrl-u+quit+enter']
745
+ while child.poll() is None and time.monotonic() < exit_deadline:
746
+ remaining = exit_deadline - time.monotonic()
747
+ if not second_enter_sent and remaining < 4.2:
748
+ write_input(b'\r')
749
+ second_enter_sent = True
750
+ exit_actions.append('enter')
751
+ elif not ctrl_c_sent and remaining < 3.3:
752
+ # Ctrl-C is a TUI key in raw mode and calls the normal cleanup path.
753
+ write_input(b'\x03')
754
+ ctrl_c_sent = True
755
+ exit_actions.append('ctrl-c')
756
+ if select.select([master], [], [], .05)[0]:
757
+ try: chunk = os.read(master, 65536)
758
+ except OSError as error:
759
+ if error.errno == errno.EIO: break
760
+ raise
761
+ output.extend(chunk)
762
+ screen.feed(chunk)
763
+ exit_request_sent = False
764
+ try:
765
+ child.wait(timeout=max(1, exit_deadline - time.monotonic()))
766
+ except subprocess.TimeoutExpired:
767
+ exit_request_sent = True
768
+ os.killpg(child.pid, signal.SIGTERM)
769
+ child.wait(timeout=2)
770
+ exit_code = child.returncode
771
+ # A PTY may still contain delayed teardown writes after child exit.
772
+ # Drain to EOF, bounded, before judging title restoration.
773
+ drain_deadline = time.monotonic() + 1.5
774
+ pty_eof = False
775
+ while time.monotonic() < drain_deadline and not pty_eof:
776
+ if not select.select([master], [], [], .05)[0]:
777
+ continue
778
+ try:
779
+ chunk = os.read(master, 65536)
780
+ except OSError as error:
781
+ if error.errno == errno.EIO:
782
+ pty_eof = True
783
+ break
784
+ raise
785
+ if not chunk:
786
+ pty_eof = True
787
+ break
788
+ output.extend(chunk)
789
+ screen.feed(chunk)
790
+ title_pushes = screen.title_controls.count('save')
791
+ title_pops = screen.title_controls.count('restore')
792
+ save_index = screen.title_controls.index('save') if 'save' in screen.title_controls else -1
793
+ restore_index = screen.title_controls.index('restore') if 'restore' in screen.title_controls else -1
794
+ title_round_trip = (title_pushes > 0 and title_pops > 0 and not screen.title_stack and
795
+ save_index >= 0 and restore_index > save_index and
796
+ screen.title == initial_title and initial_title in screen.title_history and
797
+ exit_code == 0 and not exit_request_sent and pty_eof)
798
+ if restore_index >= 0:
799
+ restored_title = screen.title_history[-1] if screen.title_history else None
800
+ title_round_trip = title_round_trip and restored_title == initial_title
801
+ result['checks']['terminalTitleRestoration'] = {
802
+ 'status': 'PASS' if title_round_trip else 'FAIL',
803
+ 'reason': ('Controlled terminal host restored the seeded original title after normal TUI exit' if title_round_trip else
804
+ 'Normal exit completed, but captured terminal output did not restore the seeded title through an ordered VT title-stack round trip'),
805
+ 'controlledHostModel': True,
806
+ 'normalExitCode': exit_code,
807
+ 'exitActions': exit_actions,
808
+ 'forcedTerminationAfterExitRequestTimeout': exit_request_sent,
809
+ 'ptyDrainedToEof': pty_eof,
810
+ 'titleSaveControls': title_pushes,
811
+ 'titleRestoreControls': title_pops,
812
+ 'titleControlOrder': screen.title_controls,
813
+ 'modeledTitleStackDepth': len(screen.title_stack),
814
+ 'titleControlsObserved': screen.title_controls,
815
+ 'titleControlSequences': screen.title_control_sequences,
816
+ 'initialTitle': initial_title,
817
+ 'finalTitle': screen.title,
818
+ 'titleSequenceObserved': screen.title_history,
819
+ 'oscTitleUpdates': screen.osc_title_updates}
820
+ selection_values = screen.clipboard_history[selected_clipboard_count:copy_clipboard_count if copy_invoked else None] if selection_sent else []
821
+ selection_readback = locals().get('selection_readback_at_selection')
822
+ selection_model_pass = (selection_sent and selected_text == selection_target and
823
+ selection_values == [selection_target] and
824
+ selection_readback == selection_target)
825
+ result['checks']['selectionClipboard'] = {
826
+ 'status': 'PASS' if selection_model_pass else 'UNVERIFIED',
827
+ 'reason': ('Controlled host model accepted and queried OSC 52 containing only the exact selected substring; this is not physical OS clipboard proof' if selection_sent and selection_values == [selected_text] and selection_readback == selected_text
828
+ else 'Native selection gesture did not yield an exact selection OSC 52 update and modeled readback'),
829
+ 'mouseReporting': mouse_mode, 'mouseDragSent': selection_sent,
830
+ 'fixtureResponseInCapturedPty': fixture_response.encode() in output,
831
+ 'fixtureResponseInScreenModel': fixture_response in screen.visible(),
832
+ 'screenExcerpt': screen.visible()[-500:],
833
+ 'selectedSubstring': selected_text if selection_sent else None,
834
+ 'mouseCoordinates': selection_coordinates if selection_sent else None,
835
+ 'controlledHostClipboardUpdates': selection_values,
836
+ 'clipboardUpdateDeltaBeforeCopy': selection_values,
837
+ 'controlledHostClipboardReadback': selection_readback,
838
+ 'physicalOSClipboardVerified': False}
839
+ copy_values = screen.clipboard_history[copy_clipboard_count:] if copy_invoked else []
840
+ copy_readback = screen.query_clipboard() if copy_invoked and copy_values else None
841
+ copy_model_pass = (copy_invoked and copy_values == [fixture_response] and
842
+ copy_readback == fixture_response)
843
+ result['checks']['clipboardOSC52'] = {
844
+ 'status': 'PASS' if copy_model_pass else 'UNVERIFIED',
845
+ 'reason': 'Controlled host model accepted and queried OSC 52 with the exact distinct latest fixture response; this is not physical OS clipboard proof' if copy_invoked and copy_values == [fixture_response] and copy_readback == fixture_response else 'No exact new OSC 52 clipboard value and readback observed after /copy',
846
+ 'copyActionSent': copy_invoked,
847
+ 'controlledHostClipboardUpdates': copy_values,
848
+ 'clipboardUpdateDeltaAfterSelection': copy_values,
849
+ 'controlledHostClipboardReadback': copy_readback,
850
+ 'physicalOSClipboardVerified': False}
851
+ incomplete = [name for name, check in result['checks'].items()
852
+ if check['status'] != 'PASS']
853
+ result['status'] = 'INCOMPLETE' if incomplete else 'PASS'
854
+ result['incompleteChecks'] = incomplete
855
+ rendered = json.dumps(result, ensure_ascii=False, indent=2) + '\n'
856
+ print(rendered, end='')
857
+ if args.artifact:
858
+ artifact_path = Path(args.artifact).resolve()
859
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
860
+ artifact_path.write_text(rendered, encoding='utf8')
861
+ return 2 if incomplete else 0
862
+ finally:
863
+ if child.poll() is None:
864
+ os.killpg(child.pid, signal.SIGTERM)
865
+ try:
866
+ child.wait(timeout=5)
867
+ except subprocess.TimeoutExpired:
868
+ os.killpg(child.pid, signal.SIGKILL)
869
+ child.wait()
870
+ os.close(master)
871
+ server.shutdown()
872
+ server.server_close()
873
+
874
+
875
+ if __name__ == '__main__':
876
+ raise SystemExit(main())