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.
- package/LICENSE +661 -0
- package/README.md +244 -0
- package/dist/agents/claude.d.ts +113 -0
- package/dist/agents/claudePretoolGate.d.ts +137 -0
- package/dist/agents/dispatchMetrics.d.ts +77 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +46 -0
- package/dist/agents/opencode.d.ts +284 -0
- package/dist/agents/opencodePlugin.d.ts +96 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +126 -0
- package/dist/agents/reviewer.d.ts +41 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +246 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/tokenQuota.d.ts +118 -0
- package/dist/agents/writeGateSource.d.ts +22 -0
- package/dist/agents/writePolicy.d.ts +79 -0
- package/dist/brand.d.ts +67 -0
- package/dist/cli.mjs +247846 -0
- package/dist/external.d.ts +227 -0
- package/dist/i18n/scaffolding.d.ts +59 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +247061 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +2398 -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/assess.d.ts +157 -0
- package/dist/mcp/capabilityContext.d.ts +71 -0
- package/dist/mcp/changeSummary.d.ts +52 -0
- package/dist/mcp/checkSuite.d.ts +27 -0
- package/dist/mcp/checkout.d.ts +92 -0
- package/dist/mcp/comment.d.ts +127 -0
- package/dist/mcp/commitInfo.d.ts +11 -0
- package/dist/mcp/crosswalk.d.ts +178 -0
- package/dist/mcp/crosswalkDigest.d.ts +1 -0
- package/dist/mcp/cyberEssentials.d.ts +24 -0
- package/dist/mcp/dashboard.d.ts +125 -0
- package/dist/mcp/dependencies.d.ts +12 -0
- package/dist/mcp/frameworks.d.ts +74 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +60 -0
- package/dist/mcp/guardrails.d.ts +222 -0
- package/dist/mcp/issue.d.ts +20 -0
- package/dist/mcp/issueComments.d.ts +11 -0
- package/dist/mcp/issueEvents.d.ts +11 -0
- package/dist/mcp/issueInfo.d.ts +11 -0
- package/dist/mcp/labels.d.ts +14 -0
- package/dist/mcp/localContext.d.ts +26 -0
- package/dist/mcp/moduleExtraction.d.ts +79 -0
- package/dist/mcp/moduleTests.d.ts +106 -0
- package/dist/mcp/modules.d.ts +198 -0
- package/dist/mcp/output.d.ts +16 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +50 -0
- package/dist/mcp/pr.d.ts +64 -0
- package/dist/mcp/prInfo.d.ts +11 -0
- package/dist/mcp/providerSchema.d.ts +52 -0
- package/dist/mcp/review.d.ts +212 -0
- package/dist/mcp/reviewComments.d.ts +245 -0
- package/dist/mcp/roots.d.ts +60 -0
- package/dist/mcp/scope.d.ts +26 -0
- package/dist/mcp/selectMode.d.ts +20 -0
- package/dist/mcp/server.d.ts +61 -0
- package/dist/mcp/shared.d.ts +62 -0
- package/dist/mcp/shell.d.ts +58 -0
- package/dist/mcp/staleFix.d.ts +138 -0
- package/dist/mcp/terraform/azurePrices.d.ts +8 -0
- package/dist/mcp/terraform/baseline.d.ts +57 -0
- package/dist/mcp/terraform/concernResult.d.ts +38 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/cspm.d.ts +80 -0
- package/dist/mcp/terraform/currency.d.ts +110 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/eidas.d.ts +100 -0
- package/dist/mcp/terraform/evidence.d.ts +343 -0
- package/dist/mcp/terraform/findings.d.ts +76 -0
- package/dist/mcp/terraform/fixMemory.d.ts +81 -0
- package/dist/mcp/terraform/governance.d.ts +56 -0
- package/dist/mcp/terraform/hcl.d.ts +115 -0
- package/dist/mcp/terraform/iacLanguages.d.ts +120 -0
- package/dist/mcp/terraform/idleFloor.d.ts +78 -0
- package/dist/mcp/terraform/keyless.d.ts +68 -0
- package/dist/mcp/terraform/moduleDocs.d.ts +56 -0
- package/dist/mcp/terraform/nativeRules.d.ts +38 -0
- package/dist/mcp/terraform/nativeScan.d.ts +11 -0
- package/dist/mcp/terraform/noiseProfile.d.ts +100 -0
- package/dist/mcp/terraform/normalization.d.ts +52 -0
- package/dist/mcp/terraform/oscal.d.ts +103 -0
- package/dist/mcp/terraform/packs/azurerm-3-4.d.ts +2 -0
- package/dist/mcp/terraform/packs/cloudflare-4-5.d.ts +2 -0
- package/dist/mcp/terraform/paths.d.ts +28 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/planDrift.d.ts +199 -0
- package/dist/mcp/terraform/policyAuthor.d.ts +191 -0
- package/dist/mcp/terraform/prowlerOcsf.d.ts +136 -0
- package/dist/mcp/terraform/refactor.d.ts +183 -0
- package/dist/mcp/terraform/registry.d.ts +108 -0
- package/dist/mcp/terraform/risk.d.ts +41 -0
- package/dist/mcp/terraform/scanSession.d.ts +116 -0
- package/dist/mcp/terraform/scannerCache.d.ts +15 -0
- package/dist/mcp/terraform/scannerEgress.d.ts +72 -0
- package/dist/mcp/terraform/scannerJson.d.ts +13 -0
- package/dist/mcp/terraform/scanners.d.ts +205 -0
- package/dist/mcp/terraform/signing.d.ts +61 -0
- package/dist/mcp/terraform/stateLocking.d.ts +18 -0
- package/dist/mcp/terraform/subprocess.d.ts +43 -0
- package/dist/mcp/terraform/suppressions.d.ts +90 -0
- package/dist/mcp/terraform/taxonomy.d.ts +39 -0
- package/dist/mcp/terraform/tfquery.d.ts +47 -0
- package/dist/mcp/terraform/toolchainIntegrity.d.ts +82 -0
- package/dist/mcp/terraform/tools/authorPolicy.d.ts +59 -0
- package/dist/mcp/terraform/tools/detectPlanDrift.d.ts +20 -0
- package/dist/mcp/terraform/tools/emitCost.d.ts +11 -0
- package/dist/mcp/terraform/tools/emitSarif.d.ts +14 -0
- package/dist/mcp/terraform/tools/fixMemory.d.ts +19 -0
- package/dist/mcp/terraform/tools/infracostDiff.d.ts +22 -0
- package/dist/mcp/terraform/tools/ingestExternalFindings.d.ts +21 -0
- package/dist/mcp/terraform/tools/moduleLookup.d.ts +14 -0
- package/dist/mcp/terraform/tools/moduleSearch.d.ts +23 -0
- package/dist/mcp/terraform/tools/plan.d.ts +59 -0
- package/dist/mcp/terraform/tools/providerUpgrade.d.ts +55 -0
- package/dist/mcp/terraform/tools/readFindings.d.ts +17 -0
- package/dist/mcp/terraform/tools/scan.d.ts +114 -0
- package/dist/mcp/terraform/tools/validate.d.ts +31 -0
- package/dist/mcp/terraform/tools/verifyRemediation.d.ts +31 -0
- package/dist/mcp/terraform/tools/versionCurrency.d.ts +5 -0
- package/dist/mcp/terraform/tools.d.ts +16 -0
- package/dist/mcp/terraform/transformPacks.d.ts +267 -0
- package/dist/mcp/terraform/types.d.ts +226 -0
- package/dist/mcp/terraform/verification.d.ts +81 -0
- package/dist/mcp/terraform/vex.d.ts +115 -0
- package/dist/mcp/terraform/writeOnly.d.ts +67 -0
- package/dist/mcp/terraform.d.ts +51 -0
- package/dist/mcp/terratest.d.ts +85 -0
- package/dist/mcp/untrustedContent.d.ts +76 -0
- package/dist/mcp/upload.d.ts +8 -0
- package/dist/models.d.ts +181 -0
- package/dist/modes/address-reviews.d.ts +2 -0
- package/dist/modes/assess.d.ts +2 -0
- package/dist/modes/build.d.ts +2 -0
- package/dist/modes/compliance-audit.d.ts +2 -0
- package/dist/modes/cost-optimization.d.ts +2 -0
- package/dist/modes/drift-detect.d.ts +2 -0
- package/dist/modes/fix.d.ts +2 -0
- package/dist/modes/incremental-review.d.ts +2 -0
- package/dist/modes/index.d.ts +24 -0
- package/dist/modes/modernize-deprecated.d.ts +2 -0
- package/dist/modes/plan.d.ts +2 -0
- package/dist/modes/policy-gate.d.ts +2 -0
- package/dist/modes/prFormats.d.ts +3 -0
- package/dist/modes/refactor.d.ts +2 -0
- package/dist/modes/refresh-remediation.d.ts +2 -0
- package/dist/modes/remediate-and-refactor.d.ts +2 -0
- package/dist/modes/remediate.d.ts +2 -0
- package/dist/modes/resolve-conflicts.d.ts +2 -0
- package/dist/modes/review.d.ts +2 -0
- package/dist/modes/summarize-pr.d.ts +2 -0
- package/dist/modes/task.d.ts +2 -0
- package/dist/modes/terraform-code-review.d.ts +2 -0
- package/dist/modes/types.d.ts +9 -0
- package/dist/modes/update-dependencies.d.ts +2 -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 +107 -0
- package/dist/skills/terraform-best-practices/SKILL.md +379 -0
- package/dist/toolState.d.ts +123 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +47 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/annotations.d.ts +17 -0
- package/dist/utils/apiFetch.d.ts +11 -0
- package/dist/utils/apiKeys.d.ts +48 -0
- package/dist/utils/apiUrl.d.ts +28 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/baseRefConfig.d.ts +121 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildInfraweaverFooter.d.ts +29 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/changeImpact.d.ts +101 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/cloudClient.d.ts +33 -0
- package/dist/utils/cloudFetchCore.d.ts +42 -0
- package/dist/utils/cloudReport.d.ts +114 -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/env.d.ts +46 -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 +104 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/infraweaverConfig.d.ts +56 -0
- package/dist/utils/install.d.ts +37 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/keylessOidc.d.ts +28 -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/modelResolver.d.ts +63 -0
- package/dist/utils/moduleFetch.d.ts +39 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/openaiCompatible.d.ts +26 -0
- package/dist/utils/opentofuMcp.d.ts +50 -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 +249 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/presets.d.ts +36 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/proxyModel.d.ts +76 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/redact.d.ts +33 -0
- package/dist/utils/registryAuth.d.ts +52 -0
- package/dist/utils/remediationCommand.d.ts +58 -0
- package/dist/utils/retry.d.ts +38 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +112 -0
- package/dist/utils/runContextData.d.ts +42 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +89 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +34 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +44 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +81 -0
- package/dist/utils/terraformMcp.d.ts +46 -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 +52 -0
- package/dist/utils/toolLicensing.d.ts +56 -0
- package/dist/utils/toolSelection.d.ts +73 -0
- package/dist/utils/toon.d.ts +16 -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 +132 -0
- package/src/agents/claude.ts +1588 -0
- package/src/agents/claudePretoolGate.ts +286 -0
- package/src/agents/dispatchMetrics.ts +162 -0
- package/src/agents/gateServer.ts +178 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +164 -0
- package/src/agents/opencode.ts +1600 -0
- package/src/agents/opencodePlugin.ts +304 -0
- package/src/agents/opencodeShared.ts +134 -0
- package/src/agents/postRun.ts +615 -0
- package/src/agents/reviewer.ts +134 -0
- package/src/agents/sessionLabeler.ts +185 -0
- package/src/agents/shared.ts +377 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/tokenQuota.ts +203 -0
- package/src/agents/writeGateSource.ts +148 -0
- package/src/agents/writePolicy.ts +132 -0
- package/src/brand.ts +84 -0
- package/src/cli.ts +117 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +126 -0
- package/src/commands/verifyEvidence.ts +100 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.ts +109 -0
- package/src/external.ts +308 -0
- package/src/i18n/scaffolding.ts +191 -0
- package/src/index.ts +7 -0
- package/src/internal/index.ts +74 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.ts +815 -0
- package/src/mcp/__fixtures__/infraweaver-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/infraweaver-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/infraweaver-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/assess.ts +723 -0
- package/src/mcp/capabilityContext.ts +80 -0
- package/src/mcp/changeSummary.ts +145 -0
- package/src/mcp/checkSuite.ts +261 -0
- package/src/mcp/checkout.ts +1015 -0
- package/src/mcp/comment.ts +683 -0
- package/src/mcp/commitInfo.ts +57 -0
- package/src/mcp/crosswalk.ts +1253 -0
- package/src/mcp/crosswalkDigest.ts +5 -0
- package/src/mcp/cyberEssentials.ts +60 -0
- package/src/mcp/dashboard.ts +232 -0
- package/src/mcp/dependencies.ts +190 -0
- package/src/mcp/frameworks.ts +236 -0
- package/src/mcp/geminiSanitizer.ts +212 -0
- package/src/mcp/git.ts +1116 -0
- package/src/mcp/guardrails.ts +771 -0
- package/src/mcp/issue.ts +74 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.ts +35 -0
- package/src/mcp/localContext.ts +65 -0
- package/src/mcp/localServer.ts +217 -0
- package/src/mcp/moduleExtraction.ts +368 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.ts +752 -0
- package/src/mcp/output.ts +71 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.ts +226 -0
- package/src/mcp/pr.ts +238 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.ts +1081 -0
- package/src/mcp/reviewComments.ts +1137 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.ts +82 -0
- package/src/mcp/selectMode.ts +207 -0
- package/src/mcp/server.ts +498 -0
- package/src/mcp/shared.ts +120 -0
- package/src/mcp/shell.ts +631 -0
- package/src/mcp/staleFix.ts +557 -0
- package/src/mcp/terraform/__snapshots__/moduleDocs.test.ts.snap +40 -0
- package/src/mcp/terraform/azurePrices.ts +38 -0
- package/src/mcp/terraform/baseline.ts +172 -0
- package/src/mcp/terraform/concernResult.ts +111 -0
- package/src/mcp/terraform/cost.ts +175 -0
- package/src/mcp/terraform/cspm.ts +326 -0
- package/src/mcp/terraform/currency.ts +354 -0
- package/src/mcp/terraform/decisions.ts +590 -0
- package/src/mcp/terraform/eidas.ts +134 -0
- package/src/mcp/terraform/evidence.ts +771 -0
- package/src/mcp/terraform/findings.ts +383 -0
- package/src/mcp/terraform/fixMemory.ts +253 -0
- package/src/mcp/terraform/governance.ts +231 -0
- package/src/mcp/terraform/hcl.ts +273 -0
- package/src/mcp/terraform/iacLanguages.ts +431 -0
- package/src/mcp/terraform/idleFloor.ts +304 -0
- package/src/mcp/terraform/keyless.ts +123 -0
- package/src/mcp/terraform/moduleDocs.ts +303 -0
- package/src/mcp/terraform/nativeRules.ts +152 -0
- package/src/mcp/terraform/nativeScan.ts +67 -0
- package/src/mcp/terraform/noiseProfile.ts +183 -0
- package/src/mcp/terraform/normalization.ts +265 -0
- package/src/mcp/terraform/oscal.ts +283 -0
- package/src/mcp/terraform/packs/azurerm-3-4.ts +382 -0
- package/src/mcp/terraform/packs/cloudflare-4-5.ts +114 -0
- package/src/mcp/terraform/paths.ts +59 -0
- package/src/mcp/terraform/plan.ts +377 -0
- package/src/mcp/terraform/planDrift.ts +520 -0
- package/src/mcp/terraform/policyAuthor.ts +886 -0
- package/src/mcp/terraform/prowlerOcsf.ts +380 -0
- package/src/mcp/terraform/refactor.ts +879 -0
- package/src/mcp/terraform/registry.ts +317 -0
- package/src/mcp/terraform/risk.ts +99 -0
- package/src/mcp/terraform/scanSession.ts +242 -0
- package/src/mcp/terraform/scannerCache.ts +86 -0
- package/src/mcp/terraform/scannerEgress.ts +141 -0
- package/src/mcp/terraform/scannerJson.ts +22 -0
- package/src/mcp/terraform/scanners.ts +1134 -0
- package/src/mcp/terraform/signing.ts +151 -0
- package/src/mcp/terraform/stateLocking.ts +106 -0
- package/src/mcp/terraform/subprocess.ts +102 -0
- package/src/mcp/terraform/suppressions.ts +551 -0
- package/src/mcp/terraform/taxonomy.ts +0 -0
- package/src/mcp/terraform/tfquery.ts +159 -0
- package/src/mcp/terraform/toolchainIntegrity.ts +151 -0
- package/src/mcp/terraform/tools/authorPolicy.ts +185 -0
- package/src/mcp/terraform/tools/detectPlanDrift.ts +279 -0
- package/src/mcp/terraform/tools/emitCost.ts +130 -0
- package/src/mcp/terraform/tools/emitSarif.ts +108 -0
- package/src/mcp/terraform/tools/fixMemory.ts +82 -0
- package/src/mcp/terraform/tools/infracostDiff.ts +110 -0
- package/src/mcp/terraform/tools/ingestExternalFindings.ts +259 -0
- package/src/mcp/terraform/tools/moduleLookup.ts +86 -0
- package/src/mcp/terraform/tools/moduleSearch.ts +46 -0
- package/src/mcp/terraform/tools/plan.ts +384 -0
- package/src/mcp/terraform/tools/providerUpgrade.ts +217 -0
- package/src/mcp/terraform/tools/readFindings.ts +138 -0
- package/src/mcp/terraform/tools/scan.ts +303 -0
- package/src/mcp/terraform/tools/validate.ts +160 -0
- package/src/mcp/terraform/tools/verifyRemediation.ts +190 -0
- package/src/mcp/terraform/tools/versionCurrency.ts +64 -0
- package/src/mcp/terraform/tools.ts +62 -0
- package/src/mcp/terraform/transformPacks.ts +594 -0
- package/src/mcp/terraform/types.ts +382 -0
- package/src/mcp/terraform/verification.ts +150 -0
- package/src/mcp/terraform/vex.ts +367 -0
- package/src/mcp/terraform/writeOnly.ts +252 -0
- package/src/mcp/terraform.ts +52 -0
- package/src/mcp/terratest.ts +215 -0
- package/src/mcp/untrustedContent.ts +126 -0
- package/src/mcp/upload.ts +123 -0
- package/src/models.ts +753 -0
- package/src/modes/address-reviews.ts +35 -0
- package/src/modes/assess.ts +27 -0
- package/src/modes/build.ts +86 -0
- package/src/modes/compliance-audit.ts +25 -0
- package/src/modes/cost-optimization.ts +36 -0
- package/src/modes/drift-detect.ts +31 -0
- package/src/modes/fix.ts +29 -0
- package/src/modes/incremental-review.ts +103 -0
- package/src/modes/index.ts +94 -0
- package/src/modes/modernize-deprecated.ts +36 -0
- package/src/modes/plan.ts +21 -0
- package/src/modes/policy-gate.ts +27 -0
- package/src/modes/prFormats.ts +325 -0
- package/src/modes/refactor.ts +74 -0
- package/src/modes/refresh-remediation.ts +40 -0
- package/src/modes/remediate-and-refactor.ts +42 -0
- package/src/modes/remediate.ts +78 -0
- package/src/modes/resolve-conflicts.ts +32 -0
- package/src/modes/review.ts +135 -0
- package/src/modes/summarize-pr.ts +42 -0
- package/src/modes/task.ts +26 -0
- package/src/modes/terraform-code-review.ts +68 -0
- package/src/modes/types.ts +17 -0
- package/src/modes/update-dependencies.ts +44 -0
- package/src/prep/index.ts +96 -0
- package/src/prep/installNodeDependencies.ts +251 -0
- package/src/prep/installPythonDependencies.ts +235 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.ts +237 -0
- package/src/runCli.ts +335 -0
- package/src/skills/terraform-best-practices/SKILL.md +379 -0
- package/src/toolState.ts +241 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.ts +245 -0
- package/src/utils/agentHangReport.ts +180 -0
- package/src/utils/annotations.ts +56 -0
- package/src/utils/apiFetch.ts +19 -0
- package/src/utils/apiKeys.ts +305 -0
- package/src/utils/apiUrl.ts +42 -0
- package/src/utils/assets.ts +123 -0
- package/src/utils/baseRefConfig.ts +254 -0
- package/src/utils/billingErrors.ts +210 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildInfraweaverFooter.ts +104 -0
- package/src/utils/byokFallback.ts +137 -0
- package/src/utils/changeImpact.ts +330 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +36 -0
- package/src/utils/cloudClient.ts +57 -0
- package/src/utils/cloudFetchCore.ts +139 -0
- package/src/utils/cloudReport.ts +316 -0
- package/src/utils/codexHome.ts +204 -0
- package/src/utils/codexOAuth.ts +154 -0
- package/src/utils/codexRefreshDetect.ts +36 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/env.ts +84 -0
- package/src/utils/errorReport.ts +94 -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.ts +268 -0
- package/src/utils/gitAuthServer.ts +186 -0
- package/src/utils/github.ts +676 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.ts +198 -0
- package/src/utils/infraweaverConfig.ts +202 -0
- package/src/utils/install.ts +279 -0
- package/src/utils/instructions.ts +640 -0
- package/src/utils/keylessOidc.ts +47 -0
- package/src/utils/leapingComment.ts +20 -0
- package/src/utils/learnings.ts +145 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.ts +452 -0
- package/src/utils/modelResolver.ts +105 -0
- package/src/utils/moduleFetch.ts +138 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +86 -0
- package/src/utils/openaiCompatible.ts +52 -0
- package/src/utils/opentofuMcp.ts +61 -0
- package/src/utils/overrides.ts +100 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.ts +173 -0
- package/src/utils/payload.ts +1206 -0
- package/src/utils/prSummary.ts +148 -0
- package/src/utils/presets.ts +105 -0
- package/src/utils/progressComment.ts +266 -0
- package/src/utils/providerErrors.ts +189 -0
- package/src/utils/proxyModel.ts +239 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/redact.ts +86 -0
- package/src/utils/registryAuth.ts +115 -0
- package/src/utils/remediationCommand.ts +144 -0
- package/src/utils/retry.ts +180 -0
- package/src/utils/reviewCleanup.ts +116 -0
- package/src/utils/run.ts +100 -0
- package/src/utils/runContext.ts +276 -0
- package/src/utils/runContextData.ts +121 -0
- package/src/utils/runErrorRenderer.ts +282 -0
- package/src/utils/runFixture.ts +91 -0
- package/src/utils/runLifecycle.ts +360 -0
- package/src/utils/runStartupLog.ts +67 -0
- package/src/utils/secrets.ts +208 -0
- package/src/utils/setup.ts +368 -0
- package/src/utils/shell.ts +132 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.ts +474 -0
- package/src/utils/terraformMcp.ts +99 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.ts +168 -0
- package/src/utils/token.ts +294 -0
- package/src/utils/toolLicensing.ts +152 -0
- package/src/utils/toolSelection.ts +239 -0
- package/src/utils/toon.ts +76 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +94 -0
- 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
|
+
}
|