carthagent 0.0.0-stage → 1.0.7

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 (379) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -2
  3. package/bin/ctg.mjs +736 -0
  4. package/docs/budgets.md +72 -0
  5. package/docs/compaction.md +34 -0
  6. package/docs/context-efficiency-plan.md +24 -0
  7. package/docs/context-efficiency-review.md +27 -0
  8. package/docs/context-efficiency.md +49 -0
  9. package/docs/deepseek-jev-benchmark-plan.md +70 -0
  10. package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
  11. package/docs/deepseek-jev-benchmark-restart.md +9 -0
  12. package/docs/deepseek-jev-benchmark-results.md +77 -0
  13. package/docs/deepseek-jev-benchmark-running.md +60 -0
  14. package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
  15. package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
  16. package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
  17. package/docs/deepseek-jev-http400-diagnosis.md +45 -0
  18. package/docs/delivery-review-evidence.md +53 -0
  19. package/docs/delivery-timeout-repair-plan.md +51 -0
  20. package/docs/evaluation-readiness.md +31 -0
  21. package/docs/evaluation.md +70 -0
  22. package/docs/extending.md +354 -0
  23. package/docs/glm-pilot-execution.md +9 -0
  24. package/docs/glm-pilot-plan.md +15 -0
  25. package/docs/glm-pilot-results.md +71 -0
  26. package/docs/graph-context-optimization.md +15 -0
  27. package/docs/jev-repository-pilot-plan.md +25 -0
  28. package/docs/jev-repository-pilot-protocol.md +39 -0
  29. package/docs/jev-repository-pilot-restart.md +7 -0
  30. package/docs/jev-repository-pilot-results.md +79 -0
  31. package/docs/jev.md +32 -0
  32. package/docs/model-registry.md +25 -0
  33. package/docs/native-compatibility.md +117 -0
  34. package/docs/opencode-archive.md +9 -0
  35. package/docs/opencode-migration.md +115 -0
  36. package/docs/opencode-runtime.md +94 -0
  37. package/docs/opencode-workspace.md +31 -0
  38. package/docs/owned-runtime-workflow-registration.md +17 -0
  39. package/docs/plan-advisor-evaluation.md +38 -0
  40. package/docs/planning-startup-fix-plan.md +23 -0
  41. package/docs/planning-startup-fix-review.md +24 -0
  42. package/docs/planning.md +118 -0
  43. package/docs/production-merge-review.md +23 -0
  44. package/docs/quality.md +35 -0
  45. package/docs/repository-benchmark-design.md +111 -0
  46. package/docs/review-assurance.md +9 -0
  47. package/docs/review-experiment-results.md +59 -0
  48. package/docs/tested-planning-roadmap.md +58 -0
  49. package/docs/tested-planning.md +58 -0
  50. package/docs/tui-pickers.md +9 -0
  51. package/docs/webdriver.md +83 -0
  52. package/docs/workflow-modes.md +26 -0
  53. package/docs/workflow.d2 +17 -0
  54. package/docs/workflow.svg +149 -0
  55. package/docs/workspace-coordination.md +9 -0
  56. package/evaluate.mjs +249 -0
  57. package/extensions/codegraph.ts +36 -0
  58. package/extensions/delivery.ts +819 -0
  59. package/extensions/experiential.ts +21 -0
  60. package/extensions/subscriptions.ts +21 -0
  61. package/guidance/profiles/authenticated-web.json +24 -0
  62. package/guidance/profiles/data-visualization.json +25 -0
  63. package/guidance/profiles/database-change.json +24 -0
  64. package/guidance/profiles/external-api.json +24 -0
  65. package/guidance/profiles/transactional-data.json +25 -0
  66. package/guidance/profiles/web-application.json +26 -0
  67. package/lib/acceptance-review.mjs +4 -0
  68. package/lib/browser-bin.mjs +160 -0
  69. package/lib/browser-errors.mjs +54 -0
  70. package/lib/browser.mjs +219 -0
  71. package/lib/budget.mjs +37 -0
  72. package/lib/cdp.mjs +221 -0
  73. package/lib/cloud/client.mjs +275 -0
  74. package/lib/cloud/control-plane.mjs +350 -0
  75. package/lib/cloud/gateway.mjs +380 -0
  76. package/lib/cloud/http.mjs +135 -0
  77. package/lib/cloud/ledger.mjs +215 -0
  78. package/lib/codegraph-query.mjs +60 -0
  79. package/lib/codegraph.mjs +378 -0
  80. package/lib/context-archive.mjs +18 -0
  81. package/lib/delivery-context.mjs +132 -0
  82. package/lib/delivery-guidance.mjs +9 -0
  83. package/lib/delivery-review.mjs +94 -0
  84. package/lib/delivery.mjs +610 -0
  85. package/lib/engine.mjs +137 -0
  86. package/lib/guidance.mjs +71 -0
  87. package/lib/jev-advisor.mjs +47 -0
  88. package/lib/opencode/archive.mjs +122 -0
  89. package/lib/opencode/compatibility.mjs +19 -0
  90. package/lib/opencode/credentials.mjs +205 -0
  91. package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
  92. package/lib/opencode/evaluation.mjs +242 -0
  93. package/lib/opencode/file-lock.mjs +86 -0
  94. package/lib/opencode/headless.mjs +200 -0
  95. package/lib/opencode/review.mjs +213 -0
  96. package/lib/opencode/runtime.mjs +246 -0
  97. package/lib/opencode/service.mjs +249 -0
  98. package/lib/opencode/session-options.mjs +60 -0
  99. package/lib/opencode/workspace.mjs +133 -0
  100. package/lib/openrouter-jev.mjs +37 -0
  101. package/lib/outcome-contract.mjs +21 -0
  102. package/lib/plan-advisor.mjs +38 -0
  103. package/lib/planning-access.mjs +7 -0
  104. package/lib/planning-advice.mjs +28 -0
  105. package/lib/planning-discovery.mjs +14 -0
  106. package/lib/planning-guide.mjs +34 -0
  107. package/lib/planning-view.mjs +57 -0
  108. package/lib/planning.mjs +155 -0
  109. package/lib/probe-diagnostics.mjs +13 -0
  110. package/lib/providers/experiential.mjs +292 -0
  111. package/lib/providers/model-catalog.json +700 -0
  112. package/lib/providers/model-catalog.mjs +7 -0
  113. package/lib/providers/names.mjs +13 -0
  114. package/lib/providers/subscription-models.mjs +483 -0
  115. package/lib/reports.mjs +80 -0
  116. package/lib/review-state.mjs +49 -0
  117. package/lib/review.mjs +114 -0
  118. package/lib/scenarios.mjs +99 -0
  119. package/lib/ship-auth.mjs +44 -0
  120. package/lib/ship-jev.mjs +59 -0
  121. package/lib/ship-telemetry.mjs +183 -0
  122. package/lib/tui/ansi.mjs +255 -0
  123. package/lib/tui/app.mjs +2445 -0
  124. package/lib/tui/auth.mjs +249 -0
  125. package/lib/tui/carthage.mjs +109 -0
  126. package/lib/tui/clipboard.mjs +122 -0
  127. package/lib/tui/d2.mjs +431 -0
  128. package/lib/tui/feed.mjs +467 -0
  129. package/lib/tui/framing.mjs +80 -0
  130. package/lib/tui/glyphs.mjs +172 -0
  131. package/lib/tui/hash-worker.mjs +11 -0
  132. package/lib/tui/mock-engine.mjs +222 -0
  133. package/lib/tui/models.mjs +73 -0
  134. package/lib/tui/picker.mjs +18 -0
  135. package/lib/tui/theme.mjs +663 -0
  136. package/lib/tui/title.mjs +7 -0
  137. package/lib/update-channel.mjs +62 -0
  138. package/lib/update.mjs +230 -0
  139. package/lib/webdriver.mjs +82 -0
  140. package/lib/workflow.mjs +88 -0
  141. package/lib/workspace.mjs +39 -0
  142. package/metadata.json +8 -0
  143. package/package.json +94 -4
  144. package/plugins/carthagent/index.ts +3 -0
  145. package/plugins/carthagent/package.json +28 -0
  146. package/plugins/carthagent/src/archive-rpc.ts +46 -0
  147. package/plugins/carthagent/src/bootstrap.ts +38 -0
  148. package/plugins/carthagent/src/index.ts +62 -0
  149. package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
  150. package/plugins/carthagent/src/rpc.ts +76 -0
  151. package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
  152. package/plugins/carthagent/src/server/budgets.ts +21 -0
  153. package/plugins/carthagent/src/server/codegraph.ts +46 -0
  154. package/plugins/carthagent/src/server/delivery.ts +488 -0
  155. package/plugins/carthagent/src/server/device-auth.ts +91 -0
  156. package/plugins/carthagent/src/server/native-delivery.ts +26 -0
  157. package/plugins/carthagent/src/server/orchestration.ts +56 -0
  158. package/plugins/carthagent/src/server/policy.ts +24 -0
  159. package/plugins/carthagent/src/server/providers.ts +424 -0
  160. package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
  161. package/plugins/carthagent/src/server/session-controls.ts +82 -0
  162. package/plugins/carthagent/src/server/telemetry.ts +92 -0
  163. package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
  164. package/plugins/carthagent/src/session-rpc.ts +31 -0
  165. package/plugins/carthagent/src/testing.ts +85 -0
  166. package/plugins/carthagent/src/workspace-rpc.ts +133 -0
  167. package/plugins/carthagent/test-plugin/index.ts +4 -0
  168. package/plugins/carthagent/test-plugin/package.json +1 -0
  169. package/plugins/carthagent/tsconfig.json +19 -0
  170. package/plugins/carthagent/tui.ts +1 -0
  171. package/prompts/delivery.md +7 -0
  172. package/public/index.html +983 -0
  173. package/scenarios/cli/grade_cli.py +69 -0
  174. package/scenarios/cli/scenario.json +21 -0
  175. package/scenarios/game/baseline/minecraft.py +193 -0
  176. package/scenarios/game/context.md +1 -0
  177. package/scenarios/game/grade_minecraft_clone.py +603 -0
  178. package/scenarios/game/requirements.txt +2 -0
  179. package/scenarios/game/scenario.json +33 -0
  180. package/server.mjs +341 -0
  181. package/skills/game-development/SKILL.md +36 -0
  182. package/skills/game-development/assets/ursina_starter.py +98 -0
  183. package/skills/game-development/scripts/verify_ursina.py +80 -0
  184. package/skills/software-delivery/SKILL.md +50 -0
  185. package/skills/typescript-delivery/SKILL.md +69 -0
  186. package/tests/acceptance-review.test.mjs +13 -0
  187. package/tests/auth.test.mjs +234 -0
  188. package/tests/bounded-summary.test.mjs +68 -0
  189. package/tests/browser-bin.test.mjs +91 -0
  190. package/tests/browser.test.mjs +96 -0
  191. package/tests/budget-engine.test.mjs +110 -0
  192. package/tests/budget.test.mjs +23 -0
  193. package/tests/cli.test.mjs +245 -0
  194. package/tests/clipboard.test.mjs +79 -0
  195. package/tests/cloud-client.test.mjs +129 -0
  196. package/tests/cloud-control-plane.test.mjs +144 -0
  197. package/tests/cloud-gateway.test.mjs +147 -0
  198. package/tests/cloud-http.test.mjs +102 -0
  199. package/tests/cloud-ledger.test.mjs +75 -0
  200. package/tests/codegraph-query.test.mjs +66 -0
  201. package/tests/codegraph.test.mjs +142 -0
  202. package/tests/delivery-context.test.mjs +66 -0
  203. package/tests/delivery-review.test.mjs +139 -0
  204. package/tests/delivery.test.mjs +343 -0
  205. package/tests/engine-api.mjs +24 -0
  206. package/tests/experiential-extension.test.mjs +37 -0
  207. package/tests/experiential.test.mjs +300 -0
  208. package/tests/extension.test.mjs +921 -0
  209. package/tests/fixtures/archive-plugin/index.ts +10 -0
  210. package/tests/guidance.test.mjs +78 -0
  211. package/tests/model-registry.test.mjs +68 -0
  212. package/tests/models.test.mjs +70 -0
  213. package/tests/openai-idempotency.test.mjs +99 -0
  214. package/tests/opencode-archive-rpc.test.mjs +82 -0
  215. package/tests/opencode-delivery.test.mjs +231 -0
  216. package/tests/opencode-providers.test.mjs +414 -0
  217. package/tests/opencode-runtime.test.mjs +455 -0
  218. package/tests/opencode-session-controls.test.mjs +115 -0
  219. package/tests/opencode-session-options.test.mjs +26 -0
  220. package/tests/opencode-workspace.test.mjs +339 -0
  221. package/tests/outcome-contract.test.mjs +17 -0
  222. package/tests/picker.test.mjs +34 -0
  223. package/tests/plan-advisor.test.mjs +88 -0
  224. package/tests/plan-review-loop.test.mjs +53 -0
  225. package/tests/planning-advice.test.mjs +27 -0
  226. package/tests/planning-contract.test.mjs +65 -0
  227. package/tests/planning-startup.test.mjs +38 -0
  228. package/tests/planning-view.test.mjs +33 -0
  229. package/tests/planning.test.mjs +223 -0
  230. package/tests/probe-diagnostics.test.mjs +21 -0
  231. package/tests/pull-request-rpc.test.mjs +366 -0
  232. package/tests/review-state.test.mjs +32 -0
  233. package/tests/review.test.mjs +127 -0
  234. package/tests/server.test.mjs +252 -0
  235. package/tests/ship-jev.test.mjs +77 -0
  236. package/tests/ship-telemetry.test.mjs +333 -0
  237. package/tests/standard-workflow.test.mjs +28 -0
  238. package/tests/subscription-models.test.mjs +416 -0
  239. package/tests/subscriptions-extension.test.mjs +27 -0
  240. package/tests/terminal-title.test.mjs +13 -0
  241. package/tests/test_grade_minecraft_clone.py +199 -0
  242. package/tests/test_starter.py +78 -0
  243. package/tests/theme.test.mjs +175 -0
  244. package/tests/tui-copy-paste.test.mjs +54 -0
  245. package/tests/tui.test.mjs +808 -0
  246. package/tests/update-channels.test.mjs +129 -0
  247. package/tests/update.test.mjs +175 -0
  248. package/tests/webdriver.test.mjs +31 -0
  249. package/tests/workflow.test.mjs +47 -0
  250. package/tests/workspace-rpc.test.mjs +236 -0
  251. package/tests/workspace.test.mjs +24 -0
  252. package/tools/benchmark/advisor.test.mjs +7 -0
  253. package/tools/benchmark/advisor.ts +27 -0
  254. package/tools/benchmark/campaign.mjs +45 -0
  255. package/tools/benchmark/diagnose-request.mjs +40 -0
  256. package/tools/benchmark/eval-common.mjs +32 -0
  257. package/tools/benchmark/eval-ledger.mjs +22 -0
  258. package/tools/benchmark/eval-shop.mjs +26 -0
  259. package/tools/benchmark/eval-voxel.mjs +20 -0
  260. package/tools/benchmark/evaluate.mjs +24 -0
  261. package/tools/benchmark/evaluators.test.mjs +13 -0
  262. package/tools/benchmark/fixtures/shop/server.mjs +18 -0
  263. package/tools/benchmark/fixtures/shop/ui.html +8 -0
  264. package/tools/benchmark/freeze.mjs +30 -0
  265. package/tools/benchmark/gateway.mjs +91 -0
  266. package/tools/benchmark/gateway.test.mjs +38 -0
  267. package/tools/benchmark/generator.mjs +11 -0
  268. package/tools/benchmark/generator.test.mjs +26 -0
  269. package/tools/benchmark/guard.test.mjs +133 -0
  270. package/tools/benchmark/guard.ts +136 -0
  271. package/tools/benchmark/native-plugin/index.ts +24 -0
  272. package/tools/benchmark/planning-startup.test.mjs +47 -0
  273. package/tools/benchmark/preflight-live.mjs +31 -0
  274. package/tools/benchmark/preflight.mjs +22 -0
  275. package/tools/benchmark/replay-diagnostic.mjs +23 -0
  276. package/tools/benchmark/runner.mjs +141 -0
  277. package/tools/benchmark/runner.test.mjs +93 -0
  278. package/tools/benchmark/runtime.mjs +23 -0
  279. package/tools/benchmark/selftest.mjs +47 -0
  280. package/tools/benchmark/smoke-jev.mjs +5 -0
  281. package/tools/benchmark/tasks.mjs +20 -0
  282. package/tools/benchmark/transport.mjs +65 -0
  283. package/tools/benchmark/transport.test.mjs +44 -0
  284. package/tools/benchmark/upstream-error.mjs +18 -0
  285. package/tools/benchmark/upstream-error.test.mjs +10 -0
  286. package/tools/browser-check.mjs +109 -0
  287. package/tools/check-syntax.mjs +27 -0
  288. package/tools/evaluate-plan-advisor.mjs +20 -0
  289. package/tools/native-fixture.mjs +194 -0
  290. package/tools/native-pty-smoke.py +108 -0
  291. package/tools/native-report-plugin/index.ts +50 -0
  292. package/tools/native-report-plugin/package.json +7 -0
  293. package/tools/patch-compaction.mjs +24 -0
  294. package/tools/patch-model-catalog.mjs +12 -0
  295. package/tools/patch-print-shutdown.mjs +39 -0
  296. package/tools/repo-pilot/evaluator.cjs +32 -0
  297. package/tools/repo-pilot/reference.mjs +43 -0
  298. package/tools/repo-pilot/runner.mjs +85 -0
  299. package/tools/repo-pilot/sandbox-entry.mjs +12 -0
  300. package/tools/repo-pilot/tasks.mjs +20 -0
  301. package/tools/repo-pilot/type-evaluator.cjs +6 -0
  302. package/tools/test-native-controls.mjs +84 -0
  303. package/tools/test-native-delivery-host.mjs +307 -0
  304. package/tools/test-native-evaluation.mjs +46 -0
  305. package/tools/test-native-host.mjs +385 -0
  306. package/tools/test-native-outcomes.mjs +73 -0
  307. package/tools/test-native-package.mjs +70 -0
  308. package/tools/test-native-pty.mjs +20 -0
  309. package/tools/test-native-report.mjs +88 -0
  310. package/tools/test-native-swe-host.mjs +132 -0
  311. package/tools/test-native-swe.mjs +122 -0
  312. package/tools/test-picker-pty.py +45 -0
  313. package/tools/test-python.mjs +29 -0
  314. package/tools/test-webdriver.mjs +54 -0
  315. package/tools/verify-release-version.mjs +37 -0
  316. package/tools/webdriver-check.mjs +52 -0
  317. package/vendor/agent/bounded-summary.mjs +88 -0
  318. package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
  319. package/vendor/agent/chunks/anthropic.js +75 -0
  320. package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
  321. package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
  322. package/vendor/agent/chunks/chord-context.js +96 -0
  323. package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
  324. package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
  325. package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
  326. package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
  327. package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
  328. package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
  329. package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
  330. package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
  331. package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
  332. package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
  333. package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
  334. package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
  335. package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
  336. package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
  337. package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
  338. package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
  339. package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
  340. package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
  341. package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
  342. package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
  343. package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
  344. package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
  345. package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
  346. package/vendor/agent/chunks/github-copilot.js +2 -0
  347. package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
  348. package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
  349. package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
  350. package/vendor/agent/chunks/image-resize-worker.js +2 -0
  351. package/vendor/agent/chunks/kimi-coding.js +2 -0
  352. package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
  353. package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
  354. package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  355. package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
  356. package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
  357. package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
  358. package/vendor/agent/chunks/openai-codex.js +75 -0
  359. package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
  360. package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
  361. package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
  362. package/vendor/agent/chunks/openrouter.js +75 -0
  363. package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
  364. package/vendor/agent/chunks/radius.js +75 -0
  365. package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
  366. package/vendor/agent/chunks/xai.js +2 -0
  367. package/vendor/agent/cli.js +3 -0
  368. package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
  369. package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
  370. package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
  371. package/vendor/agent/index.js +2 -0
  372. package/vendor/agent/modes/interactive/theme/dark.json +91 -0
  373. package/vendor/agent/modes/interactive/theme/light.json +90 -0
  374. package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
  375. package/vendor/agent/package.json +14 -0
  376. package/vendor/agent/rpc-entry.js +3 -0
  377. package/vendor/agent/theme/dark.json +91 -0
  378. package/vendor/agent/theme/light.json +90 -0
  379. package/vendor/agent/theme/theme-schema.json +356 -0
