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