terramend 0.2.0

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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,1312 @@
1
+ /**
2
+ * OpenCode agent — in-process harness (opencode-ai >=1.14.x SDK-v2 / Effect-ts
3
+ * CLI rewrite).
4
+ *
5
+ * Architecture, post v2-in-process migration:
6
+ *
7
+ * 1. Spawn ONE `opencode serve --port <p>` subprocess per Terramend run via
8
+ * `node:child_process.spawn` directly (NOT our `spawn()` wrapper — see
9
+ * `bootOpencodeServer` for why: long-lived stdio streaming, manual
10
+ * activity gating against the SDK event loop, killGroup teardown).
11
+ * 2. Talk to it over loopback HTTP via the typed `@opencode-ai/sdk/v2`
12
+ * `createOpencodeClient({ baseUrl })` — no `Server.Default()` embed,
13
+ * no `createOpencode()` SDK lifecycle (would re-wrap our subprocess).
14
+ * 3. Create ONE session up front (`client.session.create`).
15
+ * 4. Subscribe to events once (`client.event.subscribe`) and pump them
16
+ * through a single per-run handler set for live logging + activity
17
+ * tracking + subagent labeling.
18
+ * 5. Run the initial prompt via `client.session.prompt({ sessionID, parts })`.
19
+ * Every post-run gate retry AND the reflection turn re-enter the same
20
+ * session via another `client.session.prompt()` call. Warm MCP, warm
21
+ * plugins, warm provider connections, same context window — no
22
+ * `--continue` subprocess respawn.
23
+ * 6. Close the server in a finally.
24
+ *
25
+ * What that replaces (vs the pre-migration v2 harness):
26
+ * - The per-run `opencode run --format json --print-logs --thinking` CLI
27
+ * subprocess that emitted NDJSON envelopes.
28
+ * - The `runOpenCode(... args: [...baseArgs, "--continue", c.prompt] ...)`
29
+ * resume callback that booted a SECOND opencode process (fresh MCP,
30
+ * fresh plugins, cold cache) for each gate retry / reflection turn.
31
+ * - The `opencodePlugin.ts` bus-event re-emitter — we subscribe to the
32
+ * global event stream now, so subagent events arrive naturally without
33
+ * a stdout sentinel envelope.
34
+ *
35
+ * What stays identical:
36
+ * - bash: "deny" via OPENCODE_CONFIG_CONTENT
37
+ * - OPENCODE_PERMISSION filesystem sandbox — deny-all + allow /tmp
38
+ * - MCP Terramend server injected via `mcp.<name> = { type: "remote", url }`
39
+ * - ASKPASS for git auth
40
+ * - codex auth materialization + post-hook writeback
41
+ * - reviewfrog subagent config / model derivation
42
+ * - bedrock model prefix routing
43
+ * - skills install
44
+ * - todo tracker / onToolUse forwarding
45
+ */
46
+ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
47
+ import { mkdirSync, writeFileSync } from "node:fs";
48
+ import { join } from "node:path";
49
+ import { performance } from "node:perf_hooks";
50
+ import * as core from "@actions/core";
51
+ import {
52
+ type AssistantMessage,
53
+ createOpencodeClient,
54
+ type EventSubscribeResponse,
55
+ type OpencodeClient,
56
+ type Part,
57
+ type TextPartInput,
58
+ } from "@opencode-ai/sdk/v2";
59
+ import { Agent, fetch as undiciFetch } from "undici";
60
+ import {
61
+ GIT_NATIVE_READ_DENY_OPENCODE,
62
+ GIT_NATIVE_WRITE_DENY_OPENCODE,
63
+ } from "#app/agents/nativeFsDenies";
64
+ import {
65
+ TERRAMEND_OPENCODE_GATE_PLUGIN_FILENAME,
66
+ TERRAMEND_OPENCODE_GATE_PLUGIN_SOURCE,
67
+ } from "#app/agents/opencodePlugin";
68
+ import {
69
+ autoSelectModel,
70
+ buildReviewerAgentConfig,
71
+ geminiHighThinkingOverrides,
72
+ installOpencodeCli,
73
+ type OpenCodeConfig,
74
+ } from "#app/agents/opencodeShared";
75
+ import {
76
+ buildLearningsReflectionPrompt,
77
+ runPostRunRetryLoop,
78
+ shouldRunReflection,
79
+ } from "#app/agents/postRun";
80
+ import { REVIEWER_AGENT_NAME } from "#app/agents/reviewer";
81
+ import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "#app/agents/sessionLabeler";
82
+ import {
83
+ type AgentResult,
84
+ type AgentRunContext,
85
+ type AgentUsage,
86
+ agent,
87
+ logTokenTable,
88
+ MAX_STDERR_LINES,
89
+ MCP_SERVER_TOKEN_ENV,
90
+ } from "#app/agents/shared";
91
+ import { terramendMcpName } from "#app/external";
92
+ import { BEDROCK_MODEL_ID_ENV } from "#app/models";
93
+ import type { ToolState } from "#app/toolState";
94
+ import { AGENT_ACTIVITY_TIMEOUT_MS, markActivity } from "#app/utils/activity";
95
+ import type { AgentDiagnostic } from "#app/utils/agentHangReport";
96
+ import { formatJsonValue, log } from "#app/utils/cli";
97
+ import { installCodexAuth } from "#app/utils/codexHome";
98
+ import { findProviderErrorMatch } from "#app/utils/providerErrors";
99
+ import { installBundledSkills } from "#app/utils/skills";
100
+ import { trackChild, untrackChild } from "#app/utils/subprocess";
101
+ import { resolveTerraformMcp, TERRAFORM_MCP_SERVER_NAME } from "#app/utils/terraformMcp";
102
+ import type { TodoTracker } from "#app/utils/todoTracking";
103
+ import { resolveVertexOpenCodeModel } from "#app/utils/vertex";
104
+
105
+ const installCli = () => installOpencodeCli({ binPath: "bin/opencode.exe" });
106
+
107
+ // ── config ─────────────────────────────────────────────────────────────────────
108
+
109
+ export function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
110
+ // P2.2 — opt-in second server: HashiCorp's terraform-mcp-server (registry
111
+ // toolset, docker stdio) for live module/provider knowledge.
112
+ const terraformMcp = resolveTerraformMcp(ctx.payload);
113
+ if (terraformMcp.kind === "docker_missing") log.info(`» ${terraformMcp.note}`);
114
+ const config: OpenCodeConfig = {
115
+ permission: {
116
+ bash: "deny",
117
+ edit: "allow",
118
+ read: "allow",
119
+ webfetch: "allow",
120
+ external_directory: "allow",
121
+ skill: "allow",
122
+ },
123
+ mcp: {
124
+ // 300s tool timeout (vs the MCP SDK's 60s default). `checkout_pr` runs a
125
+ // multi-minute `git fetch` on large repos (remotion); a 60s client abort
126
+ // surfaces as `MCP error -32001` and used to push the agent toward
127
+ // deleting live git locks (the corruption in #860/#864 — the dangerous
128
+ // `rm` guidance is gone, but the spurious aborts shouldn't happen either).
129
+ // server-side cap is 600s (`checkout_pr` `timeoutMs`).
130
+ // Authorization carries the per-run MCP bearer token. opencode expands
131
+ // `{env:VAR}` in remote-MCP header values (opencode.ai/docs/mcp-servers),
132
+ // so the config holds only the placeholder; the raw token reaches the
133
+ // opencode server via MCP_SERVER_TOKEN_ENV on its spawn env (below).
134
+ [terramendMcpName]: {
135
+ type: "remote",
136
+ url: ctx.mcpServerUrl,
137
+ headers: { Authorization: `Bearer {env:${MCP_SERVER_TOKEN_ENV}}` },
138
+ timeout: 300_000,
139
+ },
140
+ ...(terraformMcp.kind === "available"
141
+ ? {
142
+ [TERRAFORM_MCP_SERVER_NAME]: {
143
+ type: "local",
144
+ command: [terraformMcp.command, ...terraformMcp.args],
145
+ enabled: true,
146
+ },
147
+ }
148
+ : {}),
149
+ },
150
+ agent: (() => {
151
+ const cfg = buildReviewerAgentConfig(model);
152
+ const reviewerModel = (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
153
+ log.info(`» subagent models: reviewfrog=${reviewerModel}`);
154
+ return cfg;
155
+ })(),
156
+ // gemini-3 thinking pinned to high for review depth; gpt and anthropic
157
+ // effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
158
+ provider: { google: { models: geminiHighThinkingOverrides() } },
159
+ };
160
+
161
+ if (model) {
162
+ config.model = model;
163
+ const slashIndex = model.indexOf("/");
164
+ if (slashIndex > 0) {
165
+ config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
166
+ // moonshotai/kimi stalls on the @openrouter/ai-sdk-provider 2.8.1 stream
167
+ // parser opencode 1.15.13 bundles: duplicate tool-call emission on
168
+ // kimi-k2.6 makes the turn produce zero further part.updated events until
169
+ // the inner watchdog aborts (47% of kimi proxy runs vs 0% for claude).
170
+ // upstream fixed it in 2.9.0 (openrouter/ai-sdk-provider PR #489). opencode
171
+ // only loads a non-bundled provider version when a model is redeclared
172
+ // under provider.<id>.models with a versioned npm spec — a bare
173
+ // provider.npm is a no-op for catalog models, which keep the bundled import
174
+ // (provider.ts BUNDLED_PROVIDERS is keyed by the unversioned name). so pin
175
+ // 2.9.0 for the redeclared kimi model; opencode installs it on demand via
176
+ // Npm.add. the empty model body inherits catalog cost/limits; provider id +
177
+ // model id stay openrouter/kimi so slug/cost/billing are unchanged. scoped
178
+ // to the moonshot route so other openrouter models keep the bundled parser.
179
+ if (model.startsWith("openrouter/moonshotai/")) {
180
+ const modelID = model.slice(slashIndex + 1);
181
+ config.provider = {
182
+ ...config.provider,
183
+ openrouter: {
184
+ npm: "@openrouter/ai-sdk-provider@2.9.0",
185
+ options: {
186
+ baseURL: "https://openrouter.ai/api/v1",
187
+ apiKey: "{env:OPENROUTER_API_KEY}",
188
+ },
189
+ models: { [modelID]: {} },
190
+ },
191
+ };
192
+ }
193
+ }
194
+ }
195
+
196
+ return JSON.stringify(config);
197
+ }
198
+
199
+ /** split `<providerID>/<modelID>` into the SDK's prompt model shape. */
200
+ export function parseModel(
201
+ value: string | undefined,
202
+ ): { providerID: string; modelID: string } | undefined {
203
+ if (!value) return undefined;
204
+ const slash = value.indexOf("/");
205
+ if (slash <= 0) return undefined;
206
+ return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) };
207
+ }
208
+
209
+ // ── server boot ────────────────────────────────────────────────────────────────
210
+
211
+ interface ServerHandle {
212
+ baseUrl: string;
213
+ proc: ChildProcess;
214
+ /** kill the server; idempotent. */
215
+ close: () => Promise<void>;
216
+ /** rolling tail of server stderr for diagnostics. */
217
+ recentStderr: string[];
218
+ }
219
+
220
+ /**
221
+ * Spawn `<cliPath> serve --port 0 --hostname 127.0.0.1` and wait for the
222
+ * "opencode server listening on http://..." stdout line.
223
+ *
224
+ * Direct node:child_process.spawn instead of our `spawn()` wrapper because
225
+ * the wrapper's contract is "Promise<SpawnResult> that resolves on exit" —
226
+ * we need a handle that stays alive across many session.prompt() calls.
227
+ * We still register with `trackChild()` so Ctrl-C kills the server alongside
228
+ * everything else.
229
+ */
230
+ export function bootOpencodeServer(params: {
231
+ cliPath: string;
232
+ env: NodeJS.ProcessEnv;
233
+ cwd: string;
234
+ }): Promise<ServerHandle> {
235
+ const proc = nodeSpawn(params.cliPath, ["serve", "--port", "0", "--hostname", "127.0.0.1"], {
236
+ cwd: params.cwd,
237
+ env: params.env,
238
+ stdio: ["ignore", "pipe", "pipe"],
239
+ // detached + killGroup so SIGKILL nukes the whole tree: node_modules/
240
+ // opencode-ai/bin/opencode is a Node shim that spawnSync's the native
241
+ // binary; without process-group kill the native binary is reparented
242
+ // to PID 1 and never dies. mirrors the same fix in runOpenCode's
243
+ // original spawn().
244
+ detached: true,
245
+ });
246
+ trackChild({ child: proc, killGroup: true });
247
+
248
+ const recentStderr: string[] = [];
249
+ proc.stderr?.on("data", (chunk: Buffer) => {
250
+ const text = chunk.toString().trim();
251
+ if (!text) return;
252
+ recentStderr.push(text);
253
+ if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
254
+ log.debug(`[opencode serve] ${text}`);
255
+ });
256
+
257
+ let closed = false;
258
+ const close = async (): Promise<void> => {
259
+ if (closed) return;
260
+ closed = true;
261
+ untrackChild(proc);
262
+ if (proc.pid && !proc.killed) {
263
+ try {
264
+ process.kill(-proc.pid, "SIGTERM");
265
+ } catch {
266
+ proc.kill("SIGTERM");
267
+ }
268
+ // give the server 2s to exit cleanly, then SIGKILL the group.
269
+ await new Promise<void>((resolve) => {
270
+ const escalator = setTimeout(() => {
271
+ if (!proc.killed) {
272
+ try {
273
+ process.kill(-proc.pid!, "SIGKILL");
274
+ } catch {
275
+ proc.kill("SIGKILL");
276
+ }
277
+ }
278
+ }, 2000);
279
+ proc.once("close", () => {
280
+ clearTimeout(escalator);
281
+ resolve();
282
+ });
283
+ });
284
+ }
285
+ };
286
+
287
+ return new Promise<ServerHandle>((resolve, reject) => {
288
+ // serve.ts logs `opencode server listening on http://<host>:<port>` once
289
+ // bound. parse it out, then resolve. drain remaining stdout to debug.
290
+ let buffer = "";
291
+ let resolved = false;
292
+ const onStdout = (chunk: Buffer) => {
293
+ const text = chunk.toString();
294
+ buffer += text;
295
+ if (!resolved) {
296
+ const match = buffer.match(/opencode server listening on (https?:\/\/[^\s]+)/);
297
+ if (match?.[1]) {
298
+ resolved = true;
299
+ log.info(`» opencode server up: ${match[1]}`);
300
+ resolve({ baseUrl: match[1], proc, close, recentStderr });
301
+ // keep draining for debug visibility after handover.
302
+ }
303
+ }
304
+ // log any stdout line that's not the listening sentinel at debug level
305
+ // so a noisy serve startup is visible without polluting info logs.
306
+ const lines = text.split("\n");
307
+ for (const line of lines) {
308
+ const trimmed = line.trim();
309
+ if (trimmed && !trimmed.includes("opencode server listening")) {
310
+ log.debug(`[opencode serve] ${trimmed}`);
311
+ }
312
+ }
313
+ };
314
+ proc.stdout?.on("data", onStdout);
315
+
316
+ proc.once("error", (err) => {
317
+ if (!resolved) {
318
+ reject(new Error(`failed to spawn opencode serve: ${err.message}`));
319
+ }
320
+ });
321
+ proc.once("close", (code, signal) => {
322
+ if (!resolved) {
323
+ const tail = recentStderr.slice(-5).join("\n");
324
+ reject(
325
+ new Error(
326
+ `opencode serve exited before ready (code=${code} signal=${signal})${tail ? `\n${tail}` : ""}`,
327
+ ),
328
+ );
329
+ }
330
+ });
331
+
332
+ // safety: if the listening line never arrives, bail after 30s.
333
+ const bootTimeout = setTimeout(() => {
334
+ if (!resolved) {
335
+ resolved = true;
336
+ const tail = recentStderr.slice(-5).join("\n");
337
+ void close();
338
+ reject(
339
+ new Error(
340
+ `timed out after 30s waiting for opencode serve to bind${tail ? `\n${tail}` : ""}`,
341
+ ),
342
+ );
343
+ }
344
+ }, 30_000);
345
+ bootTimeout.unref?.();
346
+ });
347
+ }
348
+
349
+ // ── per-turn state ─────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * What we collect during a single session.prompt() turn so we can render a
353
+ * unified AgentResult at the end. Per-turn snapshot is reset between turns
354
+ * inside the event loop via `beginTurn()` / `endTurn()`.
355
+ */
356
+ export interface TurnAccumulator {
357
+ finalText: string;
358
+ /**
359
+ * Aggregate token totals from step-finish parts across the orchestrator AND
360
+ * any subagent sessions dispatched during the turn (e.g. reviewfrog).
361
+ * Mirrors v1's `accumulatedTokens` semantics so production billing/audit
362
+ * numbers stay apples-to-apples across the migration.
363
+ */
364
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
365
+ costUsd: number;
366
+ sessionError: string | null;
367
+ /** populated when a tool_use part on the orchestrator session reports error. */
368
+ lastToolError: string | null;
369
+ }
370
+
371
+ export function newTurn(): TurnAccumulator {
372
+ return {
373
+ finalText: "",
374
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
375
+ costUsd: 0,
376
+ sessionError: null,
377
+ lastToolError: null,
378
+ };
379
+ }
380
+
381
+ // ── runner ─────────────────────────────────────────────────────────────────────
382
+
383
+ export interface RunnerContext {
384
+ client: OpencodeClient;
385
+ sessionID: string;
386
+ label: string;
387
+ orchestratorSessionID: string;
388
+ labeler: SessionLabeler;
389
+ toolState: ToolState;
390
+ todoTracker?: TodoTracker | undefined;
391
+ onActivityTimeout?: (() => void) | undefined;
392
+ onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
393
+ /** current per-turn aggregator; nullable between turns. */
394
+ currentTurn: TurnAccumulator | null;
395
+ /** monotonic event count for diagnostics. */
396
+ eventCount: number;
397
+ /** last activity timestamp (event-stream silence detector). */
398
+ lastEventAt: number;
399
+ /** active task dispatch metadata keyed by callID (for subagent timing). */
400
+ taskDispatchByCallID: Map<string, { label: string; startedAt: number }>;
401
+ /**
402
+ * orchestrator tool callIDs already surfaced via `log.info(» ${tool}(...))`,
403
+ * tracked so the end-of-turn fallback can re-emit only the calls the live
404
+ * event stream missed. closes the SSE-connect race against the first
405
+ * `session.prompt()` (the SDK opens the SSE lazily on first iteration; by
406
+ * then the server may already have emitted the turn's tool part-updated
407
+ * events). without the fallback those calls never appear in stdout, which
408
+ * breaks every validator that greps for tool-call shape.
409
+ */
410
+ loggedToolCallIDs: Set<string>;
411
+ /** rolling stderr tail from the server process (for diagnostics). */
412
+ recentStderr: string[];
413
+ diagnostic: AgentDiagnostic;
414
+ }
415
+
416
+ /**
417
+ * orchestrate the event stream consumer for the entire server lifetime.
418
+ *
419
+ * NB: the SDK subscribe is lazy — the SSE fetch only opens on the first
420
+ * iteration. so the first turn's tool part-updated events can race the
421
+ * connect and be missed. live-stream logging is best-effort; see the
422
+ * end-of-turn `logUnseenToolCalls` fallback for the guarantee.
423
+ */
424
+ export async function consumeEvents(ctx: RunnerContext, signal: AbortSignal): Promise<void> {
425
+ // wire the abort signal into the SSE request itself. without it the
426
+ // generated client falls back to an internal never-aborting signal
427
+ // (serverSentEvents.gen.js: `options.signal ?? new AbortController().signal`),
428
+ // so `reader.read()` parks forever once the session goes idle and the stream
429
+ // falls silent. the teardown `await eventLoopPromise` in the run() finally
430
+ // then blocks — `abortController.abort()` can't interrupt a `for await` that
431
+ // never advances — until the outer process-output watchdog kills the
432
+ // already-succeeded run once the flat idle budget elapses and reports a false
433
+ // "stalled" (PR #876).
434
+ const result = await ctx.client.event.subscribe({}, { signal });
435
+ for await (const event of result.stream as AsyncGenerator<EventSubscribeResponse>) {
436
+ if (signal.aborted) break;
437
+ ctx.eventCount += 1;
438
+ ctx.diagnostic.eventCount = ctx.eventCount;
439
+ // NB: `lastEventAt` (the inner-watchdog clock) is intentionally NOT bumped
440
+ // here — opencode's keepalive/lifecycle/idle events would otherwise mask a
441
+ // provider stall (the model going silent mid-turn looks like steady event
442
+ // flow). it is refreshed only on meaningful progress in `dispatchEvent`.
443
+ markActivity();
444
+ try {
445
+ await dispatchEvent(ctx, event);
446
+ } catch (err) {
447
+ log.debug(
448
+ `» event dispatch threw for type=${(event as { type?: string }).type ?? "?"}: ${err instanceof Error ? err.message : String(err)}`,
449
+ );
450
+ }
451
+ }
452
+ }
453
+
454
+ export async function dispatchEvent(
455
+ ctx: RunnerContext,
456
+ event: EventSubscribeResponse,
457
+ ): Promise<void> {
458
+ // event union covers heartbeats, session lifecycle, message lifecycle, tui,
459
+ // mcp, etc. we only care about a small subset.
460
+ if (event.type === "message.part.updated") {
461
+ // real model/tool progress: token/text/reasoning streaming and tool
462
+ // part transitions all arrive as part.updated. this is the only event
463
+ // class that refreshes the inner-watchdog clock.
464
+ ctx.lastEventAt = performance.now();
465
+ await onPartUpdated(ctx, event.properties.part);
466
+ return;
467
+ }
468
+ if (event.type === "session.error") {
469
+ const sessionID = event.properties.sessionID;
470
+ if (sessionID !== ctx.orchestratorSessionID) return;
471
+ const err = event.properties.error;
472
+ const message = err ? extractErrorMessage(err) : "(no error payload)";
473
+ if (ctx.currentTurn) ctx.currentTurn.sessionError = message;
474
+ log.info(`» ${ctx.label} session error: ${message}`);
475
+ return;
476
+ }
477
+ // session.idle / session.status are useful breadcrumbs but we don't drive
478
+ // anything off them — the prompt() POST returns when the assistant message
479
+ // is committed, which is also when the session goes idle.
480
+ }
481
+
482
+ function extractErrorMessage(err: {
483
+ name?: string;
484
+ data?: { message?: string; [key: string]: unknown };
485
+ }): string {
486
+ if (err.data?.message) return err.data.message;
487
+ if (err.name) return err.name;
488
+ return JSON.stringify(err);
489
+ }
490
+
491
+ async function onPartUpdated(ctx: RunnerContext, part: Part): Promise<void> {
492
+ const label = ctx.labeler.labelFor(part.sessionID);
493
+ const isOrchestrator = part.sessionID === ctx.orchestratorSessionID;
494
+
495
+ // text — only orchestrator's final text becomes the run's "output";
496
+ // subagent text is logged but not folded into finalOutput.
497
+ if (part.type === "text" && part.time?.end !== undefined) {
498
+ const text = part.text.trim();
499
+ if (!text) return;
500
+ const boxTitle = label === ORCHESTRATOR_LABEL ? ctx.label : `${ctx.label} [${label}]`;
501
+ log.box(text, { title: boxTitle });
502
+ if (isOrchestrator && ctx.currentTurn) {
503
+ ctx.currentTurn.finalText = text;
504
+ }
505
+ return;
506
+ }
507
+
508
+ if (part.type === "reasoning" && part.time.end !== undefined) {
509
+ const text = part.text.trim();
510
+ if (!text) return;
511
+ const dur = formatPartDuration(part.time);
512
+ const preview = text.length > 280 ? `${text.slice(0, 280)}…` : text;
513
+ log.info(withLabel(label, `» thinking${dur}: ${preview.replace(/\n+/g, " ")}`));
514
+ if (text.length > 280) log.debug(withLabel(label, `» thinking (full): ${text}`));
515
+ return;
516
+ }
517
+
518
+ if (part.type === "step-finish") {
519
+ // aggregate orchestrator AND subagent step-finish events into the same
520
+ // per-turn accumulator. The legacy CLI harness summed both via opencode's
521
+ // CLI `--print-logs` output; filtering subagents here would silently
522
+ // undercount production cost/usage by the reviewfrog subagent's
523
+ // contribution (often the bulk of a Review-mode turn).
524
+ if (!ctx.currentTurn) return;
525
+ const t = part.tokens;
526
+ if (t) {
527
+ ctx.currentTurn.tokens.input += t.input || 0;
528
+ ctx.currentTurn.tokens.output += t.output || 0;
529
+ ctx.currentTurn.tokens.cacheRead += t.cache?.read || 0;
530
+ ctx.currentTurn.tokens.cacheWrite += t.cache?.write || 0;
531
+ }
532
+ if (typeof part.cost === "number" && Number.isFinite(part.cost)) {
533
+ ctx.currentTurn.costUsd += part.cost;
534
+ }
535
+ return;
536
+ }
537
+
538
+ if (part.type === "tool") {
539
+ await onToolPart(ctx, part, label, isOrchestrator);
540
+ return;
541
+ }
542
+
543
+ // step-start / snapshot / patch / agent / retry / compaction / subtask /
544
+ // file: nothing actionable here.
545
+ }
546
+
547
+ async function onToolPart(
548
+ ctx: RunnerContext,
549
+ part: Extract<Part, { type: "tool" }>,
550
+ label: string,
551
+ isOrchestrator: boolean,
552
+ ): Promise<void> {
553
+ const status = part.state.status;
554
+ const toolName = part.tool;
555
+ const toolId = part.callID;
556
+
557
+ // early task-dispatch announce: bind subagent sessionID to a label as soon
558
+ // as the orchestrator's task tool transitions to "running" (where input is
559
+ // populated). dedupe against later terminal observations via callID.
560
+ if (
561
+ toolName === "task" &&
562
+ status === "running" &&
563
+ isOrchestrator &&
564
+ !ctx.taskDispatchByCallID.has(toolId)
565
+ ) {
566
+ const input = (part.state.input ?? {}) as {
567
+ description?: string;
568
+ subagent_type?: string;
569
+ prompt?: string;
570
+ };
571
+ const dispatched = ctx.labeler.recordTaskDispatch(input);
572
+ ctx.taskDispatchByCallID.set(toolId, { label: dispatched, startedAt: performance.now() });
573
+ log.info(
574
+ `» dispatching subagent: ${dispatched}` +
575
+ (input.subagent_type ? ` (subagent_type=${input.subagent_type})` : ""),
576
+ );
577
+ return;
578
+ }
579
+
580
+ // terminal bookkeeping (log line, side effects) runs once per callID via
581
+ // `processTerminalToolPart` — see its docstring for the dedup contract
582
+ // shared with the end-of-turn fallback.
583
+ processTerminalToolPart(ctx, part, label, isOrchestrator);
584
+ }
585
+
586
+ /**
587
+ * shared terminal bookkeeping for a tool part: log line, dedup callID, run
588
+ * orchestrator-side hooks (`onToolUse` → diff-coverage tracker; `todowrite` /
589
+ * `report_progress` → todo tracker; tool-error → `lastToolError`), and emit
590
+ * subagent-finish summary on `task` returns.
591
+ *
592
+ * called from both the live SSE path (`onToolPart`) and the end-of-turn
593
+ * fallback (`logUnseenToolCalls`) — `loggedToolCallIDs` is the dedup guard
594
+ * so each call's side effects fire exactly once across both paths. critical
595
+ * for diff-coverage: a first-turn `Read` that races SSE attach would
596
+ * otherwise be missed by `recordDiffReadFromToolUse`, and the subsequent
597
+ * `create_pull_request_review` pre-flight would reject the review.
598
+ */
599
+ export function processTerminalToolPart(
600
+ ctx: RunnerContext,
601
+ part: Extract<Part, { type: "tool" }>,
602
+ label: string,
603
+ isOrchestrator: boolean,
604
+ ): void {
605
+ const toolName = part.tool;
606
+ const toolId = part.callID;
607
+ const state = part.state;
608
+ if (state.status !== "completed" && state.status !== "error") return;
609
+ if (isOrchestrator && ctx.loggedToolCallIDs.has(toolId)) return;
610
+
611
+ const input = state.input ?? {};
612
+ const inputFormatted = formatJsonValue(input);
613
+ const callLine = inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
614
+ log.info(withLabel(label, callLine));
615
+ if (isOrchestrator) ctx.loggedToolCallIDs.add(toolId);
616
+
617
+ if (state.status === "completed") {
618
+ log.debug(withLabel(label, ` output: ${state.output}`));
619
+ } else {
620
+ log.info(withLabel(label, `» tool call failed: ${state.error}`));
621
+ if (isOrchestrator && ctx.currentTurn) {
622
+ ctx.currentTurn.lastToolError = state.error;
623
+ }
624
+ }
625
+
626
+ // subagent finish bookkeeping — exact callID match (v1.15 keeps callID
627
+ // stable across the whole tool-input → tool-call → terminal chain).
628
+ if (toolName === "task") {
629
+ const dispatch = ctx.taskDispatchByCallID.get(toolId);
630
+ if (dispatch) {
631
+ const dur = ((performance.now() - dispatch.startedAt) / 1000).toFixed(1);
632
+ const outputStr = state.status === "completed" ? state.output : "";
633
+ const preview =
634
+ typeof outputStr === "string" && outputStr.length > 120
635
+ ? `${outputStr.slice(0, 120)}…`
636
+ : outputStr;
637
+ log.info(
638
+ `» subagent finished: ${dispatch.label} (${dur}s, status=${state.status})` +
639
+ (preview ? ` — ${String(preview).replace(/\n/g, " ")}` : ""),
640
+ );
641
+ ctx.taskDispatchByCallID.delete(toolId);
642
+ }
643
+ }
644
+
645
+ // forward orchestrator tool usage to the harness's hooks. subagent
646
+ // tool calls don't count toward the parent's diff-coverage tracking —
647
+ // it's the orchestrator that submits the review.
648
+ if (isOrchestrator) {
649
+ ctx.onToolUse?.({ toolName, input });
650
+ }
651
+
652
+ if (toolName.includes("report_progress") && ctx.todoTracker) {
653
+ log.debug("» report_progress detected, disabling todo tracking");
654
+ ctx.todoTracker.cancel();
655
+ }
656
+ if (toolName === "todowrite" && ctx.todoTracker?.enabled && isOrchestrator) {
657
+ ctx.todoTracker.update(input);
658
+ }
659
+ }
660
+
661
+ /**
662
+ * end-of-turn safety net for tool-call bookkeeping. queries `session.messages`
663
+ * for the canonical orchestrator transcript and replays any tool callID the
664
+ * live event stream hasn't already processed — closes the SSE-connect race
665
+ * documented on `loggedToolCallIDs`. `session.prompt`'s own `data.parts` is
666
+ * only the final assistant message's parts (mostly text/reasoning); the tool
667
+ * calls in earlier steps of the same turn live on prior messages, so we need
668
+ * the full session-scoped read.
669
+ *
670
+ * delegates to `processTerminalToolPart` so the same side effects fire as
671
+ * on the live SSE path: log line, `onToolUse` (diff-coverage feed),
672
+ * `todoTracker` updates, `lastToolError`. completed/errored parts only;
673
+ * pending states are inflight and not yet meaningful.
674
+ */
675
+ async function logUnseenToolCalls(ctx: RunnerContext): Promise<void> {
676
+ try {
677
+ const resp = await ctx.client.session.messages({ sessionID: ctx.orchestratorSessionID });
678
+ if (resp.error || !resp.data) return;
679
+ for (const message of resp.data) {
680
+ for (const part of message.parts) {
681
+ if (part.type !== "tool") continue;
682
+ processTerminalToolPart(ctx, part, ORCHESTRATOR_LABEL, true);
683
+ }
684
+ }
685
+ } catch (err) {
686
+ log.debug(`» logUnseenToolCalls failed: ${err instanceof Error ? err.message : String(err)}`);
687
+ }
688
+ }
689
+
690
+ export function formatPartDuration(time: { start?: number; end?: number } | undefined): string {
691
+ if (!time || typeof time.start !== "number" || typeof time.end !== "number") return "";
692
+ if (time.end <= time.start) return "";
693
+ return ` (${((time.end - time.start) / 1000).toFixed(1)}s)`;
694
+ }
695
+
696
+ function withLabel(label: string, message: string): string {
697
+ return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
698
+ }
699
+
700
+ // ── per-turn execution ─────────────────────────────────────────────────────────
701
+
702
+ /**
703
+ * Run a single prompt turn against the persistent server. Resets the per-turn
704
+ * accumulator, calls `client.session.prompt()`, then assembles an AgentResult
705
+ * from the returned AssistantMessage + accumulated event state.
706
+ *
707
+ * Token / cost: `AssistantMessage.tokens` and `.cost` are authoritative for
708
+ * the turn. The event-stream accumulator is a fallback / sanity-check path
709
+ * used when the response is missing (e.g. abort, transport error) — and as
710
+ * the only source of per-step subagent attribution if we ever surface it.
711
+ */
712
+ export async function runPromptTurn(
713
+ ctx: RunnerContext,
714
+ params: {
715
+ text: string;
716
+ model: { providerID: string; modelID: string } | undefined;
717
+ signal: AbortSignal;
718
+ },
719
+ ): Promise<AgentResult> {
720
+ const start = performance.now();
721
+ // record the turn boundary in milliseconds (matches AssistantMessage.time.created)
722
+ // so the post-turn aggregator can isolate this turn's messages from the prior
723
+ // turns' messages on the same persistent orchestrator session.
724
+ const turnStartMs = Date.now();
725
+ ctx.currentTurn = newTurn();
726
+ const turn = ctx.currentTurn;
727
+
728
+ const part: TextPartInput = { type: "text", text: params.text };
729
+
730
+ let assistant: AssistantMessage | undefined;
731
+ let returnedParts: Part[] | undefined;
732
+ let networkError: string | null = null;
733
+ try {
734
+ const response = await ctx.client.session.prompt(
735
+ {
736
+ sessionID: ctx.sessionID,
737
+ parts: [part],
738
+ ...(params.model ? { model: params.model } : {}),
739
+ },
740
+ // wire the inner activity watchdog's abort signal into the SDK request
741
+ // — without this a hung HTTP keeps the run stuck even after the
742
+ // watchdog fires.
743
+ { signal: params.signal },
744
+ );
745
+ if (response.error) {
746
+ networkError = formatPromptError(response.error);
747
+ } else if (response.data) {
748
+ assistant = response.data.info;
749
+ returnedParts = response.data.parts;
750
+ } else {
751
+ // neither error nor data — malformed/partial SDK response. don't silently
752
+ // succeed with an empty AgentResult; treat as a failure so the gate loop
753
+ // surfaces it instead of looping on a "successful" no-op.
754
+ networkError = "opencode prompt returned neither data nor error";
755
+ }
756
+ } catch (err) {
757
+ networkError = err instanceof Error ? err.message : String(err);
758
+ }
759
+ const durationMs = performance.now() - start;
760
+
761
+ // authoritative cost/usage: walk every assistant message that landed during
762
+ // this turn (orchestrator session + any subagent sessions dispatched while
763
+ // it ran) and sum tokens + cost. The step-finish accumulator and the live
764
+ // AssistantMessage from session.prompt are both non-authoritative for a
765
+ // multi-step turn — step-finish events arrive on the SSE stream after
766
+ // session.prompt has already resolved (at least for the final message),
767
+ // and AssistantMessage carries only the final message's usage. Mirrors v1's
768
+ // accumulator-after-the-fact model but driven by the canonical message
769
+ // store instead of best-effort SSE sniffing.
770
+ const aggregatedUsage = await aggregateTurnUsage(ctx, turnStartMs);
771
+ const usage = aggregatedUsage ?? buildUsage(turn, assistant);
772
+
773
+ // surface the rendered final text. preference order:
774
+ // 1. orchestrator text part with time.end set (captured by event loop)
775
+ // 2. text part on the returned response (when present)
776
+ // 3. assistant message id as a last-resort placeholder
777
+ const finalText = turn.finalText || extractTextFromParts(returnedParts) || "";
778
+
779
+ await logUnseenToolCalls(ctx);
780
+
781
+ log.info(`» ${ctx.label} turn completed in ${Math.round(durationMs)}ms`);
782
+ if (usage) {
783
+ logTokenTable({
784
+ input: usage.inputTokens - (usage.cacheReadTokens ?? 0) - (usage.cacheWriteTokens ?? 0),
785
+ cacheRead: usage.cacheReadTokens ?? 0,
786
+ cacheWrite: usage.cacheWriteTokens ?? 0,
787
+ output: usage.outputTokens,
788
+ costUsd: usage.costUsd,
789
+ });
790
+ }
791
+
792
+ // failure modes, in order of authority:
793
+ // 1. transport / SDK-side error (response.error or thrown)
794
+ // 2. AssistantMessage.error set by the provider (auth, context overflow, etc.)
795
+ // 3. session.error event observed during the turn
796
+ if (networkError) {
797
+ // a watchdog-fired abort surfaces here as a caught `session.prompt`
798
+ // rejection (or an aborted `response.error`), not a throw that escapes to
799
+ // the caller. classify it as an `activity timeout` so `renderRunError`
800
+ // routes it through the hang renderer; any other transport failure falls
801
+ // through to the generic humanized renderer.
802
+ return {
803
+ success: false,
804
+ output: finalText,
805
+ error: params.signal.aborted
806
+ ? `activity timeout: the model went silent and the turn was aborted by the activity watchdog (${networkError})`
807
+ : `opencode prompt failed: ${networkError}`,
808
+ usage,
809
+ };
810
+ }
811
+ if (assistant?.error) {
812
+ return {
813
+ success: false,
814
+ output: finalText,
815
+ error: `provider error: ${extractErrorMessage(assistant.error)}`,
816
+ usage,
817
+ };
818
+ }
819
+ if (turn.sessionError) {
820
+ return {
821
+ success: false,
822
+ output: finalText,
823
+ error: `session error: ${turn.sessionError}`,
824
+ usage,
825
+ };
826
+ }
827
+
828
+ return { success: true, output: finalText, usage };
829
+ }
830
+
831
+ /**
832
+ * Sum the cost + tokens of every assistant message created during this turn,
833
+ * across the orchestrator session AND any subagent sessions dispatched while
834
+ * it ran. Authoritative: the SDK's own cost/tokens fields per message are the
835
+ * source of truth, identical to what `opencode --print-logs` aggregated in v1.
836
+ */
837
+ async function aggregateTurnUsage(
838
+ ctx: RunnerContext,
839
+ turnStartMs: number,
840
+ ): Promise<AgentUsage | undefined> {
841
+ // labeler tracks every sessionID we've observed events from on the
842
+ // global SSE stream, including any subagent (task tool) child sessions.
843
+ const sessionIDs = new Set<string>([ctx.orchestratorSessionID]);
844
+ for (const [sessionID] of ctx.labeler.entries()) {
845
+ sessionIDs.add(sessionID);
846
+ }
847
+
848
+ let inputTokens = 0;
849
+ let outputTokens = 0;
850
+ let cacheReadTokens = 0;
851
+ let cacheWriteTokens = 0;
852
+ let costUsd = 0;
853
+ let counted = 0;
854
+
855
+ for (const sessionID of sessionIDs) {
856
+ try {
857
+ const resp = await ctx.client.session.messages({ sessionID });
858
+ if (resp.error || !resp.data) continue;
859
+ for (const msg of resp.data) {
860
+ if (msg.info.role !== "assistant") continue;
861
+ if (msg.info.time.created < turnStartMs) continue;
862
+ const t = msg.info.tokens;
863
+ inputTokens += t.input || 0;
864
+ outputTokens += t.output || 0;
865
+ cacheReadTokens += t.cache?.read || 0;
866
+ cacheWriteTokens += t.cache?.write || 0;
867
+ costUsd += msg.info.cost || 0;
868
+ counted++;
869
+ }
870
+ } catch (err) {
871
+ log.debug(
872
+ `» aggregateTurnUsage failed for session ${sessionID}: ${err instanceof Error ? err.message : String(err)}`,
873
+ );
874
+ }
875
+ }
876
+
877
+ if (counted === 0) return undefined;
878
+
879
+ const total = inputTokens + cacheReadTokens + cacheWriteTokens;
880
+ if (total === 0 && outputTokens === 0 && costUsd === 0) return undefined;
881
+
882
+ return {
883
+ agent: "terramend",
884
+ inputTokens: total,
885
+ outputTokens,
886
+ cacheReadTokens: cacheReadTokens || undefined,
887
+ cacheWriteTokens: cacheWriteTokens || undefined,
888
+ costUsd: costUsd > 0 ? costUsd : undefined,
889
+ };
890
+ }
891
+
892
+ export function buildUsage(
893
+ turn: TurnAccumulator,
894
+ assistant: AssistantMessage | undefined,
895
+ ): AgentUsage | undefined {
896
+ // Prefer the step-finish accumulator: it sums every LLM call across the
897
+ // whole turn (orchestrator iterations + any subagent dispatches). The
898
+ // AssistantMessage at the SDK boundary only carries the *final* assistant
899
+ // message's tokens/cost — for a multi-step Review-mode turn that's just
900
+ // the closing acknowledgment, missing the bulk of the work. Fall back to
901
+ // assistant.tokens only if the accumulator is empty (e.g., the turn
902
+ // errored before any step-finish events landed).
903
+ const t = turn.tokens;
904
+ const accumulatorTotal = t.input + t.cacheRead + t.cacheWrite;
905
+ if (accumulatorTotal > 0 || t.output > 0 || turn.costUsd > 0) {
906
+ return {
907
+ agent: "terramend",
908
+ inputTokens: accumulatorTotal,
909
+ outputTokens: t.output,
910
+ cacheReadTokens: t.cacheRead || undefined,
911
+ cacheWriteTokens: t.cacheWrite || undefined,
912
+ costUsd: turn.costUsd > 0 ? turn.costUsd : undefined,
913
+ };
914
+ }
915
+ if (assistant) {
916
+ const at = assistant.tokens;
917
+ const total = (at.input || 0) + (at.cache?.read || 0) + (at.cache?.write || 0);
918
+ if (total === 0 && (at.output || 0) === 0 && (assistant.cost || 0) === 0) return undefined;
919
+ return {
920
+ agent: "terramend",
921
+ inputTokens: total,
922
+ outputTokens: at.output || 0,
923
+ cacheReadTokens: at.cache?.read || undefined,
924
+ cacheWriteTokens: at.cache?.write || undefined,
925
+ costUsd: assistant.cost > 0 ? assistant.cost : undefined,
926
+ };
927
+ }
928
+ return undefined;
929
+ }
930
+
931
+ export function extractTextFromParts(parts: Part[] | undefined): string | undefined {
932
+ if (!parts) return undefined;
933
+ const texts: string[] = [];
934
+ for (const p of parts) {
935
+ if (p.type === "text" && p.text) texts.push(p.text);
936
+ }
937
+ const joined = texts.join("\n").trim();
938
+ return joined || undefined;
939
+ }
940
+
941
+ export function formatPromptError(error: unknown): string {
942
+ if (typeof error === "string") return error;
943
+ if (error && typeof error === "object") {
944
+ const obj = error as { message?: string; error?: { message?: string }; data?: unknown };
945
+ if (obj.message) return obj.message;
946
+ if (obj.error?.message) return obj.error.message;
947
+ try {
948
+ return JSON.stringify(error);
949
+ } catch {
950
+ return String(error);
951
+ }
952
+ }
953
+ return String(error);
954
+ }
955
+
956
+ // ── inner activity timer ───────────────────────────────────────────────────────
957
+
958
+ /**
959
+ * Start an event-silence watchdog. The outer process-level activity timer
960
+ * (main.ts `createProcessOutputActivityTimeout`) watches `process.stdout.write`
961
+ * which our harness log lines drive — but it doesn't see SSE event silence
962
+ * when the harness is itself quiet. This inner timer specifically watches
963
+ * `ctx.lastEventAt` and fires `onActivityTimeout` so main.ts can tear down
964
+ * the MCP server early, mirroring the per-spawn watchdog in `subprocess.ts`.
965
+ *
966
+ * `ctx.lastEventAt` is refreshed only on meaningful progress (token/tool
967
+ * part.updated), so any prolonged gap with no progress advances the clock —
968
+ * including a long in-flight tool call. the budget is the same flat idle
969
+ * timeout as the outer watchdog, sized to exceed the worst-case legitimate
970
+ * silent tool window (#760), so a real tool can't trip it; a genuinely stalled
971
+ * provider or a hung tool does, at the flat budget.
972
+ */
973
+ export function startInnerActivityWatchdog(params: {
974
+ ctx: RunnerContext;
975
+ timeoutMs: number;
976
+ abortController: AbortController;
977
+ }): { stop: () => void } {
978
+ let fired = false;
979
+ const id = setInterval(() => {
980
+ if (fired) return;
981
+ const idleMs = performance.now() - params.ctx.lastEventAt;
982
+ if (idleMs <= params.timeoutMs) return;
983
+ fired = true;
984
+ const idleSec = Math.round(idleMs / 1000);
985
+ log.info(
986
+ `» no opencode events for ${idleSec}s — aborting in-flight prompt and notifying harness`,
987
+ );
988
+ params.abortController.abort();
989
+ try {
990
+ params.ctx.onActivityTimeout?.();
991
+ } catch (err) {
992
+ log.debug(
993
+ `inner activity callback threw: ${err instanceof Error ? err.message : String(err)}`,
994
+ );
995
+ }
996
+ }, 5_000);
997
+ id.unref?.();
998
+ return { stop: () => clearInterval(id) };
999
+ }
1000
+
1001
+ // ── agent entrypoint ───────────────────────────────────────────────────────────
1002
+
1003
+ export const opencode = agent({
1004
+ name: "opencode",
1005
+ install: installCli,
1006
+ run: async (ctx) => {
1007
+ const cliPath = await installCli();
1008
+
1009
+ const rawModel = ctx.resolvedModel ?? autoSelectModel();
1010
+
1011
+ // rawModel is the authoritative "what actually ran" — including the
1012
+ // auto-select pick that main.ts cannot know (it's opencode-specific:
1013
+ // folding it into `resolvedModel` earlier would mis-route `resolveAgent`).
1014
+ // overwrite the pre-agent best-effort so `toolState.model` (the "Using `…`"
1015
+ // footer badge) reflects the real model.
1016
+ if (rawModel) ctx.toolState.model = rawModel;
1017
+
1018
+ // bedrock route: opencode's `amazon-bedrock` provider expects the model
1019
+ // in `amazon-bedrock/<bedrock-id>` form. detect via env-var sentinel
1020
+ // (same pattern as claude.ts). do not gate on Anthropic-vs-other — that
1021
+ // discriminant lives in resolveAgent.
1022
+ const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
1023
+ const isBedrockRoute =
1024
+ rawModel !== undefined && bedrockModelId !== undefined && bedrockModelId === rawModel;
1025
+ const vertexModel = resolveVertexOpenCodeModel(rawModel);
1026
+ const model = vertexModel ?? (isBedrockRoute ? `amazon-bedrock/${rawModel}` : rawModel);
1027
+
1028
+ const homeEnv = {
1029
+ HOME: ctx.tmpdir,
1030
+ XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
1031
+ };
1032
+ // install the subagent gate into opencode's auto-discovered plugin dir
1033
+ // (under the tmpdir-redirected XDG_CONFIG_HOME). v2 installs ONLY the gate,
1034
+ // not the events re-emitter — it reads subagent events off the SDK stream,
1035
+ // so the re-emitter would be dead weight. see action/agents/opencodePlugin.ts.
1036
+ const opencodePluginDir = join(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
1037
+ mkdirSync(opencodePluginDir, { recursive: true });
1038
+ writeFileSync(
1039
+ join(opencodePluginDir, TERRAMEND_OPENCODE_GATE_PLUGIN_FILENAME),
1040
+ TERRAMEND_OPENCODE_GATE_PLUGIN_SOURCE,
1041
+ );
1042
+
1043
+ installBundledSkills({ home: homeEnv.HOME });
1044
+
1045
+ // materialize CODEX_AUTH_JSON into the runner's real $HOME/.local/share/
1046
+ // opencode/auth.json so OpenCode's CodexAuthPlugin picks it up. see
1047
+ // action/utils/codexHome.ts and wiki/codex-auth.md.
1048
+ const codexAuth = installCodexAuth();
1049
+
1050
+ // OPENCODE_PERMISSION has absolute highest precedence (merged after managed/MDM configs).
1051
+ // external_directory gates ALL native filesystem tools (Read, Write, Edit, Glob, Grep, etc.)
1052
+ // for paths outside the project root. last-match-wins: deny everything, then allow /tmp.
1053
+ // codex auth lives at /var/lib/terramend/opencode/auth.json in CI (see codexHome.ts),
1054
+ // which is outside /tmp/* — deny-default protects it from native FS tools.
1055
+ //
1056
+ // read + edit rules deny git surfaces INSIDE the project root, where
1057
+ // external_directory short-circuits (Instance.containsPath). edit denies
1058
+ // ALL of .git (blanket write — nothing legit writes .git via native tools;
1059
+ // MCP git tools run in the action process, outside this gate); read denies
1060
+ // only .git/config (narrow — broad .git read-blocks break orientation reads
1061
+ // like .git/HEAD, and ASKPASS keeps live tokens out of .git/config). `*` is
1062
+ // recursive in opencode's Wildcard dialect. grep/glob match the search
1063
+ // pattern not a filepath, so they can't be path-denied (documented in
1064
+ // wiki/security.md). canonical surfaces: action/agents/nativeFsDenies.ts.
1065
+ const permissionOverride = JSON.stringify({
1066
+ external_directory: { "*": "deny", "/tmp/*": "allow" },
1067
+ read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
1068
+ edit: { "*": "allow", ...GIT_NATIVE_WRITE_DENY_OPENCODE },
1069
+ });
1070
+
1071
+ const repoDir = process.cwd();
1072
+
1073
+ // opencode-ai >=1.14 resolves the session's `directory` from process.env.PWD
1074
+ // first (cli/cmd/run.ts:282 → Filesystem.resolve(PWD ?? cwd)). The server
1075
+ // does the same per-request via the x-opencode-directory header, but we
1076
+ // also pass PWD on the spawn env so any in-server tool that re-resolves
1077
+ // cwd locally lands in repoDir.
1078
+ const env: NodeJS.ProcessEnv = {
1079
+ ...process.env,
1080
+ ...homeEnv,
1081
+ PWD: repoDir,
1082
+ OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
1083
+ OPENCODE_PERMISSION: permissionOverride,
1084
+ // the opencode server expands {env:TERRAMEND_MCP_TOKEN} in the MCP header
1085
+ // from here; set only on this spawn env (not process.env) so the MCP shell
1086
+ // sandbox + any dependency-install subprocess never inherit it.
1087
+ [MCP_SERVER_TOKEN_ENV]: ctx.mcpServerToken,
1088
+ GOOGLE_GENERATIVE_AI_API_KEY:
1089
+ process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
1090
+ };
1091
+ if (codexAuth) {
1092
+ env.XDG_DATA_HOME = codexAuth.xdgDataHome;
1093
+ delete env.OPENAI_API_KEY;
1094
+ core.saveState(
1095
+ "codex_writeback",
1096
+ JSON.stringify({
1097
+ apiToken: ctx.apiToken,
1098
+ authPath: codexAuth.authPath,
1099
+ originalRefresh: codexAuth.originalRefresh,
1100
+ }),
1101
+ );
1102
+ }
1103
+
1104
+ log.debug(`» starting Terramend (OpenCode, in-process SDK): ${cliPath}`);
1105
+ log.debug(`» working directory: ${repoDir}`);
1106
+
1107
+ // ── boot server + create session ─────────────────────────────────────────
1108
+ const server = await bootOpencodeServer({ cliPath, env, cwd: repoDir });
1109
+ // the SDK's bundled fetch tries to disable per-request timeouts via the
1110
+ // bun-only `req.timeout = false` no-op, which does nothing under node/undici
1111
+ // — so undici's default 300s headers/body timeout aborts any turn that
1112
+ // streams for >5min as `TypeError: fetch failed`. wire an unbounded undici
1113
+ // dispatcher through a custom fetch (createOpencodeClient's `fetch` override
1114
+ // bypasses the SDK's own fetch) so a long turn isn't capped client-side.
1115
+ // the inner activity watchdog below — not undici — is what bounds true stalls.
1116
+ const dispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 0 });
1117
+ // forward the request through undici's own dispatcher-aware fetch (Agent and
1118
+ // fetch from the same package, so `dispatcher` typechecks with no cast). the
1119
+ // SDK hands our override a global `Request`, which the esbuild-bundled undici
1120
+ // realm can't consume directly ("Failed to parse URL from [object Request]"),
1121
+ // so we re-state its fields explicitly. `duplex: "half"` is required by the
1122
+ // fetch spec whenever a stream body is sent.
1123
+ const fetchWithoutTimeout: typeof fetch = (input, init) => {
1124
+ const request = input instanceof Request ? input : new Request(input, init);
1125
+ return undiciFetch(request.url, {
1126
+ method: request.method,
1127
+ headers: [...request.headers],
1128
+ body: request.body,
1129
+ duplex: "half",
1130
+ signal: request.signal,
1131
+ dispatcher,
1132
+ });
1133
+ };
1134
+ try {
1135
+ const client = createOpencodeClient({
1136
+ baseUrl: server.baseUrl,
1137
+ directory: repoDir,
1138
+ fetch: fetchWithoutTimeout,
1139
+ });
1140
+
1141
+ const sessionResp = await client.session.create({ title: "Terramend" });
1142
+ if (sessionResp.error || !sessionResp.data) {
1143
+ const msg = sessionResp.error
1144
+ ? formatPromptError(sessionResp.error)
1145
+ : "session.create returned no data";
1146
+ return {
1147
+ success: false,
1148
+ output: "",
1149
+ error: `opencode session.create failed: ${msg}`,
1150
+ };
1151
+ }
1152
+ const sessionID = sessionResp.data.id;
1153
+ log.info(`» opencode session: ${sessionID}`);
1154
+
1155
+ // bind the orchestrator label up front. without this, the first
1156
+ // foreign sessionID we see (a subagent) would consume the ORCHESTRATOR
1157
+ // slot in the labeler's FIFO and every label downstream would shift.
1158
+ const labeler = new SessionLabeler();
1159
+ labeler.labelFor(sessionID);
1160
+
1161
+ const runnerCtx: RunnerContext = {
1162
+ client,
1163
+ sessionID,
1164
+ label: "Terramend",
1165
+ orchestratorSessionID: sessionID,
1166
+ labeler,
1167
+ toolState: ctx.toolState,
1168
+ todoTracker: ctx.todoTracker,
1169
+ onActivityTimeout: ctx.onActivityTimeout,
1170
+ onToolUse: ctx.onToolUse,
1171
+ currentTurn: null,
1172
+ eventCount: 0,
1173
+ lastEventAt: performance.now(),
1174
+ taskDispatchByCallID: new Map(),
1175
+ loggedToolCallIDs: new Set(),
1176
+ recentStderr: server.recentStderr,
1177
+ diagnostic: {
1178
+ label: "Terramend",
1179
+ recentStderr: server.recentStderr,
1180
+ lastProviderError: undefined,
1181
+ eventCount: 0,
1182
+ },
1183
+ };
1184
+ ctx.toolState.agentDiagnostic = runnerCtx.diagnostic;
1185
+
1186
+ // server stderr → provider-error attribution (same pattern as the
1187
+ // old CLI subprocess harness's onStderr handler).
1188
+ server.proc.stderr?.on("data", (chunk: Buffer) => {
1189
+ const text = chunk.toString();
1190
+ for (const line of text.split("\n")) {
1191
+ const trimmed = line.trim();
1192
+ if (!trimmed) continue;
1193
+ const match = findProviderErrorMatch(trimmed);
1194
+ if (match) {
1195
+ runnerCtx.diagnostic.lastProviderError = match.label;
1196
+ log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
1197
+ }
1198
+ }
1199
+ });
1200
+
1201
+ const abortController = new AbortController();
1202
+ const eventLoopPromise = consumeEvents(runnerCtx, abortController.signal).catch((err) => {
1203
+ // SSE stream breakage during cleanup is expected; only surface during
1204
+ // active operation.
1205
+ if (!abortController.signal.aborted) {
1206
+ log.warning(
1207
+ `» opencode event subscription ended: ${err instanceof Error ? err.message : String(err)}`,
1208
+ );
1209
+ }
1210
+ });
1211
+
1212
+ const watchdog = startInnerActivityWatchdog({
1213
+ ctx: runnerCtx,
1214
+ // model-stall budget: how long the orchestrator may stream NO progress
1215
+ // (no token/tool part.updated) before we tear the turn down. opencode's
1216
+ // keepalive/lifecycle events keep the outer process-output monitor
1217
+ // alive even while the model is silent, so this inner timer is the only
1218
+ // stall detector for the v2 SSE path. it shares the flat idle budget so
1219
+ // a long synchronous tool call (no part.updated while it runs) can't
1220
+ // false-positive it.
1221
+ timeoutMs: AGENT_ACTIVITY_TIMEOUT_MS,
1222
+ abortController,
1223
+ });
1224
+
1225
+ const sdkModel = parseModel(model);
1226
+
1227
+ try {
1228
+ // initial run
1229
+ const initial = await runTurnGuarded(runnerCtx, () =>
1230
+ runPromptTurn(runnerCtx, {
1231
+ text: ctx.instructions.full,
1232
+ model: sdkModel,
1233
+ signal: abortController.signal,
1234
+ }),
1235
+ );
1236
+
1237
+ // post-run gate retry loop — every resume is another session.prompt()
1238
+ // against the same sessionID, so MCP, plugins, provider sockets stay
1239
+ // warm and the session's prompt cache survives.
1240
+ const result = await runPostRunRetryLoop({
1241
+ ctx,
1242
+ initialResult: initial,
1243
+ initialUsage: initial.usage,
1244
+ reflectionPrompt:
1245
+ ctx.toolState.learningsFilePath && shouldRunReflection(ctx.toolState.selectedMode)
1246
+ ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
1247
+ : undefined,
1248
+ resume: async (c) =>
1249
+ runTurnGuarded(runnerCtx, () =>
1250
+ runPromptTurn(runnerCtx, {
1251
+ text: c.prompt,
1252
+ model: sdkModel,
1253
+ signal: abortController.signal,
1254
+ }),
1255
+ ),
1256
+ });
1257
+
1258
+ // gate the todo-tracker flush on the post-run loop's final verdict
1259
+ // (`result.success`), not the initial turn — otherwise a Review that
1260
+ // exhausts the `unsubmittedReview` retry budget flips success to
1261
+ // false but the tracker still flushes "completed" tasks to GitHub.
1262
+ // mirrors the old `if (result.exitCode === 0)` discriminant.
1263
+ if (result.success) {
1264
+ await ctx.todoTracker?.flush();
1265
+ } else {
1266
+ ctx.todoTracker?.cancel();
1267
+ }
1268
+
1269
+ return result;
1270
+ } finally {
1271
+ watchdog.stop();
1272
+ abortController.abort();
1273
+ await eventLoopPromise.catch(() => {});
1274
+ }
1275
+ } finally {
1276
+ await server.close().catch((err) => {
1277
+ log.debug(
1278
+ `opencode server close failed: ${err instanceof Error ? err.message : String(err)}`,
1279
+ );
1280
+ });
1281
+ await dispatcher.close().catch(() => {});
1282
+ }
1283
+ },
1284
+ });
1285
+
1286
+ /**
1287
+ * Safety net around a single turn: convert any unexpected throw that escapes
1288
+ * `runPromptTurn` into a `success: false` result so the post-run gate loop
1289
+ * (which expects a result, not a rejection) can surface it through the generic
1290
+ * renderer.
1291
+ *
1292
+ * Watchdog-fired aborts do NOT reach here — `runPromptTurn` owns the abort
1293
+ * signal, catches the aborted `session.prompt` rejection internally, and
1294
+ * classifies it as an `activity timeout` error itself. This wrapper must not
1295
+ * re-classify, since a stray post-prompt throw is not a hang.
1296
+ */
1297
+ export async function runTurnGuarded(
1298
+ ctx: RunnerContext,
1299
+ fn: () => Promise<AgentResult>,
1300
+ ): Promise<AgentResult> {
1301
+ try {
1302
+ return await fn();
1303
+ } catch (err) {
1304
+ const errorMessage = err instanceof Error ? err.message : String(err);
1305
+ log.info(`» ${ctx.label} turn failed: ${errorMessage}`);
1306
+ return {
1307
+ success: false,
1308
+ output: ctx.currentTurn?.finalText ?? "",
1309
+ error: errorMessage,
1310
+ };
1311
+ }
1312
+ }