infraweaver 0.1.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 (524) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +244 -0
  3. package/dist/agents/claude.d.ts +113 -0
  4. package/dist/agents/claudePretoolGate.d.ts +137 -0
  5. package/dist/agents/dispatchMetrics.d.ts +77 -0
  6. package/dist/agents/gateServer.d.ts +7 -0
  7. package/dist/agents/index.d.ts +6 -0
  8. package/dist/agents/nativeFsDenies.d.ts +46 -0
  9. package/dist/agents/opencode.d.ts +284 -0
  10. package/dist/agents/opencodePlugin.d.ts +96 -0
  11. package/dist/agents/opencodeShared.d.ts +40 -0
  12. package/dist/agents/postRun.d.ts +126 -0
  13. package/dist/agents/reviewer.d.ts +41 -0
  14. package/dist/agents/sessionLabeler.d.ts +97 -0
  15. package/dist/agents/shared.d.ts +246 -0
  16. package/dist/agents/subagentModels.d.ts +19 -0
  17. package/dist/agents/tokenQuota.d.ts +118 -0
  18. package/dist/agents/writeGateSource.d.ts +22 -0
  19. package/dist/agents/writePolicy.d.ts +79 -0
  20. package/dist/brand.d.ts +67 -0
  21. package/dist/cli.mjs +247846 -0
  22. package/dist/external.d.ts +227 -0
  23. package/dist/i18n/scaffolding.d.ts +59 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +247061 -0
  26. package/dist/internal/index.d.ts +18 -0
  27. package/dist/internal.js +2398 -0
  28. package/dist/lifecycle.d.ts +2 -0
  29. package/dist/main.d.ts +8 -0
  30. package/dist/mcp/arkConfig.d.ts +1 -0
  31. package/dist/mcp/assess.d.ts +157 -0
  32. package/dist/mcp/capabilityContext.d.ts +71 -0
  33. package/dist/mcp/changeSummary.d.ts +52 -0
  34. package/dist/mcp/checkSuite.d.ts +27 -0
  35. package/dist/mcp/checkout.d.ts +92 -0
  36. package/dist/mcp/comment.d.ts +127 -0
  37. package/dist/mcp/commitInfo.d.ts +11 -0
  38. package/dist/mcp/crosswalk.d.ts +178 -0
  39. package/dist/mcp/crosswalkDigest.d.ts +1 -0
  40. package/dist/mcp/cyberEssentials.d.ts +24 -0
  41. package/dist/mcp/dashboard.d.ts +125 -0
  42. package/dist/mcp/dependencies.d.ts +12 -0
  43. package/dist/mcp/frameworks.d.ts +74 -0
  44. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  45. package/dist/mcp/git.d.ts +60 -0
  46. package/dist/mcp/guardrails.d.ts +222 -0
  47. package/dist/mcp/issue.d.ts +20 -0
  48. package/dist/mcp/issueComments.d.ts +11 -0
  49. package/dist/mcp/issueEvents.d.ts +11 -0
  50. package/dist/mcp/issueInfo.d.ts +11 -0
  51. package/dist/mcp/labels.d.ts +14 -0
  52. package/dist/mcp/localContext.d.ts +26 -0
  53. package/dist/mcp/moduleExtraction.d.ts +79 -0
  54. package/dist/mcp/moduleTests.d.ts +106 -0
  55. package/dist/mcp/modules.d.ts +198 -0
  56. package/dist/mcp/output.d.ts +16 -0
  57. package/dist/mcp/pathSafety.d.ts +14 -0
  58. package/dist/mcp/policy.d.ts +50 -0
  59. package/dist/mcp/pr.d.ts +64 -0
  60. package/dist/mcp/prInfo.d.ts +11 -0
  61. package/dist/mcp/providerSchema.d.ts +52 -0
  62. package/dist/mcp/review.d.ts +212 -0
  63. package/dist/mcp/reviewComments.d.ts +245 -0
  64. package/dist/mcp/roots.d.ts +60 -0
  65. package/dist/mcp/scope.d.ts +26 -0
  66. package/dist/mcp/selectMode.d.ts +20 -0
  67. package/dist/mcp/server.d.ts +61 -0
  68. package/dist/mcp/shared.d.ts +62 -0
  69. package/dist/mcp/shell.d.ts +58 -0
  70. package/dist/mcp/staleFix.d.ts +138 -0
  71. package/dist/mcp/terraform/azurePrices.d.ts +8 -0
  72. package/dist/mcp/terraform/baseline.d.ts +57 -0
  73. package/dist/mcp/terraform/concernResult.d.ts +38 -0
  74. package/dist/mcp/terraform/cost.d.ts +55 -0
  75. package/dist/mcp/terraform/cspm.d.ts +80 -0
  76. package/dist/mcp/terraform/currency.d.ts +110 -0
  77. package/dist/mcp/terraform/decisions.d.ts +178 -0
  78. package/dist/mcp/terraform/eidas.d.ts +100 -0
  79. package/dist/mcp/terraform/evidence.d.ts +343 -0
  80. package/dist/mcp/terraform/findings.d.ts +76 -0
  81. package/dist/mcp/terraform/fixMemory.d.ts +81 -0
  82. package/dist/mcp/terraform/governance.d.ts +56 -0
  83. package/dist/mcp/terraform/hcl.d.ts +115 -0
  84. package/dist/mcp/terraform/iacLanguages.d.ts +120 -0
  85. package/dist/mcp/terraform/idleFloor.d.ts +78 -0
  86. package/dist/mcp/terraform/keyless.d.ts +68 -0
  87. package/dist/mcp/terraform/moduleDocs.d.ts +56 -0
  88. package/dist/mcp/terraform/nativeRules.d.ts +38 -0
  89. package/dist/mcp/terraform/nativeScan.d.ts +11 -0
  90. package/dist/mcp/terraform/noiseProfile.d.ts +100 -0
  91. package/dist/mcp/terraform/normalization.d.ts +52 -0
  92. package/dist/mcp/terraform/oscal.d.ts +103 -0
  93. package/dist/mcp/terraform/packs/azurerm-3-4.d.ts +2 -0
  94. package/dist/mcp/terraform/packs/cloudflare-4-5.d.ts +2 -0
  95. package/dist/mcp/terraform/paths.d.ts +28 -0
  96. package/dist/mcp/terraform/plan.d.ts +157 -0
  97. package/dist/mcp/terraform/planDrift.d.ts +199 -0
  98. package/dist/mcp/terraform/policyAuthor.d.ts +191 -0
  99. package/dist/mcp/terraform/prowlerOcsf.d.ts +136 -0
  100. package/dist/mcp/terraform/refactor.d.ts +183 -0
  101. package/dist/mcp/terraform/registry.d.ts +108 -0
  102. package/dist/mcp/terraform/risk.d.ts +41 -0
  103. package/dist/mcp/terraform/scanSession.d.ts +116 -0
  104. package/dist/mcp/terraform/scannerCache.d.ts +15 -0
  105. package/dist/mcp/terraform/scannerEgress.d.ts +72 -0
  106. package/dist/mcp/terraform/scannerJson.d.ts +13 -0
  107. package/dist/mcp/terraform/scanners.d.ts +205 -0
  108. package/dist/mcp/terraform/signing.d.ts +61 -0
  109. package/dist/mcp/terraform/stateLocking.d.ts +18 -0
  110. package/dist/mcp/terraform/subprocess.d.ts +43 -0
  111. package/dist/mcp/terraform/suppressions.d.ts +90 -0
  112. package/dist/mcp/terraform/taxonomy.d.ts +39 -0
  113. package/dist/mcp/terraform/tfquery.d.ts +47 -0
  114. package/dist/mcp/terraform/toolchainIntegrity.d.ts +82 -0
  115. package/dist/mcp/terraform/tools/authorPolicy.d.ts +59 -0
  116. package/dist/mcp/terraform/tools/detectPlanDrift.d.ts +20 -0
  117. package/dist/mcp/terraform/tools/emitCost.d.ts +11 -0
  118. package/dist/mcp/terraform/tools/emitSarif.d.ts +14 -0
  119. package/dist/mcp/terraform/tools/fixMemory.d.ts +19 -0
  120. package/dist/mcp/terraform/tools/infracostDiff.d.ts +22 -0
  121. package/dist/mcp/terraform/tools/ingestExternalFindings.d.ts +21 -0
  122. package/dist/mcp/terraform/tools/moduleLookup.d.ts +14 -0
  123. package/dist/mcp/terraform/tools/moduleSearch.d.ts +23 -0
  124. package/dist/mcp/terraform/tools/plan.d.ts +59 -0
  125. package/dist/mcp/terraform/tools/providerUpgrade.d.ts +55 -0
  126. package/dist/mcp/terraform/tools/readFindings.d.ts +17 -0
  127. package/dist/mcp/terraform/tools/scan.d.ts +114 -0
  128. package/dist/mcp/terraform/tools/validate.d.ts +31 -0
  129. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +31 -0
  130. package/dist/mcp/terraform/tools/versionCurrency.d.ts +5 -0
  131. package/dist/mcp/terraform/tools.d.ts +16 -0
  132. package/dist/mcp/terraform/transformPacks.d.ts +267 -0
  133. package/dist/mcp/terraform/types.d.ts +226 -0
  134. package/dist/mcp/terraform/verification.d.ts +81 -0
  135. package/dist/mcp/terraform/vex.d.ts +115 -0
  136. package/dist/mcp/terraform/writeOnly.d.ts +67 -0
  137. package/dist/mcp/terraform.d.ts +51 -0
  138. package/dist/mcp/terratest.d.ts +85 -0
  139. package/dist/mcp/untrustedContent.d.ts +76 -0
  140. package/dist/mcp/upload.d.ts +8 -0
  141. package/dist/models.d.ts +181 -0
  142. package/dist/modes/address-reviews.d.ts +2 -0
  143. package/dist/modes/assess.d.ts +2 -0
  144. package/dist/modes/build.d.ts +2 -0
  145. package/dist/modes/compliance-audit.d.ts +2 -0
  146. package/dist/modes/cost-optimization.d.ts +2 -0
  147. package/dist/modes/drift-detect.d.ts +2 -0
  148. package/dist/modes/fix.d.ts +2 -0
  149. package/dist/modes/incremental-review.d.ts +2 -0
  150. package/dist/modes/index.d.ts +24 -0
  151. package/dist/modes/modernize-deprecated.d.ts +2 -0
  152. package/dist/modes/plan.d.ts +2 -0
  153. package/dist/modes/policy-gate.d.ts +2 -0
  154. package/dist/modes/prFormats.d.ts +3 -0
  155. package/dist/modes/refactor.d.ts +2 -0
  156. package/dist/modes/refresh-remediation.d.ts +2 -0
  157. package/dist/modes/remediate-and-refactor.d.ts +2 -0
  158. package/dist/modes/remediate.d.ts +2 -0
  159. package/dist/modes/resolve-conflicts.d.ts +2 -0
  160. package/dist/modes/review.d.ts +2 -0
  161. package/dist/modes/summarize-pr.d.ts +2 -0
  162. package/dist/modes/task.d.ts +2 -0
  163. package/dist/modes/terraform-code-review.d.ts +2 -0
  164. package/dist/modes/types.d.ts +9 -0
  165. package/dist/modes/update-dependencies.d.ts +2 -0
  166. package/dist/prep/index.d.ts +7 -0
  167. package/dist/prep/installNodeDependencies.d.ts +2 -0
  168. package/dist/prep/installPythonDependencies.d.ts +2 -0
  169. package/dist/prep/types.d.ts +31 -0
  170. package/dist/reviewQuality.d.ts +107 -0
  171. package/dist/skills/terraform-best-practices/SKILL.md +379 -0
  172. package/dist/toolState.d.ts +123 -0
  173. package/dist/utils/activity.d.ts +40 -0
  174. package/dist/utils/agent.d.ts +47 -0
  175. package/dist/utils/agentHangReport.d.ts +38 -0
  176. package/dist/utils/annotations.d.ts +17 -0
  177. package/dist/utils/apiFetch.d.ts +11 -0
  178. package/dist/utils/apiKeys.d.ts +48 -0
  179. package/dist/utils/apiUrl.d.ts +28 -0
  180. package/dist/utils/assets.d.ts +8 -0
  181. package/dist/utils/baseRefConfig.d.ts +121 -0
  182. package/dist/utils/billingErrors.d.ts +85 -0
  183. package/dist/utils/body.d.ts +34 -0
  184. package/dist/utils/buildInfraweaverFooter.d.ts +29 -0
  185. package/dist/utils/byokFallback.d.ts +85 -0
  186. package/dist/utils/changeImpact.d.ts +101 -0
  187. package/dist/utils/claudeSubscription.d.ts +30 -0
  188. package/dist/utils/cli.d.ts +10 -0
  189. package/dist/utils/cloudClient.d.ts +33 -0
  190. package/dist/utils/cloudFetchCore.d.ts +42 -0
  191. package/dist/utils/cloudReport.d.ts +114 -0
  192. package/dist/utils/codexHome.d.ts +29 -0
  193. package/dist/utils/codexOAuth.d.ts +60 -0
  194. package/dist/utils/diffCoverage.d.ts +63 -0
  195. package/dist/utils/env.d.ts +46 -0
  196. package/dist/utils/errorReport.d.ts +17 -0
  197. package/dist/utils/exitHandler.d.ts +8 -0
  198. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  199. package/dist/utils/gitAuth.d.ts +84 -0
  200. package/dist/utils/gitAuthServer.d.ts +24 -0
  201. package/dist/utils/github.d.ts +104 -0
  202. package/dist/utils/globals.d.ts +3 -0
  203. package/dist/utils/infraweaverConfig.d.ts +56 -0
  204. package/dist/utils/install.d.ts +37 -0
  205. package/dist/utils/instructions.d.ts +48 -0
  206. package/dist/utils/keylessOidc.d.ts +28 -0
  207. package/dist/utils/leapingComment.d.ts +11 -0
  208. package/dist/utils/learnings.d.ts +62 -0
  209. package/dist/utils/learningsTruncate.d.ts +25 -0
  210. package/dist/utils/lifecycle.d.ts +57 -0
  211. package/dist/utils/log.d.ts +111 -0
  212. package/dist/utils/modelResolver.d.ts +63 -0
  213. package/dist/utils/moduleFetch.d.ts +39 -0
  214. package/dist/utils/normalizeEnv.d.ts +30 -0
  215. package/dist/utils/openCodeModels.d.ts +11 -0
  216. package/dist/utils/openaiCompatible.d.ts +26 -0
  217. package/dist/utils/opentofuMcp.d.ts +50 -0
  218. package/dist/utils/overrides.d.ts +40 -0
  219. package/dist/utils/packageManager.d.ts +49 -0
  220. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  221. package/dist/utils/payload.d.ts +249 -0
  222. package/dist/utils/prSummary.d.ts +61 -0
  223. package/dist/utils/presets.d.ts +36 -0
  224. package/dist/utils/progressComment.d.ts +146 -0
  225. package/dist/utils/providerErrors.d.ts +31 -0
  226. package/dist/utils/proxyModel.d.ts +76 -0
  227. package/dist/utils/rangeDiff.d.ts +51 -0
  228. package/dist/utils/redact.d.ts +33 -0
  229. package/dist/utils/registryAuth.d.ts +52 -0
  230. package/dist/utils/remediationCommand.d.ts +58 -0
  231. package/dist/utils/retry.d.ts +38 -0
  232. package/dist/utils/reviewCleanup.d.ts +14 -0
  233. package/dist/utils/run.d.ts +9 -0
  234. package/dist/utils/runContext.d.ts +112 -0
  235. package/dist/utils/runContextData.d.ts +42 -0
  236. package/dist/utils/runErrorRenderer.d.ts +64 -0
  237. package/dist/utils/runLifecycle.d.ts +89 -0
  238. package/dist/utils/runStartupLog.d.ts +15 -0
  239. package/dist/utils/secrets.d.ts +34 -0
  240. package/dist/utils/setup.d.ts +90 -0
  241. package/dist/utils/shell.d.ts +44 -0
  242. package/dist/utils/skills.d.ts +10 -0
  243. package/dist/utils/subprocess.d.ts +81 -0
  244. package/dist/utils/terraformMcp.d.ts +46 -0
  245. package/dist/utils/time.d.ts +15 -0
  246. package/dist/utils/timer.d.ts +23 -0
  247. package/dist/utils/todoTracking.d.ts +16 -0
  248. package/dist/utils/token.d.ts +52 -0
  249. package/dist/utils/toolLicensing.d.ts +56 -0
  250. package/dist/utils/toolSelection.d.ts +73 -0
  251. package/dist/utils/toon.d.ts +16 -0
  252. package/dist/utils/version.d.ts +2 -0
  253. package/dist/utils/versioning.d.ts +7 -0
  254. package/dist/utils/vertex.d.ts +16 -0
  255. package/dist/utils/workflow.d.ts +13 -0
  256. package/package.json +132 -0
  257. package/src/agents/claude.ts +1588 -0
  258. package/src/agents/claudePretoolGate.ts +286 -0
  259. package/src/agents/dispatchMetrics.ts +162 -0
  260. package/src/agents/gateServer.ts +178 -0
  261. package/src/agents/index.ts +10 -0
  262. package/src/agents/nativeFsDenies.ts +164 -0
  263. package/src/agents/opencode.ts +1600 -0
  264. package/src/agents/opencodePlugin.ts +304 -0
  265. package/src/agents/opencodeShared.ts +134 -0
  266. package/src/agents/postRun.ts +615 -0
  267. package/src/agents/reviewer.ts +134 -0
  268. package/src/agents/sessionLabeler.ts +185 -0
  269. package/src/agents/shared.ts +377 -0
  270. package/src/agents/subagentModels.ts +40 -0
  271. package/src/agents/tokenQuota.ts +203 -0
  272. package/src/agents/writeGateSource.ts +148 -0
  273. package/src/agents/writePolicy.ts +132 -0
  274. package/src/brand.ts +84 -0
  275. package/src/cli.ts +117 -0
  276. package/src/commands/gha.ts +188 -0
  277. package/src/commands/mcp.ts +126 -0
  278. package/src/commands/verifyEvidence.ts +100 -0
  279. package/src/entry.ts +7 -0
  280. package/src/entryPost.ts +109 -0
  281. package/src/external.ts +308 -0
  282. package/src/i18n/scaffolding.ts +191 -0
  283. package/src/index.ts +7 -0
  284. package/src/internal/index.ts +74 -0
  285. package/src/lifecycle.ts +2 -0
  286. package/src/main.ts +815 -0
  287. package/src/mcp/__fixtures__/infraweaver-scratch-pr-49-review-3485940013.json +110 -0
  288. package/src/mcp/__fixtures__/infraweaver-scratch-pr-64-review-3531000326.json +14 -0
  289. package/src/mcp/__fixtures__/infraweaver-test-repo-pr-1.diff.json +67 -0
  290. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  291. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  292. package/src/mcp/arkConfig.ts +7 -0
  293. package/src/mcp/assess.ts +723 -0
  294. package/src/mcp/capabilityContext.ts +80 -0
  295. package/src/mcp/changeSummary.ts +145 -0
  296. package/src/mcp/checkSuite.ts +261 -0
  297. package/src/mcp/checkout.ts +1015 -0
  298. package/src/mcp/comment.ts +683 -0
  299. package/src/mcp/commitInfo.ts +57 -0
  300. package/src/mcp/crosswalk.ts +1253 -0
  301. package/src/mcp/crosswalkDigest.ts +5 -0
  302. package/src/mcp/cyberEssentials.ts +60 -0
  303. package/src/mcp/dashboard.ts +232 -0
  304. package/src/mcp/dependencies.ts +190 -0
  305. package/src/mcp/frameworks.ts +236 -0
  306. package/src/mcp/geminiSanitizer.ts +212 -0
  307. package/src/mcp/git.ts +1116 -0
  308. package/src/mcp/guardrails.ts +771 -0
  309. package/src/mcp/issue.ts +74 -0
  310. package/src/mcp/issueComments.ts +48 -0
  311. package/src/mcp/issueEvents.ts +100 -0
  312. package/src/mcp/issueInfo.ts +72 -0
  313. package/src/mcp/labels.ts +35 -0
  314. package/src/mcp/localContext.ts +65 -0
  315. package/src/mcp/localServer.ts +217 -0
  316. package/src/mcp/moduleExtraction.ts +368 -0
  317. package/src/mcp/moduleTests.ts +421 -0
  318. package/src/mcp/modules.ts +752 -0
  319. package/src/mcp/output.ts +71 -0
  320. package/src/mcp/pathSafety.ts +28 -0
  321. package/src/mcp/policy.ts +226 -0
  322. package/src/mcp/pr.ts +238 -0
  323. package/src/mcp/prInfo.ts +91 -0
  324. package/src/mcp/providerSchema.ts +175 -0
  325. package/src/mcp/review.ts +1081 -0
  326. package/src/mcp/reviewComments.ts +1137 -0
  327. package/src/mcp/roots.ts +217 -0
  328. package/src/mcp/scope.ts +82 -0
  329. package/src/mcp/selectMode.ts +207 -0
  330. package/src/mcp/server.ts +498 -0
  331. package/src/mcp/shared.ts +120 -0
  332. package/src/mcp/shell.ts +631 -0
  333. package/src/mcp/staleFix.ts +557 -0
  334. package/src/mcp/terraform/__snapshots__/moduleDocs.test.ts.snap +40 -0
  335. package/src/mcp/terraform/azurePrices.ts +38 -0
  336. package/src/mcp/terraform/baseline.ts +172 -0
  337. package/src/mcp/terraform/concernResult.ts +111 -0
  338. package/src/mcp/terraform/cost.ts +175 -0
  339. package/src/mcp/terraform/cspm.ts +326 -0
  340. package/src/mcp/terraform/currency.ts +354 -0
  341. package/src/mcp/terraform/decisions.ts +590 -0
  342. package/src/mcp/terraform/eidas.ts +134 -0
  343. package/src/mcp/terraform/evidence.ts +771 -0
  344. package/src/mcp/terraform/findings.ts +383 -0
  345. package/src/mcp/terraform/fixMemory.ts +253 -0
  346. package/src/mcp/terraform/governance.ts +231 -0
  347. package/src/mcp/terraform/hcl.ts +273 -0
  348. package/src/mcp/terraform/iacLanguages.ts +431 -0
  349. package/src/mcp/terraform/idleFloor.ts +304 -0
  350. package/src/mcp/terraform/keyless.ts +123 -0
  351. package/src/mcp/terraform/moduleDocs.ts +303 -0
  352. package/src/mcp/terraform/nativeRules.ts +152 -0
  353. package/src/mcp/terraform/nativeScan.ts +67 -0
  354. package/src/mcp/terraform/noiseProfile.ts +183 -0
  355. package/src/mcp/terraform/normalization.ts +265 -0
  356. package/src/mcp/terraform/oscal.ts +283 -0
  357. package/src/mcp/terraform/packs/azurerm-3-4.ts +382 -0
  358. package/src/mcp/terraform/packs/cloudflare-4-5.ts +114 -0
  359. package/src/mcp/terraform/paths.ts +59 -0
  360. package/src/mcp/terraform/plan.ts +377 -0
  361. package/src/mcp/terraform/planDrift.ts +520 -0
  362. package/src/mcp/terraform/policyAuthor.ts +886 -0
  363. package/src/mcp/terraform/prowlerOcsf.ts +380 -0
  364. package/src/mcp/terraform/refactor.ts +879 -0
  365. package/src/mcp/terraform/registry.ts +317 -0
  366. package/src/mcp/terraform/risk.ts +99 -0
  367. package/src/mcp/terraform/scanSession.ts +242 -0
  368. package/src/mcp/terraform/scannerCache.ts +86 -0
  369. package/src/mcp/terraform/scannerEgress.ts +141 -0
  370. package/src/mcp/terraform/scannerJson.ts +22 -0
  371. package/src/mcp/terraform/scanners.ts +1134 -0
  372. package/src/mcp/terraform/signing.ts +151 -0
  373. package/src/mcp/terraform/stateLocking.ts +106 -0
  374. package/src/mcp/terraform/subprocess.ts +102 -0
  375. package/src/mcp/terraform/suppressions.ts +551 -0
  376. package/src/mcp/terraform/taxonomy.ts +0 -0
  377. package/src/mcp/terraform/tfquery.ts +159 -0
  378. package/src/mcp/terraform/toolchainIntegrity.ts +151 -0
  379. package/src/mcp/terraform/tools/authorPolicy.ts +185 -0
  380. package/src/mcp/terraform/tools/detectPlanDrift.ts +279 -0
  381. package/src/mcp/terraform/tools/emitCost.ts +130 -0
  382. package/src/mcp/terraform/tools/emitSarif.ts +108 -0
  383. package/src/mcp/terraform/tools/fixMemory.ts +82 -0
  384. package/src/mcp/terraform/tools/infracostDiff.ts +110 -0
  385. package/src/mcp/terraform/tools/ingestExternalFindings.ts +259 -0
  386. package/src/mcp/terraform/tools/moduleLookup.ts +86 -0
  387. package/src/mcp/terraform/tools/moduleSearch.ts +46 -0
  388. package/src/mcp/terraform/tools/plan.ts +384 -0
  389. package/src/mcp/terraform/tools/providerUpgrade.ts +217 -0
  390. package/src/mcp/terraform/tools/readFindings.ts +138 -0
  391. package/src/mcp/terraform/tools/scan.ts +303 -0
  392. package/src/mcp/terraform/tools/validate.ts +160 -0
  393. package/src/mcp/terraform/tools/verifyRemediation.ts +190 -0
  394. package/src/mcp/terraform/tools/versionCurrency.ts +64 -0
  395. package/src/mcp/terraform/tools.ts +62 -0
  396. package/src/mcp/terraform/transformPacks.ts +594 -0
  397. package/src/mcp/terraform/types.ts +382 -0
  398. package/src/mcp/terraform/verification.ts +150 -0
  399. package/src/mcp/terraform/vex.ts +367 -0
  400. package/src/mcp/terraform/writeOnly.ts +252 -0
  401. package/src/mcp/terraform.ts +52 -0
  402. package/src/mcp/terratest.ts +215 -0
  403. package/src/mcp/untrustedContent.ts +126 -0
  404. package/src/mcp/upload.ts +123 -0
  405. package/src/models.ts +753 -0
  406. package/src/modes/address-reviews.ts +35 -0
  407. package/src/modes/assess.ts +27 -0
  408. package/src/modes/build.ts +86 -0
  409. package/src/modes/compliance-audit.ts +25 -0
  410. package/src/modes/cost-optimization.ts +36 -0
  411. package/src/modes/drift-detect.ts +31 -0
  412. package/src/modes/fix.ts +29 -0
  413. package/src/modes/incremental-review.ts +103 -0
  414. package/src/modes/index.ts +94 -0
  415. package/src/modes/modernize-deprecated.ts +36 -0
  416. package/src/modes/plan.ts +21 -0
  417. package/src/modes/policy-gate.ts +27 -0
  418. package/src/modes/prFormats.ts +325 -0
  419. package/src/modes/refactor.ts +74 -0
  420. package/src/modes/refresh-remediation.ts +40 -0
  421. package/src/modes/remediate-and-refactor.ts +42 -0
  422. package/src/modes/remediate.ts +78 -0
  423. package/src/modes/resolve-conflicts.ts +32 -0
  424. package/src/modes/review.ts +135 -0
  425. package/src/modes/summarize-pr.ts +42 -0
  426. package/src/modes/task.ts +26 -0
  427. package/src/modes/terraform-code-review.ts +68 -0
  428. package/src/modes/types.ts +17 -0
  429. package/src/modes/update-dependencies.ts +44 -0
  430. package/src/prep/index.ts +96 -0
  431. package/src/prep/installNodeDependencies.ts +251 -0
  432. package/src/prep/installPythonDependencies.ts +235 -0
  433. package/src/prep/types.ts +38 -0
  434. package/src/reviewQuality.ts +237 -0
  435. package/src/runCli.ts +335 -0
  436. package/src/skills/terraform-best-practices/SKILL.md +379 -0
  437. package/src/toolState.ts +241 -0
  438. package/src/utils/activity.ts +210 -0
  439. package/src/utils/agent.ts +245 -0
  440. package/src/utils/agentHangReport.ts +180 -0
  441. package/src/utils/annotations.ts +56 -0
  442. package/src/utils/apiFetch.ts +19 -0
  443. package/src/utils/apiKeys.ts +305 -0
  444. package/src/utils/apiUrl.ts +42 -0
  445. package/src/utils/assets.ts +123 -0
  446. package/src/utils/baseRefConfig.ts +254 -0
  447. package/src/utils/billingErrors.ts +210 -0
  448. package/src/utils/body.ts +168 -0
  449. package/src/utils/buildInfraweaverFooter.ts +104 -0
  450. package/src/utils/byokFallback.ts +137 -0
  451. package/src/utils/changeImpact.ts +330 -0
  452. package/src/utils/claudeSubscription.ts +93 -0
  453. package/src/utils/cli.ts +36 -0
  454. package/src/utils/cloudClient.ts +57 -0
  455. package/src/utils/cloudFetchCore.ts +139 -0
  456. package/src/utils/cloudReport.ts +316 -0
  457. package/src/utils/codexHome.ts +204 -0
  458. package/src/utils/codexOAuth.ts +154 -0
  459. package/src/utils/codexRefreshDetect.ts +36 -0
  460. package/src/utils/diffCoverage.ts +404 -0
  461. package/src/utils/env.ts +84 -0
  462. package/src/utils/errorReport.ts +94 -0
  463. package/src/utils/exitHandler.ts +35 -0
  464. package/src/utils/fixDoubleEscapedString.ts +9 -0
  465. package/src/utils/ghaCore.ts +13 -0
  466. package/src/utils/gitAuth.ts +268 -0
  467. package/src/utils/gitAuthServer.ts +186 -0
  468. package/src/utils/github.ts +676 -0
  469. package/src/utils/globals.ts +9 -0
  470. package/src/utils/humanEditCapture.ts +198 -0
  471. package/src/utils/infraweaverConfig.ts +202 -0
  472. package/src/utils/install.ts +279 -0
  473. package/src/utils/instructions.ts +640 -0
  474. package/src/utils/keylessOidc.ts +47 -0
  475. package/src/utils/leapingComment.ts +20 -0
  476. package/src/utils/learnings.ts +145 -0
  477. package/src/utils/learningsTruncate.ts +42 -0
  478. package/src/utils/lifecycle.ts +198 -0
  479. package/src/utils/log.ts +452 -0
  480. package/src/utils/modelResolver.ts +105 -0
  481. package/src/utils/moduleFetch.ts +138 -0
  482. package/src/utils/normalizeEnv.ts +106 -0
  483. package/src/utils/openCodeModels.ts +86 -0
  484. package/src/utils/openaiCompatible.ts +52 -0
  485. package/src/utils/opentofuMcp.ts +61 -0
  486. package/src/utils/overrides.ts +100 -0
  487. package/src/utils/packageManager.ts +257 -0
  488. package/src/utils/patchWorkflowRunFields.ts +173 -0
  489. package/src/utils/payload.ts +1206 -0
  490. package/src/utils/prSummary.ts +148 -0
  491. package/src/utils/presets.ts +105 -0
  492. package/src/utils/progressComment.ts +266 -0
  493. package/src/utils/providerErrors.ts +189 -0
  494. package/src/utils/proxyModel.ts +239 -0
  495. package/src/utils/rangeDiff.ts +182 -0
  496. package/src/utils/redact.ts +86 -0
  497. package/src/utils/registryAuth.ts +115 -0
  498. package/src/utils/remediationCommand.ts +144 -0
  499. package/src/utils/retry.ts +180 -0
  500. package/src/utils/reviewCleanup.ts +116 -0
  501. package/src/utils/run.ts +100 -0
  502. package/src/utils/runContext.ts +276 -0
  503. package/src/utils/runContextData.ts +121 -0
  504. package/src/utils/runErrorRenderer.ts +282 -0
  505. package/src/utils/runFixture.ts +91 -0
  506. package/src/utils/runLifecycle.ts +360 -0
  507. package/src/utils/runStartupLog.ts +67 -0
  508. package/src/utils/secrets.ts +208 -0
  509. package/src/utils/setup.ts +368 -0
  510. package/src/utils/shell.ts +132 -0
  511. package/src/utils/skills.ts +67 -0
  512. package/src/utils/subprocess.ts +474 -0
  513. package/src/utils/terraformMcp.ts +99 -0
  514. package/src/utils/time.ts +59 -0
  515. package/src/utils/timer.ts +72 -0
  516. package/src/utils/todoTracking.ts +168 -0
  517. package/src/utils/token.ts +294 -0
  518. package/src/utils/toolLicensing.ts +152 -0
  519. package/src/utils/toolSelection.ts +239 -0
  520. package/src/utils/toon.ts +76 -0
  521. package/src/utils/version.ts +10 -0
  522. package/src/utils/versioning.ts +44 -0
  523. package/src/utils/vertex.ts +94 -0
  524. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,1600 @@
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 Infraweaver 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 Infraweaver server injected via `mcp.<name> = { type: "remote", url }`
39
+ * - ASKPASS for git auth
40
+ * - codex auth materialization + post-hook writeback
41
+ * - loupe 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 { setTimeout as sleep } from "node:timers/promises";
51
+ import * as core from "@actions/core";
52
+ import {
53
+ type AssistantMessage,
54
+ createOpencodeClient,
55
+ type EventSubscribeResponse,
56
+ type OpencodeClient,
57
+ type Part,
58
+ type TextPartInput,
59
+ } from "@opencode-ai/sdk/v2";
60
+ import { Agent, fetch as undiciFetch } from "undici";
61
+ import {
62
+ GIT_NATIVE_READ_DENY_OPENCODE,
63
+ GIT_NATIVE_WRITE_DENY_OPENCODE,
64
+ } from "#app/agents/nativeFsDenies";
65
+ import {
66
+ buildOpencodeSubagentGateSource,
67
+ INFRAWEAVER_OPENCODE_GATE_PLUGIN_FILENAME,
68
+ } from "#app/agents/opencodePlugin";
69
+ import {
70
+ autoSelectModel,
71
+ buildReviewerAgentConfig,
72
+ geminiHighThinkingOverrides,
73
+ installOpencodeCli,
74
+ type OpenCodeConfig,
75
+ } from "#app/agents/opencodeShared";
76
+ import {
77
+ buildLearningsReflectionPrompt,
78
+ runPostRunRetryLoop,
79
+ shouldRunReflection,
80
+ } from "#app/agents/postRun";
81
+ import { REVIEWER_AGENT_NAME } from "#app/agents/reviewer";
82
+ import { writePolicyPath } from "#app/agents/writePolicy";
83
+ import {
84
+ formatWithLabel,
85
+ ORCHESTRATOR_LABEL,
86
+ SessionLabeler,
87
+ } from "#app/agents/sessionLabeler";
88
+ import {
89
+ type AgentResult,
90
+ type AgentRunContext,
91
+ type AgentUsage,
92
+ agent,
93
+ agentHomeEnv,
94
+ baseAgentEnv,
95
+ buildAgentUsage,
96
+ logTokenTable,
97
+ MAX_STDERR_LINES,
98
+ MCP_SERVER_TOKEN_ENV,
99
+ } from "#app/agents/shared";
100
+ import {
101
+ type RunTokenQuota,
102
+ TokenQuotaExceededError,
103
+ } from "#app/agents/tokenQuota";
104
+ import { PRODUCT_NAME } from "#app/brand";
105
+ import { infraweaverMcpName } from "#app/external";
106
+ import { BEDROCK_MODEL_ID_ENV } from "#app/models";
107
+ import type { ToolState } from "#app/toolState";
108
+ import { AGENT_ACTIVITY_TIMEOUT_MS, markActivity } from "#app/utils/activity";
109
+ import type { AgentDiagnostic } from "#app/utils/agentHangReport";
110
+ import { formatJsonValue, log } from "#app/utils/cli";
111
+ import { installCodexAuth } from "#app/utils/codexHome";
112
+ import {
113
+ OPENAI_COMPATIBLE_PROVIDER,
114
+ openAiCompatibleProviderBlock,
115
+ resolveOpenAiCompatibleOpenCodeModel,
116
+ } from "#app/utils/openaiCompatible";
117
+ import { findProviderErrorMatch } from "#app/utils/providerErrors";
118
+ import { installBundledSkills } from "#app/utils/skills";
119
+ import { trackChild, untrackChild } from "#app/utils/subprocess";
120
+ import {
121
+ OPENTOFU_MCP_SERVER_NAME,
122
+ resolveOpenTofuMcp,
123
+ } from "#app/utils/opentofuMcp";
124
+ import {
125
+ resolveTerraformMcp,
126
+ TERRAFORM_MCP_SERVER_NAME,
127
+ } from "#app/utils/terraformMcp";
128
+ import type { TodoTracker } from "#app/utils/todoTracking";
129
+ import { resolveVertexOpenCodeModel } from "#app/utils/vertex";
130
+
131
+ const installCli = () => installOpencodeCli({ binPath: "bin/opencode.exe" });
132
+
133
+ // ── config ─────────────────────────────────────────────────────────────────────
134
+
135
+ export function buildSecurityConfig(
136
+ ctx: AgentRunContext,
137
+ model: string | undefined,
138
+ ): string {
139
+ // opt-in second server: HashiCorp's terraform-mcp-server (registry
140
+ // toolset, docker stdio) for live module/provider knowledge.
141
+ const terraformMcp = resolveTerraformMcp(ctx.payload);
142
+ if (terraformMcp.kind === "docker_missing")
143
+ log.info(`» ${terraformMcp.note}`);
144
+ // opt-in sibling: the OpenTofu registry MCP server (hosted HTTP, no docker).
145
+ const opentofuMcp = resolveOpenTofuMcp(ctx.payload);
146
+ const config: OpenCodeConfig = {
147
+ permission: {
148
+ bash: "deny",
149
+ edit: "allow",
150
+ read: "allow",
151
+ webfetch: "allow",
152
+ external_directory: "allow",
153
+ skill: "allow",
154
+ },
155
+ mcp: {
156
+ // 300s tool timeout (vs the MCP SDK's 60s default). `checkout_pr` runs a
157
+ // multi-minute `git fetch` on large repos (remotion); a 60s client abort
158
+ // surfaces as `MCP error -32001` and used to push the agent toward
159
+ // deleting live git locks (the corruption in #860/#864 — the dangerous
160
+ // `rm` guidance is gone, but the spurious aborts shouldn't happen either).
161
+ // server-side cap is 600s (`checkout_pr` `timeoutMs`).
162
+ // Authorization carries the per-run MCP bearer token. opencode expands
163
+ // `{env:VAR}` in remote-MCP header values (opencode.ai/docs/mcp-servers),
164
+ // so the config holds only the placeholder; the raw token reaches the
165
+ // opencode server via MCP_SERVER_TOKEN_ENV on its spawn env (below).
166
+ [infraweaverMcpName]: {
167
+ type: "remote",
168
+ url: ctx.mcpServerUrl,
169
+ headers: { Authorization: `Bearer {env:${MCP_SERVER_TOKEN_ENV}}` },
170
+ timeout: 300_000,
171
+ },
172
+ ...(terraformMcp.kind === "available"
173
+ ? {
174
+ [TERRAFORM_MCP_SERVER_NAME]: {
175
+ type: "local",
176
+ command: [terraformMcp.command, ...terraformMcp.args],
177
+ enabled: true,
178
+ },
179
+ }
180
+ : {}),
181
+ ...(opentofuMcp.kind === "available"
182
+ ? {
183
+ [OPENTOFU_MCP_SERVER_NAME]: {
184
+ type: "remote",
185
+ url: opentofuMcp.url,
186
+ enabled: true,
187
+ },
188
+ }
189
+ : {}),
190
+ },
191
+ agent: (() => {
192
+ const cfg = buildReviewerAgentConfig(model);
193
+ const reviewerModel =
194
+ (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
195
+ log.info(`» subagent models: ${REVIEWER_AGENT_NAME}=${reviewerModel}`);
196
+ return cfg;
197
+ })(),
198
+ // gemini-3 thinking pinned to high for review depth; gpt and anthropic
199
+ // effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
200
+ provider: { google: { models: geminiHighThinkingOverrides() } },
201
+ };
202
+
203
+ if (model) {
204
+ config.model = model;
205
+ const slashIndex = model.indexOf("/");
206
+ if (slashIndex > 0) {
207
+ config.enabled_providers = [model.slice(0, slashIndex).toLowerCase()];
208
+ // NOTE: the old `openrouter/moonshotai/` provider-pin (`@openrouter/ai-sdk-provider@2.9.0`)
209
+ // that worked around the kimi-k2.6 duplicate-tool-call stall is gone — opencode 1.17.9
210
+ // bundles a fixed provider (the fix landed for opencode ≥1.16.2; upstream pullfrog
211
+ // dropped the pin too). Redeclaring the provider here would only cost an on-demand
212
+ // Npm.add and discard opencode's own `OPENROUTER_API_KEY` env auto-detection.
213
+ }
214
+ // openai-compatible BYOK: register the user's endpoint as a custom provider
215
+ // (baseURL + apiKey + the single model id) via the AI SDK adapter. Only fires
216
+ // for the `openai-compatible/<id>` model shape, so other routes are untouched.
217
+ if (model.startsWith(`${OPENAI_COMPATIBLE_PROVIDER}/`)) {
218
+ const block = openAiCompatibleProviderBlock();
219
+ if (block) config.provider = { ...config.provider, ...block };
220
+ }
221
+ }
222
+
223
+ return JSON.stringify(config);
224
+ }
225
+
226
+ /** split `<providerID>/<modelID>` into the SDK's prompt model shape. */
227
+ export function parseModel(
228
+ value: string | undefined,
229
+ ): { providerID: string; modelID: string } | undefined {
230
+ if (!value) return undefined;
231
+ const slash = value.indexOf("/");
232
+ if (slash <= 0) return undefined;
233
+ return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) };
234
+ }
235
+
236
+ // ── server boot ────────────────────────────────────────────────────────────────
237
+
238
+ interface ServerHandle {
239
+ baseUrl: string;
240
+ proc: ChildProcess;
241
+ /** kill the server; idempotent. */
242
+ close: () => Promise<void>;
243
+ /** rolling tail of server stderr for diagnostics. */
244
+ recentStderr: string[];
245
+ }
246
+
247
+ /**
248
+ * Spawn `<cliPath> serve --port 0 --hostname 127.0.0.1` and wait for the
249
+ * "opencode server listening on http://..." stdout line.
250
+ *
251
+ * Direct node:child_process.spawn instead of our `spawn()` wrapper because
252
+ * the wrapper's contract is "Promise<SpawnResult> that resolves on exit" —
253
+ * we need a handle that stays alive across many session.prompt() calls.
254
+ * We still register with `trackChild()` so Ctrl-C kills the server alongside
255
+ * everything else.
256
+ */
257
+ export function bootOpencodeServer(params: {
258
+ cliPath: string;
259
+ env: NodeJS.ProcessEnv;
260
+ cwd: string;
261
+ }): Promise<ServerHandle> {
262
+ const proc = nodeSpawn(
263
+ params.cliPath,
264
+ ["serve", "--port", "0", "--hostname", "127.0.0.1"],
265
+ {
266
+ cwd: params.cwd,
267
+ env: params.env,
268
+ stdio: ["ignore", "pipe", "pipe"],
269
+ // detached + killGroup so SIGKILL nukes the whole tree: node_modules/
270
+ // opencode-ai/bin/opencode is a Node shim that spawnSync's the native
271
+ // binary; without process-group kill the native binary is reparented
272
+ // to PID 1 and never dies. mirrors the same fix in runOpenCode's
273
+ // original spawn().
274
+ detached: true,
275
+ },
276
+ );
277
+ trackChild({ child: proc, killGroup: true });
278
+
279
+ const recentStderr: string[] = [];
280
+ proc.stderr?.on("data", (chunk: Buffer) => {
281
+ const text = chunk.toString().trim();
282
+ if (!text) return;
283
+ recentStderr.push(text);
284
+ if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
285
+ log.debug(`[opencode serve] ${text}`);
286
+ });
287
+
288
+ let closed = false;
289
+ const close = async (): Promise<void> => {
290
+ if (closed) return;
291
+ closed = true;
292
+ untrackChild(proc);
293
+ // already exited (server crashed / was killed): its `close` event fired
294
+ // before this listener could attach, so awaiting `proc.once("close")`
295
+ // would hang forever. nothing left to kill — return immediately.
296
+ if (proc.exitCode !== null || proc.signalCode !== null || !proc.pid) return;
297
+ try {
298
+ process.kill(-proc.pid, "SIGTERM");
299
+ } catch {
300
+ proc.kill("SIGTERM");
301
+ }
302
+ // give the server 2s to exit cleanly, then SIGKILL the group. resolve on
303
+ // whichever comes first — the `close` event OR the escalation timer — so a
304
+ // handle whose `close` never re-fires (dead process) can't wedge teardown.
305
+ await new Promise<void>((resolve) => {
306
+ let settled = false;
307
+ const done = () => {
308
+ if (settled) return;
309
+ settled = true;
310
+ clearTimeout(escalator);
311
+ resolve();
312
+ };
313
+ const escalator = setTimeout(() => {
314
+ if (proc.exitCode === null && proc.signalCode === null) {
315
+ try {
316
+ process.kill(-proc.pid!, "SIGKILL");
317
+ } catch {
318
+ proc.kill("SIGKILL");
319
+ }
320
+ }
321
+ done();
322
+ }, 2000);
323
+ proc.once("close", done);
324
+ });
325
+ };
326
+
327
+ return new Promise<ServerHandle>((resolve, reject) => {
328
+ // serve.ts logs `opencode server listening on http://<host>:<port>` once
329
+ // bound. parse it out, then resolve. drain remaining stdout to debug.
330
+ let buffer = "";
331
+ let resolved = false;
332
+ const onStdout = (chunk: Buffer) => {
333
+ const text = chunk.toString();
334
+ if (!resolved) {
335
+ // only accumulate until the listening line is found; after handover the
336
+ // buffer serves no purpose and would grow unbounded for a multi-hour run
337
+ // on a chatty server. Post-resolve chunks are still drained to debug below.
338
+ buffer += text;
339
+ const match = buffer.match(
340
+ /opencode server listening on (https?:\/\/[^\s]+)/,
341
+ );
342
+ if (match?.[1]) {
343
+ resolved = true;
344
+ buffer = "";
345
+ log.info(`» opencode server up: ${match[1]}`);
346
+ resolve({ baseUrl: match[1], proc, close, recentStderr });
347
+ // keep draining for debug visibility after handover.
348
+ }
349
+ }
350
+ // log any stdout line that's not the listening sentinel at debug level
351
+ // so a noisy serve startup is visible without polluting info logs.
352
+ const lines = text.split("\n");
353
+ for (const line of lines) {
354
+ const trimmed = line.trim();
355
+ if (trimmed && !trimmed.includes("opencode server listening")) {
356
+ log.debug(`[opencode serve] ${trimmed}`);
357
+ }
358
+ }
359
+ };
360
+ proc.stdout?.on("data", onStdout);
361
+
362
+ proc.once("error", (err) => {
363
+ if (!resolved) {
364
+ resolved = true;
365
+ clearTimeout(bootTimeout);
366
+ untrackChild(proc);
367
+ reject(new Error(`failed to spawn opencode serve: ${err.message}`));
368
+ }
369
+ });
370
+ proc.once("close", (code, signal) => {
371
+ if (!resolved) {
372
+ resolved = true;
373
+ clearTimeout(bootTimeout);
374
+ untrackChild(proc);
375
+ const tail = recentStderr.slice(-5).join("\n");
376
+ reject(
377
+ new Error(
378
+ `opencode serve exited before ready (code=${code} signal=${signal})${tail ? `\n${tail}` : ""}`,
379
+ ),
380
+ );
381
+ }
382
+ });
383
+
384
+ // safety: if the listening line never arrives, bail after 30s.
385
+ const bootTimeout = setTimeout(() => {
386
+ if (!resolved) {
387
+ resolved = true;
388
+ const tail = recentStderr.slice(-5).join("\n");
389
+ void close();
390
+ reject(
391
+ new Error(
392
+ `timed out after 30s waiting for opencode serve to bind${tail ? `\n${tail}` : ""}`,
393
+ ),
394
+ );
395
+ }
396
+ }, 30_000);
397
+ bootTimeout.unref?.();
398
+ });
399
+ }
400
+
401
+ // ── per-turn state ─────────────────────────────────────────────────────────────
402
+
403
+ /**
404
+ * What we collect during a single session.prompt() turn so we can render a
405
+ * unified AgentResult at the end. Per-turn snapshot is reset between turns
406
+ * inside the event loop via `beginTurn()` / `endTurn()`.
407
+ */
408
+ export interface TurnAccumulator {
409
+ finalText: string;
410
+ /**
411
+ * Aggregate token totals from step-finish parts across the orchestrator AND
412
+ * any subagent sessions dispatched during the turn (e.g. loupe).
413
+ * Mirrors v1's `accumulatedTokens` semantics so production billing/audit
414
+ * numbers stay apples-to-apples across the migration.
415
+ */
416
+ tokens: {
417
+ input: number;
418
+ output: number;
419
+ cacheRead: number;
420
+ cacheWrite: number;
421
+ };
422
+ costUsd: number;
423
+ sessionError: string | null;
424
+ /** populated when a tool_use part on the orchestrator session reports error. */
425
+ lastToolError: string | null;
426
+ /**
427
+ * The token quota's source key for this turn. `tokens` above is reset per
428
+ * turn, and the quota charges only the increase under a key — so every turn
429
+ * needs its own, or the second turn's tokens would be free until they
430
+ * exceeded the first turn's total. See tokenQuota.ts.
431
+ */
432
+ quotaSource: string;
433
+ }
434
+
435
+ let turnCounter = 0;
436
+
437
+ export function newTurn(): TurnAccumulator {
438
+ turnCounter += 1;
439
+ return {
440
+ finalText: "",
441
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
442
+ costUsd: 0,
443
+ sessionError: null,
444
+ lastToolError: null,
445
+ quotaSource: `turn#${turnCounter}`,
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Charge the run's token quota with this turn's running total and, if the
451
+ * ceiling is reached, abort the turn (LLM10).
452
+ *
453
+ * Aborting is the point: a flag alone would let the loop keep streaming until
454
+ * it happened to finish, which is the consumption we are bounding. The shared
455
+ * abort controller also serves the activity watchdog, so `tokenQuotaExceeded`
456
+ * records which of the two fired.
457
+ */
458
+ export function chargeTokenQuota(ctx: RunnerContext): void {
459
+ const quota = ctx.tokenQuota;
460
+ if (!quota || !ctx.currentTurn || ctx.tokenQuotaExceeded) return;
461
+ if (!quota.observe(ctx.currentTurn.quotaSource, ctx.currentTurn.tokens))
462
+ return;
463
+ ctx.tokenQuotaExceeded = true;
464
+ log.info(`» ${ctx.label}: ${quota.gap()}`);
465
+ ctx.abortRun?.(new TokenQuotaExceededError(quota.gap()));
466
+ }
467
+
468
+ // ── runner ─────────────────────────────────────────────────────────────────────
469
+
470
+ export interface RunnerContext {
471
+ client: OpencodeClient;
472
+ sessionID: string;
473
+ label: string;
474
+ orchestratorSessionID: string;
475
+ labeler: SessionLabeler;
476
+ toolState: ToolState;
477
+ todoTracker?: TodoTracker | undefined;
478
+ onActivityTimeout?: (() => void) | undefined;
479
+ onToolUse?:
480
+ | ((event: { toolName: string; input: unknown }) => void)
481
+ | undefined;
482
+ /** current per-turn aggregator; nullable between turns. */
483
+ currentTurn: TurnAccumulator | null;
484
+ /** monotonic event count for diagnostics. */
485
+ eventCount: number;
486
+ /** last activity timestamp (event-stream silence detector). */
487
+ lastEventAt: number;
488
+ /** active task dispatch metadata keyed by callID (for subagent timing). */
489
+ taskDispatchByCallID: Map<string, { label: string; startedAt: number }>;
490
+ /**
491
+ * orchestrator tool callIDs already surfaced via `log.info(» ${tool}(...))`,
492
+ * tracked so the end-of-turn fallback can re-emit only the calls the live
493
+ * event stream missed. closes the SSE-connect race against the first
494
+ * `session.prompt()` (the SDK opens the SSE lazily on first iteration; by
495
+ * then the server may already have emitted the turn's tool part-updated
496
+ * events). without the fallback those calls never appear in stdout, which
497
+ * breaks every validator that greps for tool-call shape.
498
+ */
499
+ loggedToolCallIDs: Set<string>;
500
+ /**
501
+ * server-side `time.end` of the newest `todowrite` applied to the todo
502
+ * tracker. the end-of-turn replay processes callIDs the live stream missed
503
+ * — the *earliest* calls of the first turn — so without this ordering guard
504
+ * a replayed older todo list would overwrite a newer one already applied
505
+ * live, and the post-run flush would post the stale checklist to GitHub.
506
+ */
507
+ lastTodoUpdateEndMs: number;
508
+ /** rolling stderr tail from the server process (for diagnostics). */
509
+ recentStderr: string[];
510
+ diagnostic: AgentDiagnostic;
511
+ /**
512
+ * LLM10 — the per-run token ceiling, charged from `step-finish` parts. The
513
+ * inner watchdog bounds SILENCE; a remediation loop that cannot converge
514
+ * streams tokens the whole time and is never silent, so it needs this
515
+ * separate bound. Absent when there is nothing to enforce.
516
+ */
517
+ tokenQuota?: RunTokenQuota | undefined;
518
+ /**
519
+ * Set when {@link RunnerContext.tokenQuota} tripped and aborted the turn.
520
+ * The abort controller is shared with the activity watchdog, so without this
521
+ * flag a quota stop would be reported as "the model went silent" — the
522
+ * opposite of what happened.
523
+ */
524
+ tokenQuotaExceeded: boolean;
525
+ /**
526
+ * Aborts the in-flight turn. Assigned by the runner once the turn's abort
527
+ * controller exists (the context is built first), so it is optional here and
528
+ * a no-op in unit tests that drive the event handlers directly.
529
+ */
530
+ abortRun?: ((reason: Error) => void) | undefined;
531
+ }
532
+
533
+ /**
534
+ * reconnect backoff for the global SSE subscription. capped at the last
535
+ * entry; the index resets as soon as an event flows again, so a healthy
536
+ * stream that drops once pays only the first delay.
537
+ */
538
+ const SSE_RECONNECT_DELAYS_MS: readonly number[] = [
539
+ 1_000, 5_000, 15_000, 30_000,
540
+ ];
541
+
542
+ /**
543
+ * orchestrate the event stream consumer for the entire server lifetime.
544
+ *
545
+ * NB: the SDK subscribe is lazy — the SSE fetch only opens on the first
546
+ * iteration. so the first turn's tool part-updated events can race the
547
+ * connect and be missed. live-stream logging is best-effort; see the
548
+ * end-of-turn `logUnseenToolCalls` fallback for the guarantee.
549
+ *
550
+ * the subscription is wrapped in a reconnect loop: a single `for await` with
551
+ * no retry meant any mid-turn stream error/end permanently killed event
552
+ * consumption, freezing `ctx.lastEventAt` while the turn kept streaming over
553
+ * its separate `session.prompt` connection — after the flat idle budget the
554
+ * inner watchdog then aborted a healthy, progressing run as "model went
555
+ * silent". reconnects continue (bounded backoff) until the signal aborts.
556
+ */
557
+ export async function consumeEvents(
558
+ ctx: RunnerContext,
559
+ signal: AbortSignal,
560
+ ): Promise<void> {
561
+ let reconnectAttempt = 0;
562
+ while (!signal.aborted) {
563
+ try {
564
+ // wire the abort signal into the SSE request itself. without it the
565
+ // generated client falls back to an internal never-aborting signal
566
+ // (serverSentEvents.gen.js: `options.signal ?? new AbortController().signal`),
567
+ // so `reader.read()` parks forever once the session goes idle and the stream
568
+ // falls silent. the teardown `await eventLoopPromise` in the run() finally
569
+ // then blocks — `abortController.abort()` can't interrupt a `for await` that
570
+ // never advances — until the outer process-output watchdog kills the
571
+ // already-succeeded run once the flat idle budget elapses and reports a false
572
+ // "stalled" (PR #876).
573
+ const result = await ctx.client.event.subscribe({}, { signal });
574
+ const stream = result.stream as
575
+ | AsyncGenerator<EventSubscribeResponse>
576
+ | undefined;
577
+ // subscribe can resolve with an error body and no stream — `for await`
578
+ // over undefined throws immediately, so route it through the same
579
+ // reconnect path as a dropped connection.
580
+ if (!stream) throw new Error("event.subscribe returned no stream");
581
+ // (re)subscribed: refresh the inner-watchdog clock so the window the
582
+ // stream was down doesn't count as model silence — the turn kept
583
+ // streaming over its own connection while we were reconnecting.
584
+ ctx.lastEventAt = performance.now();
585
+ for await (const event of stream) {
586
+ if (signal.aborted) return;
587
+ reconnectAttempt = 0;
588
+ ctx.eventCount += 1;
589
+ ctx.diagnostic.eventCount = ctx.eventCount;
590
+ // NB: `lastEventAt` (the inner-watchdog clock) is intentionally NOT bumped
591
+ // here — opencode's keepalive/lifecycle/idle events would otherwise mask a
592
+ // provider stall (the model going silent mid-turn looks like steady event
593
+ // flow). it is refreshed only on meaningful progress in `dispatchEvent`.
594
+ markActivity();
595
+ try {
596
+ await dispatchEvent(ctx, event);
597
+ } catch (err) {
598
+ log.debug(
599
+ `» event dispatch threw for type=${(event as { type?: string }).type ?? "?"}: ${err instanceof Error ? err.message : String(err)}`,
600
+ );
601
+ }
602
+ }
603
+ if (signal.aborted) return;
604
+ log.debug("» opencode event stream ended — resubscribing");
605
+ } catch (err) {
606
+ if (signal.aborted) return;
607
+ log.warning(
608
+ `» opencode event stream dropped: ${err instanceof Error ? err.message : String(err)} — reconnecting`,
609
+ );
610
+ }
611
+ const delayMs =
612
+ SSE_RECONNECT_DELAYS_MS[
613
+ Math.min(reconnectAttempt, SSE_RECONNECT_DELAYS_MS.length - 1)
614
+ ] ?? 30_000;
615
+ reconnectAttempt += 1;
616
+ try {
617
+ await sleep(delayMs, undefined, { signal });
618
+ } catch {
619
+ return; // aborted during backoff
620
+ }
621
+ }
622
+ }
623
+
624
+ export async function dispatchEvent(
625
+ ctx: RunnerContext,
626
+ event: EventSubscribeResponse,
627
+ ): Promise<void> {
628
+ // event union covers heartbeats, session lifecycle, message lifecycle, tui,
629
+ // mcp, etc. we only care about a small subset.
630
+ if (event.type === "message.part.updated") {
631
+ // real model/tool progress: token/text/reasoning streaming and tool
632
+ // part transitions all arrive as part.updated. this is the only event
633
+ // class that refreshes the inner-watchdog clock.
634
+ ctx.lastEventAt = performance.now();
635
+ await onPartUpdated(ctx, event.properties.part);
636
+ return;
637
+ }
638
+ if (event.type === "session.error") {
639
+ const sessionID = event.properties.sessionID;
640
+ if (sessionID !== ctx.orchestratorSessionID) return;
641
+ const err = event.properties.error;
642
+ const message = err ? extractErrorMessage(err) : "(no error payload)";
643
+ if (ctx.currentTurn) ctx.currentTurn.sessionError = message;
644
+ log.info(`» ${ctx.label} session error: ${message}`);
645
+ return;
646
+ }
647
+ // session.idle / session.status are useful breadcrumbs but we don't drive
648
+ // anything off them — the prompt() POST returns when the assistant message
649
+ // is committed, which is also when the session goes idle.
650
+ }
651
+
652
+ function extractErrorMessage(err: {
653
+ name?: string;
654
+ data?: { message?: string; [key: string]: unknown };
655
+ }): string {
656
+ if (err.data?.message) return err.data.message;
657
+ if (err.name) return err.name;
658
+ return JSON.stringify(err);
659
+ }
660
+
661
+ async function onPartUpdated(ctx: RunnerContext, part: Part): Promise<void> {
662
+ const label = ctx.labeler.labelFor(part.sessionID);
663
+ const isOrchestrator = part.sessionID === ctx.orchestratorSessionID;
664
+
665
+ // text — only orchestrator's final text becomes the run's "output";
666
+ // subagent text is logged but not folded into finalOutput.
667
+ if (part.type === "text" && part.time?.end !== undefined) {
668
+ const text = part.text.trim();
669
+ if (!text) return;
670
+ const boxTitle =
671
+ label === ORCHESTRATOR_LABEL ? ctx.label : `${ctx.label} [${label}]`;
672
+ log.box(text, { title: boxTitle });
673
+ if (isOrchestrator && ctx.currentTurn) {
674
+ ctx.currentTurn.finalText = text;
675
+ }
676
+ return;
677
+ }
678
+
679
+ if (part.type === "reasoning" && part.time.end !== undefined) {
680
+ const text = part.text.trim();
681
+ if (!text) return;
682
+ const dur = formatPartDuration(part.time);
683
+ const preview = text.length > 280 ? `${text.slice(0, 280)}…` : text;
684
+ log.info(
685
+ withLabel(label, `» thinking${dur}: ${preview.replace(/\n+/g, " ")}`),
686
+ );
687
+ if (text.length > 280)
688
+ log.debug(withLabel(label, `» thinking (full): ${text}`));
689
+ return;
690
+ }
691
+
692
+ if (part.type === "step-finish") {
693
+ // aggregate orchestrator AND subagent step-finish events into the same
694
+ // per-turn accumulator. The legacy CLI harness summed both via opencode's
695
+ // CLI `--print-logs` output; filtering subagents here would silently
696
+ // undercount production cost/usage by the loupe subagent's
697
+ // contribution (often the bulk of a Review-mode turn).
698
+ if (!ctx.currentTurn) return;
699
+ const t = part.tokens;
700
+ if (t) {
701
+ ctx.currentTurn.tokens.input += t.input || 0;
702
+ ctx.currentTurn.tokens.output += t.output || 0;
703
+ ctx.currentTurn.tokens.cacheRead += t.cache?.read || 0;
704
+ ctx.currentTurn.tokens.cacheWrite += t.cache?.write || 0;
705
+ }
706
+ if (typeof part.cost === "number" && Number.isFinite(part.cost)) {
707
+ ctx.currentTurn.costUsd += part.cost;
708
+ }
709
+ chargeTokenQuota(ctx);
710
+ return;
711
+ }
712
+
713
+ if (part.type === "tool") {
714
+ await onToolPart(ctx, part, label, isOrchestrator);
715
+ return;
716
+ }
717
+
718
+ // step-start / snapshot / patch / agent / retry / compaction / subtask /
719
+ // file: nothing actionable here.
720
+ }
721
+
722
+ async function onToolPart(
723
+ ctx: RunnerContext,
724
+ part: Extract<Part, { type: "tool" }>,
725
+ label: string,
726
+ isOrchestrator: boolean,
727
+ ): Promise<void> {
728
+ const status = part.state.status;
729
+ const toolName = part.tool;
730
+ const toolId = part.callID;
731
+
732
+ // early task-dispatch announce: bind subagent sessionID to a label as soon
733
+ // as the orchestrator's task tool transitions to "running" (where input is
734
+ // populated). dedupe against later terminal observations via callID.
735
+ if (
736
+ toolName === "task" &&
737
+ status === "running" &&
738
+ isOrchestrator &&
739
+ !ctx.taskDispatchByCallID.has(toolId)
740
+ ) {
741
+ const input = (part.state.input ?? {}) as {
742
+ description?: string;
743
+ subagent_type?: string;
744
+ prompt?: string;
745
+ };
746
+ const dispatched = ctx.labeler.recordTaskDispatch(input);
747
+ // the specialist-routing watch. this branch is already guarded by
748
+ // `!taskDispatchByCallID.has(toolId)`, so it fires exactly once per
749
+ // dispatch even though tool parts arrive repeatedly as the call streams.
750
+ ctx.toolState.dispatchMetrics.recordDispatch(dispatched);
751
+ ctx.taskDispatchByCallID.set(toolId, {
752
+ label: dispatched,
753
+ startedAt: performance.now(),
754
+ });
755
+ log.info(
756
+ `» dispatching subagent: ${dispatched}` +
757
+ (input.subagent_type ? ` (subagent_type=${input.subagent_type})` : ""),
758
+ );
759
+ return;
760
+ }
761
+
762
+ // terminal bookkeeping (log line, side effects) runs once per callID via
763
+ // `processTerminalToolPart` — see its docstring for the dedup contract
764
+ // shared with the end-of-turn fallback.
765
+ processTerminalToolPart(ctx, part, label, isOrchestrator);
766
+ }
767
+
768
+ /**
769
+ * shared terminal bookkeeping for a tool part: log line, dedup callID, run
770
+ * orchestrator-side hooks (`onToolUse` → diff-coverage tracker; `todowrite` /
771
+ * `report_progress` → todo tracker; tool-error → `lastToolError`), and emit
772
+ * subagent-finish summary on `task` returns.
773
+ *
774
+ * called from both the live SSE path (`onToolPart`) and the end-of-turn
775
+ * fallback (`logUnseenToolCalls`) — `loggedToolCallIDs` is the dedup guard
776
+ * so each call's side effects fire exactly once across both paths. critical
777
+ * for diff-coverage: a first-turn `Read` that races SSE attach would
778
+ * otherwise be missed by `recordDiffReadFromToolUse`, and the subsequent
779
+ * `create_pull_request_review` pre-flight would reject the review.
780
+ */
781
+ export function processTerminalToolPart(
782
+ ctx: RunnerContext,
783
+ part: Extract<Part, { type: "tool" }>,
784
+ label: string,
785
+ isOrchestrator: boolean,
786
+ ): void {
787
+ const toolName = part.tool;
788
+ const toolId = part.callID;
789
+ const state = part.state;
790
+ if (state.status !== "completed" && state.status !== "error") return;
791
+ if (isOrchestrator && ctx.loggedToolCallIDs.has(toolId)) return;
792
+
793
+ const input = state.input ?? {};
794
+ const inputFormatted = formatJsonValue(input);
795
+ const callLine =
796
+ inputFormatted !== "{}"
797
+ ? `» ${toolName}(${inputFormatted})`
798
+ : `» ${toolName}()`;
799
+ log.info(withLabel(label, callLine));
800
+ if (isOrchestrator) ctx.loggedToolCallIDs.add(toolId);
801
+
802
+ if (state.status === "completed") {
803
+ log.debug(withLabel(label, ` output: ${state.output}`));
804
+ } else {
805
+ log.info(withLabel(label, `» tool call failed: ${state.error}`));
806
+ if (isOrchestrator && ctx.currentTurn) {
807
+ ctx.currentTurn.lastToolError = state.error;
808
+ }
809
+ }
810
+
811
+ // subagent finish bookkeeping — exact callID match (v1.15 keeps callID
812
+ // stable across the whole tool-input → tool-call → terminal chain).
813
+ if (toolName === "task") {
814
+ const dispatch = ctx.taskDispatchByCallID.get(toolId);
815
+ if (dispatch) {
816
+ const dur = ((performance.now() - dispatch.startedAt) / 1000).toFixed(1);
817
+ const outputStr = state.status === "completed" ? state.output : "";
818
+ const preview =
819
+ typeof outputStr === "string" && outputStr.length > 120
820
+ ? `${outputStr.slice(0, 120)}…`
821
+ : outputStr;
822
+ log.info(
823
+ `» subagent finished: ${dispatch.label} (${dur}s, status=${state.status})` +
824
+ (preview ? ` — ${String(preview).replace(/\n/g, " ")}` : ""),
825
+ );
826
+ ctx.taskDispatchByCallID.delete(toolId);
827
+ }
828
+ }
829
+
830
+ // forward orchestrator tool usage to the harness's hooks. subagent
831
+ // tool calls don't count toward the parent's diff-coverage tracking —
832
+ // it's the orchestrator that submits the review.
833
+ if (isOrchestrator) {
834
+ ctx.onToolUse?.({ toolName, input });
835
+ }
836
+
837
+ if (toolName.includes("report_progress") && ctx.todoTracker) {
838
+ log.debug("» report_progress detected, disabling todo tracking");
839
+ ctx.todoTracker.cancel();
840
+ }
841
+ if (toolName === "todowrite" && ctx.todoTracker?.enabled && isOrchestrator) {
842
+ // ordering guard: the end-of-turn replay path feeds callIDs the live
843
+ // stream missed — the earliest calls of the first turn — so a replayed
844
+ // todowrite can be OLDER than one already applied live. applying it would
845
+ // regress the tracker and flush a stale checklist. compare server-side
846
+ // completion times and skip anything older than the newest applied.
847
+ if (state.time.end >= ctx.lastTodoUpdateEndMs) {
848
+ ctx.lastTodoUpdateEndMs = state.time.end;
849
+ ctx.todoTracker.update(input);
850
+ } else {
851
+ log.debug(`» skipping stale todowrite replay (callID ${toolId})`);
852
+ }
853
+ }
854
+ }
855
+
856
+ /**
857
+ * end-of-turn safety net for tool-call bookkeeping. queries `session.messages`
858
+ * for the canonical orchestrator transcript and replays any tool callID the
859
+ * live event stream hasn't already processed — closes the SSE-connect race
860
+ * documented on `loggedToolCallIDs`. `session.prompt`'s own `data.parts` is
861
+ * only the final assistant message's parts (mostly text/reasoning); the tool
862
+ * calls in earlier steps of the same turn live on prior messages, so we need
863
+ * the full session-scoped read.
864
+ *
865
+ * delegates to `processTerminalToolPart` so the same side effects fire as
866
+ * on the live SSE path: log line, `onToolUse` (diff-coverage feed),
867
+ * `todoTracker` updates, `lastToolError`. completed/errored parts only;
868
+ * pending states are inflight and not yet meaningful.
869
+ */
870
+ async function logUnseenToolCalls(ctx: RunnerContext): Promise<void> {
871
+ try {
872
+ const resp = await ctx.client.session.messages({
873
+ sessionID: ctx.orchestratorSessionID,
874
+ });
875
+ if (resp.error || !resp.data) return;
876
+ for (const message of resp.data) {
877
+ for (const part of message.parts) {
878
+ if (part.type !== "tool") continue;
879
+ processTerminalToolPart(ctx, part, ORCHESTRATOR_LABEL, true);
880
+ }
881
+ }
882
+ } catch (err) {
883
+ log.debug(
884
+ `» logUnseenToolCalls failed: ${err instanceof Error ? err.message : String(err)}`,
885
+ );
886
+ }
887
+ }
888
+
889
+ export function formatPartDuration(
890
+ time: { start?: number; end?: number } | undefined,
891
+ ): string {
892
+ if (!time || typeof time.start !== "number" || typeof time.end !== "number")
893
+ return "";
894
+ if (time.end <= time.start) return "";
895
+ return ` (${((time.end - time.start) / 1000).toFixed(1)}s)`;
896
+ }
897
+
898
+ function withLabel(label: string, message: string): string {
899
+ return label === ORCHESTRATOR_LABEL
900
+ ? message
901
+ : formatWithLabel(label, message);
902
+ }
903
+
904
+ // ── per-turn execution ─────────────────────────────────────────────────────────
905
+
906
+ /**
907
+ * backoff schedule for transport-level `session.prompt` retries (one entry
908
+ * per retry). the session is warm and server-side, so re-posting after a
909
+ * dropped connection is cheap; anything outliving ~13s of backoff is treated
910
+ * as a real failure.
911
+ */
912
+ const PROMPT_TRANSPORT_RETRY_DELAYS_MS: readonly number[] = [3_000, 10_000];
913
+
914
+ /**
915
+ * Run a single prompt turn against the persistent server. Resets the per-turn
916
+ * accumulator, calls `client.session.prompt()`, then assembles an AgentResult
917
+ * from the returned AssistantMessage + accumulated event state.
918
+ *
919
+ * Token / cost: `AssistantMessage.tokens` and `.cost` are authoritative for
920
+ * the turn. The event-stream accumulator is a fallback / sanity-check path
921
+ * used when the response is missing (e.g. abort, transport error) — and as
922
+ * the only source of per-step subagent attribution if we ever surface it.
923
+ */
924
+ export async function runPromptTurn(
925
+ ctx: RunnerContext,
926
+ params: {
927
+ text: string;
928
+ model: { providerID: string; modelID: string } | undefined;
929
+ signal: AbortSignal;
930
+ },
931
+ ): Promise<AgentResult> {
932
+ const start = performance.now();
933
+ // record the turn boundary in milliseconds (matches AssistantMessage.time.created)
934
+ // so the post-turn aggregator can isolate this turn's messages from the prior
935
+ // turns' messages on the same persistent orchestrator session.
936
+ const turnStartMs = Date.now();
937
+ ctx.currentTurn = newTurn();
938
+ const turn = ctx.currentTurn;
939
+
940
+ const part: TextPartInput = { type: "text", text: params.text };
941
+
942
+ let assistant: AssistantMessage | undefined;
943
+ let returnedParts: Part[] | undefined;
944
+ let networkError: string | null = null;
945
+ // bounded transport-level retry: a thrown fetch error (ECONNRESET during a
946
+ // GC pause) or a transient server-side error body would otherwise end the
947
+ // run despite a warm server and a live, resumable session — the post-run
948
+ // loop breaks on the first `success: false`. deterministic provider errors
949
+ // (assistant.error) and watchdog aborts are never retried.
950
+ for (let attempt = 0; ; attempt++) {
951
+ assistant = undefined;
952
+ returnedParts = undefined;
953
+ networkError = null;
954
+ try {
955
+ const response = await ctx.client.session.prompt(
956
+ {
957
+ sessionID: ctx.sessionID,
958
+ parts: [part],
959
+ ...(params.model ? { model: params.model } : {}),
960
+ },
961
+ // wire the inner activity watchdog's abort signal into the SDK request
962
+ // — without this a hung HTTP keeps the run stuck even after the
963
+ // watchdog fires.
964
+ { signal: params.signal },
965
+ );
966
+ if (response.error) {
967
+ networkError = formatPromptError(response.error);
968
+ } else if (response.data) {
969
+ assistant = response.data.info;
970
+ returnedParts = response.data.parts;
971
+ } else {
972
+ // neither error nor data — malformed/partial SDK response. don't silently
973
+ // succeed with an empty AgentResult; treat as a failure so the gate loop
974
+ // surfaces it instead of looping on a "successful" no-op.
975
+ networkError = "opencode prompt returned neither data nor error";
976
+ }
977
+ } catch (err) {
978
+ networkError = err instanceof Error ? err.message : String(err);
979
+ }
980
+ const delayMs = PROMPT_TRANSPORT_RETRY_DELAYS_MS[attempt];
981
+ if (!networkError || params.signal.aborted || delayMs === undefined) break;
982
+ log.warning(
983
+ `» opencode prompt transport failure (attempt ${attempt + 1}/${PROMPT_TRANSPORT_RETRY_DELAYS_MS.length + 1}): ${networkError} — retrying in ${Math.round(delayMs / 1000)}s`,
984
+ );
985
+ try {
986
+ await sleep(delayMs, undefined, { signal: params.signal });
987
+ } catch {
988
+ break; // aborted during backoff — surface the failure as-is
989
+ }
990
+ }
991
+ const durationMs = performance.now() - start;
992
+
993
+ // authoritative cost/usage: walk every assistant message that landed during
994
+ // this turn (orchestrator session + any subagent sessions dispatched while
995
+ // it ran) and sum tokens + cost. The step-finish accumulator and the live
996
+ // AssistantMessage from session.prompt are both non-authoritative for a
997
+ // multi-step turn — step-finish events arrive on the SSE stream after
998
+ // session.prompt has already resolved (at least for the final message),
999
+ // and AssistantMessage carries only the final message's usage. Mirrors v1's
1000
+ // accumulator-after-the-fact model but driven by the canonical message
1001
+ // store instead of best-effort SSE sniffing.
1002
+ const aggregatedUsage = await aggregateTurnUsage(ctx, turnStartMs);
1003
+ const usage = aggregatedUsage ?? buildUsage(turn, assistant);
1004
+
1005
+ // surface the rendered final text. preference order:
1006
+ // 1. orchestrator text part with time.end set (captured by event loop)
1007
+ // 2. text part on the returned response (when present)
1008
+ // 3. assistant message id as a last-resort placeholder
1009
+ const finalText = turn.finalText || extractTextFromParts(returnedParts) || "";
1010
+
1011
+ await logUnseenToolCalls(ctx);
1012
+
1013
+ log.info(`» ${ctx.label} turn completed in ${Math.round(durationMs)}ms`);
1014
+ if (usage) {
1015
+ logTokenTable({
1016
+ input:
1017
+ usage.inputTokens -
1018
+ (usage.cacheReadTokens ?? 0) -
1019
+ (usage.cacheWriteTokens ?? 0),
1020
+ cacheRead: usage.cacheReadTokens ?? 0,
1021
+ cacheWrite: usage.cacheWriteTokens ?? 0,
1022
+ output: usage.outputTokens,
1023
+ costUsd: usage.costUsd,
1024
+ });
1025
+ }
1026
+
1027
+ // failure modes, in order of authority:
1028
+ // 1. transport / SDK-side error (response.error or thrown)
1029
+ // 2. AssistantMessage.error set by the provider (auth, context overflow, etc.)
1030
+ // 3. session.error event observed during the turn
1031
+ if (networkError) {
1032
+ // a watchdog-fired abort surfaces here as a caught `session.prompt`
1033
+ // rejection (or an aborted `response.error`), not a throw that escapes to
1034
+ // the caller. classify it as an `activity timeout` so `renderRunError`
1035
+ // routes it through the hang renderer; any other transport failure falls
1036
+ // through to the generic humanized renderer.
1037
+ return {
1038
+ success: false,
1039
+ output: finalText,
1040
+ error: ctx.tokenQuotaExceeded
1041
+ ? // the abort controller is shared with the activity watchdog, so this
1042
+ // must be checked FIRST — otherwise a run stopped for consuming too
1043
+ // much would be reported as one that went silent.
1044
+ (ctx.tokenQuota?.gap() ?? "run token quota exhausted")
1045
+ : params.signal.aborted
1046
+ ? `activity timeout: the model went silent and the turn was aborted by the activity watchdog (${networkError})`
1047
+ : `opencode prompt failed: ${networkError}`,
1048
+ usage,
1049
+ };
1050
+ }
1051
+ if (assistant?.error) {
1052
+ return {
1053
+ success: false,
1054
+ output: finalText,
1055
+ error: `provider error: ${extractErrorMessage(assistant.error)}`,
1056
+ usage,
1057
+ };
1058
+ }
1059
+ if (turn.sessionError) {
1060
+ return {
1061
+ success: false,
1062
+ output: finalText,
1063
+ error: `session error: ${turn.sessionError}`,
1064
+ usage,
1065
+ };
1066
+ }
1067
+
1068
+ return { success: true, output: finalText, usage };
1069
+ }
1070
+
1071
+ /**
1072
+ * Sum the cost + tokens of every assistant message created during this turn,
1073
+ * across the orchestrator session AND any subagent sessions dispatched while
1074
+ * it ran. Authoritative: the SDK's own cost/tokens fields per message are the
1075
+ * source of truth, identical to what `opencode --print-logs` aggregated in v1.
1076
+ */
1077
+ async function aggregateTurnUsage(
1078
+ ctx: RunnerContext,
1079
+ turnStartMs: number,
1080
+ ): Promise<AgentUsage | undefined> {
1081
+ // labeler tracks every sessionID we've observed events from on the
1082
+ // global SSE stream, including any subagent (task tool) child sessions.
1083
+ const sessionIDs = new Set<string>([ctx.orchestratorSessionID]);
1084
+ for (const [sessionID] of ctx.labeler.entries()) {
1085
+ sessionIDs.add(sessionID);
1086
+ }
1087
+
1088
+ let inputTokens = 0;
1089
+ let outputTokens = 0;
1090
+ let cacheReadTokens = 0;
1091
+ let cacheWriteTokens = 0;
1092
+ let costUsd = 0;
1093
+ let counted = 0;
1094
+
1095
+ for (const sessionID of sessionIDs) {
1096
+ try {
1097
+ const resp = await ctx.client.session.messages({ sessionID });
1098
+ if (resp.error || !resp.data) continue;
1099
+ // the specialist-routing watch rides along with the walk that is already
1100
+ // happening: this loop is per-SESSION, so the orchestrator/subagent split
1101
+ // is a label lookup rather than a second pass. Accumulated per session
1102
+ // and recorded once, so a session with many messages is one call.
1103
+ let sessionBillable = 0;
1104
+ for (const msg of resp.data) {
1105
+ if (msg.info.role !== "assistant") continue;
1106
+ if (msg.info.time.created < turnStartMs) continue;
1107
+ const t = msg.info.tokens;
1108
+ inputTokens += t.input || 0;
1109
+ outputTokens += t.output || 0;
1110
+ cacheReadTokens += t.cache?.read || 0;
1111
+ cacheWriteTokens += t.cache?.write || 0;
1112
+ costUsd += msg.info.cost || 0;
1113
+ sessionBillable +=
1114
+ (t.input || 0) +
1115
+ (t.output || 0) +
1116
+ (t.cache?.read || 0) +
1117
+ (t.cache?.write || 0);
1118
+ counted++;
1119
+ }
1120
+ // the orchestrator session is known by identity, not by label lookup —
1121
+ // `labelFor` would also return the orchestrator label for any session it
1122
+ // failed to bind, which would silently under-count the subagent share.
1123
+ ctx.toolState.dispatchMetrics.recordTokens(
1124
+ sessionID === ctx.orchestratorSessionID
1125
+ ? ORCHESTRATOR_LABEL
1126
+ : ctx.labeler.labelFor(sessionID),
1127
+ sessionBillable,
1128
+ );
1129
+ } catch (err) {
1130
+ log.debug(
1131
+ `» aggregateTurnUsage failed for session ${sessionID}: ${err instanceof Error ? err.message : String(err)}`,
1132
+ );
1133
+ }
1134
+ }
1135
+
1136
+ if (counted === 0) return undefined;
1137
+
1138
+ const total = inputTokens + cacheReadTokens + cacheWriteTokens;
1139
+ if (total === 0 && outputTokens === 0 && costUsd === 0) return undefined;
1140
+
1141
+ return {
1142
+ agent: "infraweaver",
1143
+ inputTokens: total,
1144
+ outputTokens,
1145
+ cacheReadTokens: cacheReadTokens || undefined,
1146
+ cacheWriteTokens: cacheWriteTokens || undefined,
1147
+ costUsd: costUsd > 0 ? costUsd : undefined,
1148
+ };
1149
+ }
1150
+
1151
+ export function buildUsage(
1152
+ turn: TurnAccumulator,
1153
+ assistant: AssistantMessage | undefined,
1154
+ ): AgentUsage | undefined {
1155
+ // Prefer the step-finish accumulator: it sums every LLM call across the
1156
+ // whole turn (orchestrator iterations + any subagent dispatches). The
1157
+ // AssistantMessage at the SDK boundary only carries the *final* assistant
1158
+ // message's tokens/cost — for a multi-step Review-mode turn that's just
1159
+ // the closing acknowledgment, missing the bulk of the work. Fall back to
1160
+ // assistant.tokens only if the accumulator is empty (e.g., the turn
1161
+ // errored before any step-finish events landed).
1162
+ const t = turn.tokens;
1163
+ const fromAccumulator = buildAgentUsage({
1164
+ agent: "infraweaver",
1165
+ input: t.input,
1166
+ output: t.output,
1167
+ cacheRead: t.cacheRead,
1168
+ cacheWrite: t.cacheWrite,
1169
+ costUsd: turn.costUsd,
1170
+ });
1171
+ if (fromAccumulator) return fromAccumulator;
1172
+ if (assistant) {
1173
+ const at = assistant.tokens;
1174
+ return buildAgentUsage({
1175
+ agent: "infraweaver",
1176
+ input: at.input || 0,
1177
+ output: at.output || 0,
1178
+ cacheRead: at.cache?.read || 0,
1179
+ cacheWrite: at.cache?.write || 0,
1180
+ costUsd: assistant.cost || 0,
1181
+ });
1182
+ }
1183
+ return undefined;
1184
+ }
1185
+
1186
+ export function extractTextFromParts(
1187
+ parts: Part[] | undefined,
1188
+ ): string | undefined {
1189
+ if (!parts) return undefined;
1190
+ const texts: string[] = [];
1191
+ for (const p of parts) {
1192
+ if (p.type === "text" && p.text) texts.push(p.text);
1193
+ }
1194
+ const joined = texts.join("\n").trim();
1195
+ return joined || undefined;
1196
+ }
1197
+
1198
+ export function formatPromptError(error: unknown): string {
1199
+ if (typeof error === "string") return error;
1200
+ if (error && typeof error === "object") {
1201
+ const obj = error as {
1202
+ message?: string;
1203
+ error?: { message?: string };
1204
+ data?: unknown;
1205
+ };
1206
+ if (obj.message) return obj.message;
1207
+ if (obj.error?.message) return obj.error.message;
1208
+ try {
1209
+ return JSON.stringify(error);
1210
+ } catch {
1211
+ return String(error);
1212
+ }
1213
+ }
1214
+ return String(error);
1215
+ }
1216
+
1217
+ // ── inner activity timer ───────────────────────────────────────────────────────
1218
+
1219
+ /**
1220
+ * Start an event-silence watchdog. The outer process-level activity timer
1221
+ * (main.ts `createProcessOutputActivityTimeout`) watches `process.stdout.write`
1222
+ * which our harness log lines drive — but it doesn't see SSE event silence
1223
+ * when the harness is itself quiet. This inner timer specifically watches
1224
+ * `ctx.lastEventAt` and fires `onActivityTimeout` so main.ts can tear down
1225
+ * the MCP server early, mirroring the per-spawn watchdog in `subprocess.ts`.
1226
+ *
1227
+ * `ctx.lastEventAt` is refreshed only on meaningful progress (token/tool
1228
+ * part.updated), so any prolonged gap with no progress advances the clock —
1229
+ * including a long in-flight tool call. the budget is the same flat idle
1230
+ * timeout as the outer watchdog, sized to exceed the worst-case legitimate
1231
+ * silent tool window (#760), so a real tool can't trip it; a genuinely stalled
1232
+ * provider or a hung tool does, at the flat budget.
1233
+ */
1234
+ export function startInnerActivityWatchdog(params: {
1235
+ ctx: RunnerContext;
1236
+ timeoutMs: number;
1237
+ abortController: AbortController;
1238
+ }): { stop: () => void } {
1239
+ let fired = false;
1240
+ const id = setInterval(() => {
1241
+ if (fired) return;
1242
+ const idleMs = performance.now() - params.ctx.lastEventAt;
1243
+ if (idleMs <= params.timeoutMs) return;
1244
+ fired = true;
1245
+ const idleSec = Math.round(idleMs / 1000);
1246
+ log.info(
1247
+ `» no opencode events for ${idleSec}s — aborting in-flight prompt and notifying harness`,
1248
+ );
1249
+ params.abortController.abort();
1250
+ try {
1251
+ params.ctx.onActivityTimeout?.();
1252
+ } catch (err) {
1253
+ log.debug(
1254
+ `inner activity callback threw: ${err instanceof Error ? err.message : String(err)}`,
1255
+ );
1256
+ }
1257
+ }, 5_000);
1258
+ id.unref?.();
1259
+ return { stop: () => clearInterval(id) };
1260
+ }
1261
+
1262
+ // ── agent entrypoint ───────────────────────────────────────────────────────────
1263
+
1264
+ export const opencode = agent({
1265
+ name: "opencode",
1266
+ install: installCli,
1267
+ run: async (ctx) => {
1268
+ const cliPath = await installCli();
1269
+
1270
+ const rawModel = ctx.resolvedModel ?? autoSelectModel();
1271
+
1272
+ // rawModel is the authoritative "what actually ran" — including the
1273
+ // auto-select pick that main.ts cannot know (it's opencode-specific:
1274
+ // folding it into `resolvedModel` earlier would mis-route `resolveAgent`).
1275
+ // overwrite the pre-agent best-effort so `toolState.model` (the "Using `…`"
1276
+ // footer badge) reflects the real model.
1277
+ if (rawModel) ctx.toolState.model = rawModel;
1278
+
1279
+ // bedrock route: opencode's `amazon-bedrock` provider expects the model
1280
+ // in `amazon-bedrock/<bedrock-id>` form. detect via env-var sentinel
1281
+ // (same pattern as claude.ts). do not gate on Anthropic-vs-other — that
1282
+ // discriminant lives in resolveAgent.
1283
+ const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
1284
+ const isBedrockRoute =
1285
+ rawModel !== undefined &&
1286
+ bedrockModelId !== undefined &&
1287
+ bedrockModelId === rawModel;
1288
+ const vertexModel = resolveVertexOpenCodeModel(rawModel);
1289
+ // openai-compatible route: opencode's custom-provider block is keyed on
1290
+ // `openai-compatible/<id>`; the provider definition (baseURL/apiKey/models)
1291
+ // is injected in buildSecurityConfig. Precedence (bedrock → vertex →
1292
+ // openai-compatible) mirrors resolveAgent's, so a contrived dual-env-var
1293
+ // misconfiguration resolves to the same backend in both places.
1294
+ const openAiCompatModel = resolveOpenAiCompatibleOpenCodeModel(rawModel);
1295
+ const model = isBedrockRoute
1296
+ ? `amazon-bedrock/${rawModel}`
1297
+ : (vertexModel ?? openAiCompatModel ?? rawModel);
1298
+
1299
+ const homeEnv = agentHomeEnv(ctx.tmpdir);
1300
+ // install the subagent gate into opencode's auto-discovered plugin dir
1301
+ // (under the tmpdir-redirected XDG_CONFIG_HOME). v2 installs ONLY the gate,
1302
+ // not the events re-emitter — it reads subagent events off the SDK stream,
1303
+ // so the re-emitter would be dead weight. see action/agents/opencodePlugin.ts.
1304
+ const opencodePluginDir = join(
1305
+ homeEnv.XDG_CONFIG_HOME,
1306
+ "opencode",
1307
+ "plugin",
1308
+ );
1309
+ mkdirSync(opencodePluginDir, { recursive: true });
1310
+ writeFileSync(
1311
+ join(opencodePluginDir, INFRAWEAVER_OPENCODE_GATE_PLUGIN_FILENAME),
1312
+ buildOpencodeSubagentGateSource(
1313
+ ctx.subagentDeniedTools,
1314
+ writePolicyPath(ctx.tmpdir),
1315
+ ),
1316
+ );
1317
+
1318
+ installBundledSkills({ home: homeEnv.HOME });
1319
+
1320
+ // materialize CODEX_AUTH_JSON into the runner's real $HOME/.local/share/
1321
+ // opencode/auth.json so OpenCode's CodexAuthPlugin picks it up. see
1322
+ // action/utils/codexHome.ts and wiki/codex-auth.md.
1323
+ const codexAuth = installCodexAuth();
1324
+
1325
+ // OPENCODE_PERMISSION has absolute highest precedence (merged after managed/MDM configs).
1326
+ // external_directory gates ALL native filesystem tools (Read, Write, Edit, Glob, Grep, etc.)
1327
+ // for paths outside the project root. last-match-wins: deny everything, then allow /tmp.
1328
+ // codex auth lives at /var/lib/infraweaver/opencode/auth.json in CI (see codexHome.ts),
1329
+ // which is outside /tmp/* — deny-default protects it from native FS tools.
1330
+ //
1331
+ // read + edit rules deny git surfaces INSIDE the project root, where
1332
+ // external_directory short-circuits (Instance.containsPath). edit denies
1333
+ // ALL of .git (blanket write — nothing legit writes .git via native tools;
1334
+ // MCP git tools run in the action process, outside this gate); read denies
1335
+ // only .git/config (narrow — broad .git read-blocks break orientation reads
1336
+ // like .git/HEAD, and ASKPASS keeps live tokens out of .git/config). `*` is
1337
+ // recursive in opencode's Wildcard dialect. grep/glob match the search
1338
+ // pattern not a filepath, so they can't be path-denied (documented in
1339
+ // wiki/security.md). canonical surfaces: action/agents/nativeFsDenies.ts.
1340
+ const permissionOverride = JSON.stringify({
1341
+ external_directory: { "*": "deny", "/tmp/*": "allow" },
1342
+ read: { "*": "allow", ...GIT_NATIVE_READ_DENY_OPENCODE },
1343
+ edit: { "*": "allow", ...GIT_NATIVE_WRITE_DENY_OPENCODE },
1344
+ });
1345
+
1346
+ const repoDir = process.cwd();
1347
+
1348
+ // PWD is pinned to the repo dir — see baseAgentEnv for why. opencode-ai
1349
+ // (≥1.14) resolves the session's `directory` from `process.env.PWD`, so any
1350
+ // in-server tool that re-resolves cwd locally lands in repoDir.
1351
+ const env: NodeJS.ProcessEnv = {
1352
+ ...baseAgentEnv(homeEnv, repoDir),
1353
+ OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
1354
+ OPENCODE_PERMISSION: permissionOverride,
1355
+ // the opencode server expands {env:INFRAWEAVER_MCP_TOKEN} in the MCP header
1356
+ // from here; set only on this spawn env (not process.env) so the MCP shell
1357
+ // sandbox + any dependency-install subprocess never inherit it.
1358
+ [MCP_SERVER_TOKEN_ENV]: ctx.mcpServerToken,
1359
+ GOOGLE_GENERATIVE_AI_API_KEY:
1360
+ process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
1361
+ };
1362
+ if (codexAuth) {
1363
+ env.XDG_DATA_HOME = codexAuth.xdgDataHome;
1364
+ delete env.OPENAI_API_KEY;
1365
+ core.saveState(
1366
+ "codex_writeback",
1367
+ JSON.stringify({
1368
+ apiToken: ctx.apiToken,
1369
+ authPath: codexAuth.authPath,
1370
+ originalRefresh: codexAuth.originalRefresh,
1371
+ }),
1372
+ );
1373
+ }
1374
+
1375
+ log.debug(
1376
+ `» starting ${PRODUCT_NAME} (OpenCode, in-process SDK): ${cliPath}`,
1377
+ );
1378
+ log.debug(`» working directory: ${repoDir}`);
1379
+
1380
+ // ── boot server + create session ─────────────────────────────────────────
1381
+ const server = await bootOpencodeServer({ cliPath, env, cwd: repoDir });
1382
+ // the SDK's bundled fetch tries to disable per-request timeouts via the
1383
+ // bun-only `req.timeout = false` no-op, which does nothing under node/undici
1384
+ // — so undici's default 300s headers/body timeout aborts any turn that
1385
+ // streams for >5min as `TypeError: fetch failed`. wire an unbounded undici
1386
+ // dispatcher through a custom fetch (createOpencodeClient's `fetch` override
1387
+ // bypasses the SDK's own fetch) so a long turn isn't capped client-side.
1388
+ // the inner activity watchdog below — not undici — is what bounds true stalls.
1389
+ const dispatcher = new Agent({
1390
+ headersTimeout: 0,
1391
+ bodyTimeout: 0,
1392
+ connectTimeout: 0,
1393
+ });
1394
+ // forward the request through undici's own dispatcher-aware fetch (Agent and
1395
+ // fetch from the same package, so `dispatcher` typechecks with no cast). the
1396
+ // SDK hands our override a global `Request`, which the esbuild-bundled undici
1397
+ // realm can't consume directly ("Failed to parse URL from [object Request]"),
1398
+ // so we re-state its fields explicitly. `duplex: "half"` is required by the
1399
+ // fetch spec whenever a stream body is sent.
1400
+ const fetchWithoutTimeout: typeof fetch = (input, init) => {
1401
+ const request =
1402
+ input instanceof Request ? input : new Request(input, init);
1403
+ return undiciFetch(request.url, {
1404
+ method: request.method,
1405
+ headers: [...request.headers],
1406
+ body: request.body,
1407
+ duplex: "half",
1408
+ signal: request.signal,
1409
+ dispatcher,
1410
+ });
1411
+ };
1412
+ try {
1413
+ const client = createOpencodeClient({
1414
+ baseUrl: server.baseUrl,
1415
+ directory: repoDir,
1416
+ fetch: fetchWithoutTimeout,
1417
+ });
1418
+
1419
+ const sessionResp = await client.session.create({ title: PRODUCT_NAME });
1420
+ if (sessionResp.error || !sessionResp.data) {
1421
+ const msg = sessionResp.error
1422
+ ? formatPromptError(sessionResp.error)
1423
+ : "session.create returned no data";
1424
+ return {
1425
+ success: false,
1426
+ output: "",
1427
+ error: `opencode session.create failed: ${msg}`,
1428
+ };
1429
+ }
1430
+ const sessionID = sessionResp.data.id;
1431
+ log.info(`» opencode session: ${sessionID}`);
1432
+
1433
+ // bind the orchestrator label up front. without this, the first
1434
+ // foreign sessionID we see (a subagent) would consume the ORCHESTRATOR
1435
+ // slot in the labeler's FIFO and every label downstream would shift.
1436
+ const labeler = new SessionLabeler();
1437
+ labeler.labelFor(sessionID);
1438
+
1439
+ const runnerCtx: RunnerContext = {
1440
+ client,
1441
+ sessionID,
1442
+ label: PRODUCT_NAME,
1443
+ orchestratorSessionID: sessionID,
1444
+ labeler,
1445
+ toolState: ctx.toolState,
1446
+ todoTracker: ctx.todoTracker,
1447
+ onActivityTimeout: ctx.onActivityTimeout,
1448
+ onToolUse: ctx.onToolUse,
1449
+ currentTurn: null,
1450
+ eventCount: 0,
1451
+ lastEventAt: performance.now(),
1452
+ taskDispatchByCallID: new Map(),
1453
+ loggedToolCallIDs: new Set(),
1454
+ lastTodoUpdateEndMs: 0,
1455
+ recentStderr: server.recentStderr,
1456
+ diagnostic: {
1457
+ label: PRODUCT_NAME,
1458
+ recentStderr: server.recentStderr,
1459
+ lastProviderError: undefined,
1460
+ eventCount: 0,
1461
+ },
1462
+ tokenQuota: ctx.tokenQuota,
1463
+ tokenQuotaExceeded: false,
1464
+ };
1465
+ ctx.toolState.agentDiagnostic = runnerCtx.diagnostic;
1466
+
1467
+ // server stderr → provider-error attribution (same pattern as the
1468
+ // old CLI subprocess harness's onStderr handler).
1469
+ server.proc.stderr?.on("data", (chunk: Buffer) => {
1470
+ const text = chunk.toString();
1471
+ for (const line of text.split("\n")) {
1472
+ const trimmed = line.trim();
1473
+ if (!trimmed) continue;
1474
+ const match = findProviderErrorMatch(trimmed);
1475
+ if (match) {
1476
+ runnerCtx.diagnostic.lastProviderError = match.label;
1477
+ log.info(
1478
+ `» provider error detected (${match.label}): ${match.excerpt}`,
1479
+ );
1480
+ }
1481
+ }
1482
+ });
1483
+
1484
+ const abortController = new AbortController();
1485
+ runnerCtx.abortRun = (reason) => abortController.abort(reason);
1486
+ const eventLoopPromise = consumeEvents(
1487
+ runnerCtx,
1488
+ abortController.signal,
1489
+ ).catch((err) => {
1490
+ // SSE stream breakage during cleanup is expected; only surface during
1491
+ // active operation.
1492
+ if (!abortController.signal.aborted) {
1493
+ log.warning(
1494
+ `» opencode event subscription ended: ${err instanceof Error ? err.message : String(err)}`,
1495
+ );
1496
+ }
1497
+ });
1498
+
1499
+ const watchdog = startInnerActivityWatchdog({
1500
+ ctx: runnerCtx,
1501
+ // model-stall budget: how long the orchestrator may stream NO progress
1502
+ // (no token/tool part.updated) before we tear the turn down. opencode's
1503
+ // keepalive/lifecycle events keep the outer process-output monitor
1504
+ // alive even while the model is silent, so this inner timer is the only
1505
+ // stall detector for the v2 SSE path. it shares the flat idle budget so
1506
+ // a long synchronous tool call (no part.updated while it runs) can't
1507
+ // false-positive it.
1508
+ timeoutMs: AGENT_ACTIVITY_TIMEOUT_MS,
1509
+ abortController,
1510
+ });
1511
+
1512
+ const sdkModel = parseModel(model);
1513
+
1514
+ try {
1515
+ // initial run
1516
+ const initial = await runTurnGuarded(runnerCtx, () =>
1517
+ runPromptTurn(runnerCtx, {
1518
+ text: ctx.instructions.full,
1519
+ model: sdkModel,
1520
+ signal: abortController.signal,
1521
+ }),
1522
+ );
1523
+
1524
+ // post-run gate retry loop — every resume is another session.prompt()
1525
+ // against the same sessionID, so MCP, plugins, provider sockets stay
1526
+ // warm and the session's prompt cache survives.
1527
+ const result = await runPostRunRetryLoop({
1528
+ ctx,
1529
+ initialResult: initial,
1530
+ initialUsage: initial.usage,
1531
+ reflectionPrompt:
1532
+ ctx.toolState.learningsFilePath &&
1533
+ shouldRunReflection(ctx.toolState.selectedMode)
1534
+ ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
1535
+ : undefined,
1536
+ resume: async (c) =>
1537
+ runTurnGuarded(runnerCtx, () =>
1538
+ runPromptTurn(runnerCtx, {
1539
+ text: c.prompt,
1540
+ model: sdkModel,
1541
+ signal: abortController.signal,
1542
+ }),
1543
+ ),
1544
+ });
1545
+
1546
+ // gate the todo-tracker flush on the post-run loop's final verdict
1547
+ // (`result.success`), not the initial turn — otherwise a Review that
1548
+ // exhausts the `unsubmittedReview` retry budget flips success to
1549
+ // false but the tracker still flushes "completed" tasks to GitHub.
1550
+ // mirrors the old `if (result.exitCode === 0)` discriminant.
1551
+ if (result.success) {
1552
+ await ctx.todoTracker?.flush();
1553
+ } else {
1554
+ ctx.todoTracker?.cancel();
1555
+ }
1556
+
1557
+ return result;
1558
+ } finally {
1559
+ watchdog.stop();
1560
+ abortController.abort();
1561
+ await eventLoopPromise.catch(() => {});
1562
+ }
1563
+ } finally {
1564
+ await server.close().catch((err) => {
1565
+ log.debug(
1566
+ `opencode server close failed: ${err instanceof Error ? err.message : String(err)}`,
1567
+ );
1568
+ });
1569
+ await dispatcher.close().catch(() => {});
1570
+ }
1571
+ },
1572
+ });
1573
+
1574
+ /**
1575
+ * Safety net around a single turn: convert any unexpected throw that escapes
1576
+ * `runPromptTurn` into a `success: false` result so the post-run gate loop
1577
+ * (which expects a result, not a rejection) can surface it through the generic
1578
+ * renderer.
1579
+ *
1580
+ * Watchdog-fired aborts do NOT reach here — `runPromptTurn` owns the abort
1581
+ * signal, catches the aborted `session.prompt` rejection internally, and
1582
+ * classifies it as an `activity timeout` error itself. This wrapper must not
1583
+ * re-classify, since a stray post-prompt throw is not a hang.
1584
+ */
1585
+ export async function runTurnGuarded(
1586
+ ctx: RunnerContext,
1587
+ fn: () => Promise<AgentResult>,
1588
+ ): Promise<AgentResult> {
1589
+ try {
1590
+ return await fn();
1591
+ } catch (err) {
1592
+ const errorMessage = err instanceof Error ? err.message : String(err);
1593
+ log.info(`» ${ctx.label} turn failed: ${errorMessage}`);
1594
+ return {
1595
+ success: false,
1596
+ output: ctx.currentTurn?.finalText ?? "",
1597
+ error: errorMessage,
1598
+ };
1599
+ }
1600
+ }