@@ -0,0 +1,200 @@
1
+ import { cancelSession, createSession, getSession, promptSession, serviceRequest, sessionMessages, setSessionEnvironment } from './service.mjs';
2
+
3
+ const POLL_INTERVAL = 50;
4
+ const MAX_TURN_MESSAGES = 200;
5
+
6
+ /** Create and run a native session, returning its terminal execution outcome. */
7
+ export async function runHeadless(service, workspace, prompt, options = {}) {
8
+ // Avoid installing a forwarding listener (or allocating timeout state) for
9
+ // calls that are already cancelled. This also keeps repeated calls on a
10
+ // shared AbortSignal listener-free.
11
+ if (options.signal?.aborted) return preAdmissionResult(options.sessionID, 'aborted', options.signal.reason);
12
+ const controller = new AbortController();
13
+ const forwardAbort = () => controller.abort(options.signal?.reason);
14
+ options.signal?.addEventListener('abort', forwardAbort, { once: true });
15
+ const timeout = options.timeout ?? 120_000;
16
+ const deadline = Date.now() + timeout;
17
+ const timeoutSignal = AbortSignal.timeout(timeout);
18
+ const signal = AbortSignal.any([controller.signal, timeoutSignal]);
19
+ let created;
20
+ let id = options.sessionID;
21
+ let session;
22
+ let accepted;
23
+ let promptStarted = false;
24
+ try {
25
+ if (!id) {
26
+ created = await createSession(service, workspace, { body: options.session ?? {}, signal });
27
+ id = created?.id ?? created?.sessionID;
28
+ }
29
+ if (!id) throw new Error('OpenCode did not return a valid session identifier');
30
+ session = created ?? { id };
31
+ await setSessionEnvironment(service, id, options.env ?? process.env, { location: workspace, signal });
32
+ promptStarted = true;
33
+ accepted = await promptSession(service, id, prompt, {
34
+ location: workspace,
35
+ signal,
36
+ body: { ...(options.prompt ?? {}), ...(options.parts ? { parts: options.parts } : {}), text: prompt },
37
+ });
38
+ await serviceRequest(service, 'POST', `/api/experimental/session/${encodeURIComponent(id)}/wait`, { signal, location: workspace });
39
+ session = await waitForOutcome(service, id, workspace, { deadline, signal, accepted });
40
+ const fetched = await sessionMessages(service, id, { signal, location: workspace });
41
+ const messages = currentTurnMessages(chronologicalMessages(fetched), accepted, options.maxMessages);
42
+ const result = extractAssistant(messages, accepted);
43
+ const idle = terminalIdle(messages);
44
+ const outcome = session.outcome ?? idle?.outcome ?? 'failed';
45
+ const terminal = Boolean(session.outcome || idle);
46
+ return {
47
+ session,
48
+ accepted,
49
+ messages,
50
+ result,
51
+ outcome,
52
+ terminal,
53
+ reason: session.ctg?.reason,
54
+ budgetReason: terminal ? undefined : session.ctg?.reason ?? 'terminal outcome was not observed before timeout',
55
+ error: outcome === 'failed' ? result?.error ?? session.error ?? idle?.error : undefined,
56
+ };
57
+ } catch (error) {
58
+ if (options.signal?.aborted || timeoutSignal.aborted) {
59
+ if (!accepted) {
60
+ if (id && promptStarted) await cancelSession(service, id, { location: workspace, timeout: 5000 }).catch(() => {});
61
+ return preAdmissionResult(id, options.signal?.aborted ? 'aborted' : 'timed-out', error, options.signal?.reason);
62
+ }
63
+ await cancelSession(service, id, { location: workspace, timeout: 5000 }).catch(() => {});
64
+ const saved = await observeOutcome(service, id, workspace, AbortSignal.timeout(5000)).catch(() => session);
65
+ if (saved?.outcome) session = saved;
66
+ const reason = options.signal?.aborted ? 'aborted' : 'timed-out';
67
+ return incompleteResult(service, id, workspace, accepted, session, reason, error, options.signal?.reason);
68
+ }
69
+ throw error;
70
+ } finally {
71
+ options.signal?.removeEventListener('abort', forwardAbort);
72
+ }
73
+ }
74
+
75
+ function preAdmissionResult(sessionID, reason, cause, abortReason) {
76
+ const outcome = reason === 'aborted' ? 'interrupted' : 'timed-out';
77
+ return {
78
+ ...(sessionID ? { session: { id: sessionID } } : {}),
79
+ messages: [],
80
+ result: undefined,
81
+ outcome,
82
+ terminal: false,
83
+ reason,
84
+ budgetReason: outcome === 'timed-out' ? reason : undefined,
85
+ cause: cause?.message,
86
+ abortReason: abortReason?.message,
87
+ };
88
+ }
89
+
90
+ async function waitForOutcome(service, sessionID, workspace, { deadline, signal, accepted }) {
91
+ let lastError;
92
+ while (Date.now() < deadline) {
93
+ signal.throwIfAborted();
94
+ try {
95
+ const session = await getSession(service, sessionID, { location: workspace, signal });
96
+ if (session?.outcome) return session;
97
+ lastError = undefined;
98
+ } catch (error) {
99
+ if (error.status !== 503) throw error;
100
+ lastError = error;
101
+ }
102
+ if (Date.now() >= deadline) break;
103
+ await delay(Math.min(POLL_INTERVAL, deadline - Date.now()), signal);
104
+ }
105
+ const observed = await observeOutcome(service, sessionID, workspace, AbortSignal.timeout(5000)).catch(() => undefined);
106
+ const messages = await sessionMessages(service, sessionID, { location: workspace, signal: AbortSignal.timeout(5000) }).catch(() => []);
107
+ const turnMessages = currentTurnMessages(chronologicalMessages(messages), accepted);
108
+ const idle = terminalIdle(turnMessages);
109
+ const reason = lastError ? 'session projection remained unavailable (503)' : 'session outcome was not observed';
110
+ return {
111
+ ...(observed ?? { id: sessionID }),
112
+ outcome: observed?.outcome ?? idle?.outcome ?? 'failed',
113
+ error: observed?.error ?? idle?.error ?? lastError,
114
+ ctg: { terminal: Boolean(observed?.outcome || idle), reason, acceptedMessageID: accepted?.id },
115
+ };
116
+ }
117
+
118
+ async function observeOutcome(service, sessionID, workspace, signal) {
119
+ return getSession(service, sessionID, { location: workspace, signal });
120
+ }
121
+
122
+ async function incompleteResult(service, sessionID, workspace, accepted, session, reason, cause, abortReason) {
123
+ const fetched = await sessionMessages(service, sessionID, { location: workspace, signal: AbortSignal.timeout(5000) }).catch(() => []);
124
+ const messages = currentTurnMessages(chronologicalMessages(fetched), accepted);
125
+ const idle = terminalIdle(messages);
126
+ const outcome = session.outcome ?? idle?.outcome ?? (reason === 'aborted' ? 'interrupted' : undefined);
127
+ const result = extractAssistant(messages, accepted);
128
+ const reasonText = session.ctg?.reason ?? reason;
129
+ const finalOutcome = outcome ?? (reason === 'timed-out' ? 'timed-out' : 'interrupted');
130
+ return {
131
+ session: { ...session, ...(outcome ? { outcome } : {}), error: session.error ?? idle?.error },
132
+ accepted,
133
+ messages,
134
+ result,
135
+ outcome: finalOutcome,
136
+ error: finalOutcome === 'failed' ? result?.error ?? session.error ?? idle?.error : undefined,
137
+ terminal: Boolean(session.outcome || idle),
138
+ reason: reasonText,
139
+ budgetReason: finalOutcome === 'timed-out' || finalOutcome === 'failed' ? reasonText : undefined,
140
+ cause: cause?.message,
141
+ abortReason: abortReason?.message,
142
+ };
143
+ }
144
+
145
+ function delay(milliseconds, signal) {
146
+ return new Promise((resolve, reject) => {
147
+ const timer = setTimeout(done, milliseconds);
148
+ function done() { signal.removeEventListener('abort', aborted); resolve(); }
149
+ function aborted() { clearTimeout(timer); signal.removeEventListener('abort', aborted); reject(signal.reason); }
150
+ signal.addEventListener('abort', aborted, { once: true });
151
+ if (signal.aborted) aborted();
152
+ });
153
+ }
154
+
155
+ function chronologicalMessages(messages) {
156
+ const list = Array.isArray(messages) ? messages : messages?.data ?? messages?.messages ?? [];
157
+ return [...list].reverse().sort((left, right) => messageTime(left) - messageTime(right));
158
+ }
159
+
160
+ function messageTime(item) {
161
+ const value = item.time?.created ?? item.timestamp ?? item.info?.time?.created ?? item.info?.timestamp ?? 0;
162
+ const number = typeof value === 'number' ? value : Date.parse(value);
163
+ return Number.isFinite(number) ? number : 0;
164
+ }
165
+
166
+ function terminalIdle(messages) {
167
+ return [...messages].reverse().find((item) => item.type === 'idle' && ['succeeded', 'failed', 'interrupted'].includes(item.outcome));
168
+ }
169
+
170
+ function currentTurnMessages(messages, accepted, limit = MAX_TURN_MESSAGES) {
171
+ const acceptedIndex = accepted?.id ? messages.findIndex((item) => item.id === accepted.id) : -1;
172
+ const lastUserIndex = acceptedIndex >= 0
173
+ ? acceptedIndex
174
+ : messages.reduce((latest, item, index) => item.type === 'user' ? index : latest, -1);
175
+ const turn = lastUserIndex >= 0 ? messages.slice(lastUserIndex) : messages;
176
+ return turn.slice(-Math.max(1, Math.min(MAX_TURN_MESSAGES, Number.isInteger(limit) ? limit : MAX_TURN_MESSAGES)));
177
+ }
178
+
179
+ function extractAssistant(messages, accepted) {
180
+ const assistantMessages = messages.filter((item) => item.type === 'assistant' || item.info?.role === 'assistant' || item.role === 'assistant');
181
+ const latestAssistant = assistantMessages.at(-1);
182
+ const acceptedTime = accepted?.time?.created;
183
+ const acceptedIndex = accepted?.id ? messages.findIndex((item) => item.id === accepted.id) : -1;
184
+ const assistantAfterAccepted = acceptedIndex >= 0
185
+ ? assistantMessages.filter((item) => messages.indexOf(item) > acceptedIndex).at(-1)
186
+ : undefined;
187
+ const message = acceptedIndex >= 0
188
+ ? assistantAfterAccepted
189
+ : (Number.isFinite(acceptedTime) ? assistantMessages.filter((item) => messageTime(item) >= acceptedTime).at(-1) : undefined) ?? latestAssistant;
190
+ if (!message) return undefined;
191
+ const parts = message.parts ?? message.content ?? [];
192
+ return {
193
+ status: message.finish ?? message.info?.finish ?? message.info?.status ?? message.status,
194
+ text: Array.isArray(parts) ? parts.filter((part) => part.type === 'text' || part.type === 'text-delta' || typeof part.text === 'string').map((part) => part.text ?? '').join('') : String(parts),
195
+ error: message.error ?? message.info?.error,
196
+ message,
197
+ };
198
+ }
199
+
200
+ export { cancelSession, createSession, getSession, promptSession, sessionMessages, setSessionEnvironment };
@@ -0,0 +1,213 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { resolve } from 'node:path';
6
+ import { beginReview, parseFindings } from '../review-state.mjs';
7
+
8
+ function reviewVerdict(output) {
9
+ const line = output.trim().split(/\r?\n/).at(-1);
10
+ const match = /^VERDICT: (APPROVE|CHANGES-REQUESTED)$/.exec(line || '');
11
+ if (!match) throw Error('Reviewer output must end with an exact structured VERDICT line');
12
+ return match[1];
13
+ }
14
+
15
+ function localSource(root, git) {
16
+ const diff = git('diff', 'HEAD', '--binary', { trim: false });
17
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(path => path && path !== '.harness' && !path.startsWith('.harness/')).sort();
18
+ return `${diff}\0UNTRACKED\0${untracked.map(path => `${path}\0${readFileSync(join(root, path)).toString('base64')}`).join('\0')}`;
19
+ }
20
+
21
+ /** Return the same source-bound identity used by local reviews without launching a reviewer. */
22
+ export function localReviewSnapshot(cwd) {
23
+ const root = resolve(cwd);
24
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
25
+ const git = (...args) => {
26
+ const options = args.at(-1)?.trim === false ? args.pop() : undefined;
27
+ const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
28
+ return options ? output : output.trim();
29
+ };
30
+ return { head, snapshot: createReviewSnapshot({ source: localSource(root, git), head }) };
31
+ }
32
+
33
+ function assistantText(messages, session) {
34
+ if (session?.outcome && session.outcome !== 'succeeded') throw Error(`Reviewer execution did not succeed (${session.outcome})`);
35
+ const terminal = messages.at(-1);
36
+ if (terminal?.type !== 'idle' || terminal.outcome !== 'succeeded') throw Error(`Reviewer execution did not reach a successful terminal state (${terminal?.type === 'idle' ? terminal.outcome : 'missing'})`);
37
+ const assistant = [...messages.slice(0, -1)].reverse().find(message => message.type === 'assistant');
38
+ if (!assistant || assistant.finish !== 'stop' || !Array.isArray(assistant.content)) throw Error('Reviewer did not return a completed assistant response');
39
+ return assistant.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
40
+ }
41
+
42
+ const READ_ONLY_PERMISSIONS = [
43
+ { action: '*', resource: '*', effect: 'deny' },
44
+ { action: 'shell', resource: '*', effect: 'deny' },
45
+ { action: 'edit', resource: '*', effect: 'deny' },
46
+ { action: 'write', resource: '*', effect: 'deny' },
47
+ { action: 'external_directory', resource: '*', effect: 'deny' },
48
+ { action: 'execute', resource: '*', effect: 'deny' },
49
+ { action: 'subagent', resource: '*', effect: 'deny' },
50
+ { action: 'skill', resource: '*', effect: 'deny' },
51
+ { action: 'read', resource: '*', effect: 'allow' },
52
+ { action: 'glob', resource: '*', effect: 'allow' },
53
+ { action: 'grep', resource: '*', effect: 'allow' },
54
+ ];
55
+
56
+ function reviewerSignal(signal, timeoutMs) {
57
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) throw Error('Review timeout must be an integer from 1 to 300000 ms');
58
+ const timeout = AbortSignal.timeout(timeoutMs);
59
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
60
+ }
61
+
62
+ async function interruptReviewer(ctx, sessionID) {
63
+ let timer;
64
+ try {
65
+ return await Promise.race([
66
+ ctx.session.interrupt({ sessionID, resume: false }, { signal: AbortSignal.timeout(2_000) }).then(result => result.interrupted === true),
67
+ new Promise(resolveInterrupt => { timer = setTimeout(() => resolveInterrupt(false), 2_000); }),
68
+ ]);
69
+ } finally { if (timer) clearTimeout(timer); }
70
+ }
71
+
72
+ async function selectReviewerAgent(ctx, agent, signal) {
73
+ const list = await ctx.agent.list({}, { signal });
74
+ const agents = Array.isArray(list) ? list : list?.data;
75
+ if (!Array.isArray(agents) || !agents.some(item => item.id === agent)) throw Error(`Reviewer agent ${agent} is not registered at this OpenCode location`);
76
+ return agent;
77
+ }
78
+
79
+ async function runReviewer({ ctx, root, title, prompt, agent, model, signal, timeoutMs }) {
80
+ const requestSignal = reviewerSignal(signal, timeoutMs);
81
+ let reviewer;
82
+ try {
83
+ await selectReviewerAgent(ctx, agent, requestSignal);
84
+ reviewer = await ctx.session.create({ title, agent, model, location: { directory: root }, permissions: READ_ONLY_PERMISSIONS }, { signal: requestSignal });
85
+ // Mark structured reviewer instructions as raw so the session-controls hook
86
+ // does not append the user's delivery workflow to this read-only review.
87
+ await ctx.session.prompt({ sessionID: reviewer.id, text: prompt, metadata: { 'ctg.raw': true } }, { signal: requestSignal });
88
+ await ctx.session.wait({ sessionID: reviewer.id }, { signal: requestSignal });
89
+ const [messages, session] = await Promise.all([
90
+ ctx.session.context({ sessionID: reviewer.id }, { signal: requestSignal }),
91
+ ctx.session.get({ sessionID: reviewer.id }, { signal: requestSignal }),
92
+ ]);
93
+ return { reviewer, output: assistantText(messages, session) };
94
+ } catch (error) {
95
+ let interrupted = false;
96
+ if (reviewer && (requestSignal.aborted || signal?.aborted)) {
97
+ try { interrupted = await interruptReviewer(ctx, reviewer.id); }
98
+ catch { /* Preserve the cancellation/timeout failure. */ }
99
+ }
100
+ if (requestSignal.aborted) {
101
+ const reason = signal?.aborted ? 'cancelled' : 'timed out';
102
+ const stopped = interrupted ? 'reviewer interruption confirmed' : 'reviewer may still be running; interruption was not confirmed';
103
+ throw Error(`Review ${reason}; ${stopped} (session ${reviewer?.id ?? 'creation pending'})`, { cause: error });
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ export function createReviewSnapshot({ source, head }) {
110
+ return createHash('sha256').update(`${source}\0${head}`).digest('hex');
111
+ }
112
+
113
+ /** Local delivery review. This does not approve or merge a remote PR. */
114
+ /** @param {{ ctx: OpenCodeContext, cwd: string, identity?: string, model?: { providerID: string, id: string, variant?: string }, agent?: string, signal?: AbortSignal, timeoutMs?: number }} options */
115
+ export async function startReview({ ctx, cwd, identity = 'local-delivery', model = /** @type {{ providerID: string, id: string, variant?: string }} */ ({ providerID: 'lattice', id: 'gpt-6-luna', variant: 'high' }), agent = 'build', signal, timeoutMs = 300_000 }) {
116
+ const root = resolve(cwd);
117
+ const git = (...args) => {
118
+ const options = args.at(-1)?.trim === false ? args.pop() : undefined;
119
+ const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
120
+ return options ? output : output.trim();
121
+ };
122
+ const head = git('rev-parse', 'HEAD');
123
+ const source = localSource(root, git);
124
+ const snapshot = createReviewSnapshot({ source, head });
125
+ const admission = beginReview(root, identity, snapshot, head);
126
+ let reviewer;
127
+ try {
128
+ const prompt = `Review this exact local delivery source at ${root}. This is a read-only local delivery review, not a PR review. Do not modify files, commit, push, or merge.\nHEAD: ${head}\nSnapshot SHA-256: ${snapshot}\n\nExact source diff and untracked source files (base64 records where applicable):\n${source}\n\nReport findings and end with REVIEW_JSON: {"version":1,"snapshot":"${snapshot}","head":"${head}","findings":[]} followed by VERDICT: APPROVE or VERDICT: CHANGES-REQUESTED.`;
129
+ const result = await runReviewer({ ctx, root, title: `CTG review: ${identity}`, prompt, agent, model, signal, timeoutMs });
130
+ reviewer = result.reviewer;
131
+ const output = result.output;
132
+ const verdict = reviewVerdict(output);
133
+ const findings = parseFindings(output, snapshot, head, verdict);
134
+ const currentHead = await git('rev-parse', 'HEAD');
135
+ const currentSource = localSource(root, git);
136
+ if (currentHead !== head || createReviewSnapshot({ source: currentSource, head: currentHead }) !== snapshot) throw Error('Local review source or HEAD moved while review was running');
137
+ const status = verdict === 'APPROVE' ? 'approved' : 'changes-requested';
138
+ admission.finish(status, { reviewerSessionID: reviewer.id, findings: findings.findings, remote: false });
139
+ return { type: 'local-delivery', identity, head, snapshot, reviewerSessionID: reviewer.id, verdict, findings };
140
+ } catch (error) {
141
+ admission.finish('inconclusive', { message: String(error.message || error) });
142
+ throw error;
143
+ } finally { admission.release(); }
144
+ }
145
+
146
+ function commandOutput(command, args, cwd, { trim = true } = {}) {
147
+ const result = execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
148
+ const output = Buffer.isBuffer(result) ? result.toString('utf8') : String(result);
149
+ return trim ? output.trim() : output;
150
+ }
151
+
152
+ function authoritativePR({ cwd, repository, prNumber }) {
153
+ if (!Number.isInteger(prNumber) || prNumber < 1) throw Error('A positive GitHub pull request number is required');
154
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || '')) throw Error('A GitHub repository in owner/name form is required');
155
+ const git = (...args) => commandOutput('git', args, cwd);
156
+ const response = commandOutput('gh', ['api', `repos/${repository}/pulls/${prNumber}`], cwd);
157
+ let pullRequest;
158
+ try { pullRequest = JSON.parse(response); } catch (error) { throw Error('GitHub CLI returned malformed pull request metadata', { cause: error }); }
159
+ const headRepository = pullRequest?.head?.repo?.full_name;
160
+ const headUrl = pullRequest?.head?.repo?.clone_url;
161
+ const baseRepository = pullRequest?.base?.repo?.full_name;
162
+ const baseUrl = pullRequest?.base?.repo?.clone_url;
163
+ const remoteHead = pullRequest?.head?.sha;
164
+ const baseHead = pullRequest?.base?.sha;
165
+ const headRefName = pullRequest?.head?.ref;
166
+ const baseRefName = pullRequest?.base?.ref;
167
+ if (pullRequest?.state !== 'open' || !headRepository || !headUrl || !baseRepository || !baseUrl || typeof headRefName !== 'string' || typeof baseRefName !== 'string' || !/^[a-f0-9]{40}$/i.test(remoteHead || '') || !/^[a-f0-9]{40}$/i.test(baseHead || '')) throw Error('GitHub PR is not open or did not provide authoritative head/base commits');
168
+ const localHead = git('rev-parse', 'HEAD');
169
+ const headRef = `refs/ctg-review/pr-${prNumber}/head`;
170
+ const baseRef = `refs/ctg-review/pr-${prNumber}/base`;
171
+ if (git('check-ref-format', '--branch', headRefName) !== headRefName || git('check-ref-format', '--branch', baseRefName) !== baseRefName) throw Error('GitHub PR returned an invalid branch ref');
172
+ commandOutput('git', ['fetch', '--no-tags', '--no-write-fetch-head', headUrl, `+refs/heads/${headRefName}:${headRef}`], cwd);
173
+ commandOutput('git', ['fetch', '--no-tags', '--no-write-fetch-head', baseUrl, `+refs/heads/${baseRefName}:${baseRef}`], cwd);
174
+ if (git('rev-parse', headRef) !== remoteHead || git('rev-parse', baseRef) !== baseHead) throw Error('Fetched GitHub branch refs do not match the authoritative pull request commit IDs');
175
+ if (localHead !== remoteHead) throw Error('Local HEAD does not match the authoritative GitHub PR head');
176
+ const mergeBase = git('merge-base', baseHead, remoteHead);
177
+ const source = commandOutput('git', ['diff', '--binary', mergeBase, remoteHead, '--'], cwd, { trim: false });
178
+ if (git('rev-parse', `${remoteHead}^{commit}`) !== remoteHead || git('rev-parse', `${baseHead}^{commit}`) !== baseHead) throw Error('Fetched Git objects do not match the authoritative GitHub PR commits');
179
+ return { repository: repository.toLowerCase(), prNumber, localHead, remoteHead, baseHead, headRepository: headRepository.toLowerCase(), source };
180
+ }
181
+
182
+ /** PR approval is derived from GitHub PR metadata and exact fetched Git objects, never caller-supplied refs/source. */
183
+ /** @typedef {import('@opencode/plugin/promise/plugin').Context} OpenCodeContext */
184
+ /** @param {{ ctx: OpenCodeContext, cwd: string, repository: string, prNumber: number, model?: { providerID: string, id: string, variant?: string }, agent?: string, signal?: AbortSignal, timeoutMs?: number }} options */
185
+ export async function startPullRequestReview({ ctx, cwd, repository, prNumber, model = /** @type {{ providerID: string, id: string, variant?: string }} */ ({ providerID: 'lattice', id: 'gpt-6-luna', variant: 'high' }), agent = 'build', signal, timeoutMs = 300_000 }) {
186
+ const root = resolve(cwd);
187
+ const initial = authoritativePR({ cwd: root, repository, prNumber });
188
+ const identity = `${initial.repository}#${initial.prNumber}`;
189
+ const head = initial.remoteHead;
190
+ const snapshot = createReviewSnapshot({ source: initial.source, head });
191
+ const admission = beginReview(root, `pr:${identity}`, snapshot, head);
192
+ try {
193
+ const prompt = `Review this exact GitHub pull request source snapshot. This is remote PR review, not local delivery review. Do not modify files, commit, push, or merge.\nRepository: ${identity}\nVerified PR head: ${head}\nSnapshot SHA-256: ${snapshot}\n\n${initial.source}\n\nReturn REVIEW_JSON: {"version":1,"snapshot":"${snapshot}","head":"${head}","findings":[]} followed by VERDICT: APPROVE or VERDICT: CHANGES-REQUESTED.`;
194
+ const result = await runReviewer({ ctx, root, title: `CTG PR review: ${identity}`, prompt, agent, model, signal, timeoutMs });
195
+ const reviewer = result.reviewer;
196
+ const output = result.output;
197
+ const verdict = reviewVerdict(output);
198
+ const findings = parseFindings(output, snapshot, head, verdict);
199
+ const current = authoritativePR({ cwd: root, repository, prNumber });
200
+ if (current.repository !== initial.repository || current.headRepository !== initial.headRepository || current.localHead !== initial.localHead || current.remoteHead !== initial.remoteHead || current.baseHead !== initial.baseHead || createReviewSnapshot({ source: current.source, head: current.remoteHead }) !== snapshot) throw Error('Authoritative GitHub PR refs or source moved while review was running');
201
+ admission.finish(verdict === 'APPROVE' ? 'approved' : 'changes-requested', { reviewerSessionID: reviewer.id, findings: findings.findings, remote: true });
202
+ return { type: 'pull-request', identity, head, snapshot, reviewerSessionID: reviewer.id, verdict, findings };
203
+ } catch (error) {
204
+ admission.finish('inconclusive', { message: String(error.message || error), remote: true });
205
+ throw error;
206
+ } finally { admission.release(); }
207
+ }
208
+
209
+ /** PR review guard: caller supplies the verified source refs from local and remote checks. */
210
+ export function verifyPullRequestHeads({ localHead, remoteHead, pullRequestHead }) {
211
+ if (!localHead || !remoteHead || !pullRequestHead || localHead !== remoteHead || remoteHead !== pullRequestHead) throw Error('PR review head mismatch; refresh and review the exact current PR source');
212
+ return { verifiedHead: pullRequestHead };
213
+ }
@@ -0,0 +1,246 @@
1
+ import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ export const OPENCODE_VERSION = '2.0.32';
9
+ const DEFAULT_ROOT = path.join(homedir(), '.carthagent', 'opencode');
10
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
11
+
12
+ function abs(value) { return path.resolve(value); }
13
+
14
+ /** Return the isolated CTG OpenCode filesystem layout. */
15
+ export function runtimePaths(options = {}) {
16
+ const root = abs(options.root ?? process.env.CTG_OPENCODE_HOME ?? DEFAULT_ROOT);
17
+ return Object.freeze({
18
+ root,
19
+ home: path.join(root, 'home'),
20
+ appData: path.join(root, 'home', 'AppData', 'Roaming'),
21
+ localAppData: path.join(root, 'home', 'AppData', 'Local'),
22
+ config: path.join(root, 'config'),
23
+ configDir: path.join(root, 'config', 'opencode'),
24
+ data: path.join(root, 'data'),
25
+ state: path.join(root, 'state'),
26
+ cache: path.join(root, 'cache'),
27
+ serviceFile: path.join(root, 'state', 'opencode', 'service.json'),
28
+ credentials: path.join(root, 'data', 'opencode', 'auth.json'),
29
+ configFile: path.join(root, 'config', 'opencode', 'opencode.json'),
30
+ lifecycleLock: path.join(root, 'state', 'opencode', 'lifecycle.lock'),
31
+ profileLock: path.join(root, 'state', 'opencode', 'profile.lock'),
32
+ });
33
+ }
34
+
35
+ /** Find a CTG/OpenCode executable without downloading or replacing user binaries. */
36
+ export async function resolveOpenCode(options = {}) {
37
+ const explicit = options.binary ?? process.env.CTG_OPENCODE_BIN;
38
+ const packaged = path.join(PACKAGE_ROOT, 'node_modules', '@opencode', 'cli', 'bin', 'opencode.exe');
39
+ const candidates = explicit ? [explicit] : [packaged];
40
+ for (const candidate of candidates) {
41
+ if (candidate.includes(path.sep) || candidate.includes('/')) {
42
+ try {
43
+ const result = await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl });
44
+ const version = result.stdout.trim().match(/(?:opencode\s+)?v?(\d+\.\d+\.\d+)/i)?.[1];
45
+ if (version !== OPENCODE_VERSION) throw new Error(`OpenCode ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} at ${candidate}`);
46
+ return abs(candidate);
47
+ } catch (error) {
48
+ if (explicit || /is required/.test(error.message)) throw error;
49
+ }
50
+ } else {
51
+ try {
52
+ const result = await runCapture(candidate, ['--version'], { timeout: 5000, spawnImpl: options.spawnImpl });
53
+ const version = result.stdout.trim().match(/(?:opencode\s+)?v?(\d+\.\d+\.\d+)/i)?.[1];
54
+ if (version !== OPENCODE_VERSION) throw new Error(`OpenCode ${OPENCODE_VERSION} is required; found ${version ?? 'unknown'} on PATH`);
55
+ return candidate;
56
+ } catch (error) {
57
+ if (explicit || /is required/.test(error.message)) throw error;
58
+ }
59
+ }
60
+ }
61
+ throw new Error(`Packaged OpenCode ${OPENCODE_VERSION} was not found at ${packaged}. Reinstall CTG or set CTG_OPENCODE_BIN explicitly; CTG will not use or replace another OpenCode installation.`);
62
+ }
63
+
64
+ /** Construct the environment for CTG-owned OpenCode children. */
65
+ export function runtimeEnvironment(options = {}) {
66
+ const paths = runtimePaths(options);
67
+ // OpenCode has many profile/config switches; do not inherit ambient host
68
+ // overrides. Workspace child environments are restored through the native
69
+ // session.environment API, not through this host process environment.
70
+ const inherited = { ...process.env, ...(options.env ?? {}) };
71
+ const env = Object.fromEntries(Object.entries(inherited).filter(([key]) => !key.startsWith('OPENCODE_')));
72
+ Object.assign(env, {
73
+ XDG_CONFIG_HOME: paths.config,
74
+ XDG_DATA_HOME: paths.data,
75
+ XDG_STATE_HOME: paths.state,
76
+ XDG_CACHE_HOME: paths.cache,
77
+ HOME: paths.home,
78
+ APPDATA: paths.appData,
79
+ LOCALAPPDATA: paths.localAppData,
80
+ OPENCODE_TEST_HOME: paths.home,
81
+ USERPROFILE: paths.home,
82
+ OPENCODE_DB: path.join(paths.data, 'opencode.db'),
83
+ OPENCODE_CONFIG: paths.configFile,
84
+ OPENCODE_CONFIG_DIR: paths.configDir,
85
+ OPENCODE_CONFIG_PROJECT_DISABLE: 'true',
86
+ OPENCODE_DISABLE_MODELS_FETCH: 'true',
87
+ OPENCODE_DISABLE_CLAUDE_CODE: 'true',
88
+ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: 'true',
89
+ OPENCODE_DISABLE_AUTOUPDATE: 'true',
90
+ CTG_OPENCODE_HOME: paths.root,
91
+ });
92
+ return env;
93
+ }
94
+
95
+ /** Ensure private persistent directories and CTG profile/plugin config exist. */
96
+ export async function provisionRuntime(options = {}) {
97
+ const paths = runtimePaths(options);
98
+ for (const directory of [paths.root, paths.home, paths.appData, paths.localAppData, paths.config, paths.configDir, paths.data, paths.state, path.dirname(paths.serviceFile), paths.cache]) {
99
+ await mkdir(directory, { recursive: true, mode: 0o700 });
100
+ await chmod(directory, 0o700).catch(() => {});
101
+ }
102
+ await withFileLock(paths.profileLock, async () => {
103
+ let current = {};
104
+ try {
105
+ current = JSON.parse(await readFile(paths.configFile, 'utf8'));
106
+ if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('expected a JSON object');
107
+ } catch (error) {
108
+ if (error.code !== 'ENOENT') throw new Error(`Cannot preserve CTG OpenCode profile at ${paths.configFile}: ${error.message}`);
109
+ }
110
+ const config = { ...current, ...(options.profile ?? {}), $schema: current.$schema ?? 'https://opencode.ai/config.json' };
111
+ if (options.pluginPath) {
112
+ const plugin = abs(options.pluginPath);
113
+ config.plugins = [...new Set([...(Array.isArray(config.plugins) ? config.plugins : []), plugin])];
114
+ }
115
+ await atomicWrite(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, 0o600);
116
+ });
117
+ return paths;
118
+ }
119
+
120
+ export async function atomicWrite(file, contents, mode = 0o600) {
121
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
122
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
123
+ const handle = await open(temporary, 'wx', mode);
124
+ try {
125
+ await handle.writeFile(contents);
126
+ await handle.sync();
127
+ await handle.close();
128
+ await rename(temporary, file);
129
+ await chmod(file, mode).catch(() => {});
130
+ } catch (error) {
131
+ await handle.close().catch(() => {});
132
+ await rm(temporary, { force: true }).catch(() => {});
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /** Cross-process exclusive lock with bounded wait and dead-owner recovery. */
138
+ export async function withFileLock(file, action, options = {}) {
139
+ const timeout = options.timeout ?? 15000;
140
+ const deadline = Date.now() + timeout;
141
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
142
+ let handle;
143
+ const token = randomUUID();
144
+ while (!handle) {
145
+ if (Date.now() >= deadline) throw lockTimeoutError(file);
146
+ try {
147
+ handle = await open(file, 'wx', 0o600);
148
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token, startedAt: Date.now() }));
149
+ await handle.sync();
150
+ } catch (error) {
151
+ if (error.code !== 'EEXIST') {
152
+ await handle?.close().catch(() => {});
153
+ handle = undefined;
154
+ await rm(file, { force: true }).catch(() => {});
155
+ throw error;
156
+ }
157
+ let owner;
158
+ let observed;
159
+ try { observed = await readFile(file, 'utf8'); owner = JSON.parse(observed); } catch {}
160
+ const abandoned = owner?.pid ? !processAlive(owner.pid) : await stat(file)
161
+ .then((details) => Date.now() - details.mtimeMs >= (options.staleLockAge ?? 5000))
162
+ .catch(() => false);
163
+ if (abandoned) {
164
+ await options.afterStaleLockCheck?.({ file, owner });
165
+ // Recovery itself is serialized. Never rename/unlink the contested
166
+ // pathname after inspecting it: another contender may have replaced
167
+ // that pathname with a fresh lock in the meantime.
168
+ let recovery;
169
+ try { recovery = await open(`${file}.recovery`, 'wx', 0o600); }
170
+ catch (lockError) {
171
+ if (lockError.code !== 'EEXIST') throw lockError;
172
+ await new Promise((resolve) => setTimeout(resolve, Math.min(options.pollInterval ?? 40, Math.max(1, deadline - Date.now()))));
173
+ continue;
174
+ }
175
+ const recoveryToken = JSON.stringify({ pid: process.pid, token: randomUUID() });
176
+ try {
177
+ await recovery.writeFile(recoveryToken);
178
+ await recovery.sync();
179
+ const latest = await readFile(file, 'utf8').catch(() => '');
180
+ let latestOwner;
181
+ try { latestOwner = JSON.parse(latest); } catch {}
182
+ if (latest === observed) {
183
+ const stillAbandoned = latestOwner?.pid
184
+ ? !processAlive(latestOwner.pid)
185
+ : await stat(file).then((details) => Date.now() - details.mtimeMs >= (options.staleLockAge ?? 5000)).catch(() => false);
186
+ if (stillAbandoned) await rm(file, { force: true });
187
+ }
188
+ } finally {
189
+ const currentRecovery = await readFile(`${file}.recovery`, 'utf8').catch(() => '');
190
+ try { if (currentRecovery === recoveryToken) await rm(`${file}.recovery`, { force: true }); } catch {}
191
+ await recovery.close();
192
+ }
193
+ continue;
194
+ }
195
+ if (Date.now() >= deadline) throw lockTimeoutError(file, owner);
196
+ await new Promise((resolve) => setTimeout(resolve, Math.min(options.pollInterval ?? 40, Math.max(1, deadline - Date.now()))));
197
+ }
198
+ }
199
+ try { return await action(); }
200
+ finally {
201
+ const owner = await readFile(file, 'utf8').catch(() => '');
202
+ try { if (JSON.parse(owner).token === token) await rm(file, { force: true }); } catch {}
203
+ await handle.close();
204
+ }
205
+ }
206
+
207
+ function lockTimeoutError(file, owner) {
208
+ const recovery = `${file}.recovery`;
209
+ return new Error(`Timed out waiting for CTG runtime lock ${file}${owner?.pid ? ` held by process ${owner.pid}` : ''}. If no CTG process is recovering it, inspect and remove the stale lock/recovery files (${file} and ${recovery}) before retrying.`);
210
+ }
211
+
212
+ export function processAlive(pid) {
213
+ try { process.kill(pid, 0); return true; }
214
+ catch (error) { return error.code === 'EPERM'; }
215
+ }
216
+
217
+ /** Run a child process with bounded startup and captured diagnostic output. */
218
+ export function runCapture(command, args, options = {}) {
219
+ return new Promise((resolve, reject) => {
220
+ const spawnImpl = options.spawnImpl ?? spawn;
221
+ const child = spawnImpl(command, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] });
222
+ let stdout = '';
223
+ let stderr = '';
224
+ const cap = options.maxOutput ?? 256 * 1024;
225
+ child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout = (stdout + chunk).slice(-cap); });
226
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-cap); });
227
+ const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`${command} timed out after ${options.timeout ?? 30000}ms`)); }, options.timeout ?? 30000);
228
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
229
+ child.once('close', (code, signal) => {
230
+ clearTimeout(timer);
231
+ if (code === 0) resolve({ stdout, stderr, code });
232
+ else reject(new Error(`${command} exited ${code ?? signal}: ${(stderr || stdout).trim().slice(-4000)}`));
233
+ });
234
+ });
235
+ }
236
+
237
+ /** Preserve workspace authentication/environment: do not apply CTG host XDG overrides. */
238
+ export function workspaceEnvironment(workspace, env = process.env) {
239
+ return { ...env, CTG_WORKSPACE: abs(workspace) };
240
+ }
241
+
242
+ export async function readRuntimeCredentials(options = {}) {
243
+ const file = runtimePaths(options).credentials;
244
+ try { return JSON.parse(await readFile(file, 'utf8')); }
245
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
246
+ }