terramend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code agent — secure harness around the `claude` CLI.
|
|
3
|
+
*
|
|
4
|
+
* mirrors the opencode harness's security model:
|
|
5
|
+
* - native exec tools (Bash, Monitor, REPL, Workflow) blocked via BOTH
|
|
6
|
+
* --disallowedTools AND managed-settings.json `permissions.deny` (the agent
|
|
7
|
+
* cannot shell out / run code outside the MCP shell). the managed-settings
|
|
8
|
+
* deny is the authoritative, bypass-immune layer: `--disallowedTools` alone
|
|
9
|
+
* (a `cliArg`-source deny) was observed to leak under
|
|
10
|
+
* `--dangerously-skip-permissions`, surfacing a secret env marker via the
|
|
11
|
+
* native Bash tool. managed-settings denies are `policySettings`-source,
|
|
12
|
+
* highest precedence, and survive bypassPermissions mode.
|
|
13
|
+
* - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
|
|
14
|
+
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
|
|
15
|
+
* - MCP server injected via --mcp-config (not replacing project config)
|
|
16
|
+
* - ASKPASS handles git auth separately (token never in subprocess env)
|
|
17
|
+
*
|
|
18
|
+
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
|
|
19
|
+
* security is enforced at the tool layer, not the process layer.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { performance } from "node:perf_hooks";
|
|
25
|
+
import {
|
|
26
|
+
buildClaudePretoolGateSettings,
|
|
27
|
+
CLAUDE_PRETOOL_GATE_FILENAME,
|
|
28
|
+
CLAUDE_PRETOOL_GATE_SOURCE,
|
|
29
|
+
} from "#app/agents/claudePretoolGate";
|
|
30
|
+
import { startGateServer } from "#app/agents/gateServer";
|
|
31
|
+
import {
|
|
32
|
+
GIT_NATIVE_READ_DENY_CLAUDE,
|
|
33
|
+
GIT_NATIVE_WRITE_DENY_CLAUDE,
|
|
34
|
+
} from "#app/agents/nativeFsDenies";
|
|
35
|
+
import { finalizeAgentResult } from "#app/agents/postRun";
|
|
36
|
+
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "#app/agents/reviewer";
|
|
37
|
+
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "#app/agents/sessionLabeler";
|
|
38
|
+
import {
|
|
39
|
+
type AgentResult,
|
|
40
|
+
type AgentRunContext,
|
|
41
|
+
type AgentUsage,
|
|
42
|
+
agent,
|
|
43
|
+
logTokenTable,
|
|
44
|
+
MAX_STDERR_LINES,
|
|
45
|
+
MCP_SERVER_TOKEN_ENV,
|
|
46
|
+
} from "#app/agents/shared";
|
|
47
|
+
import { terramendMcpName } from "#app/external";
|
|
48
|
+
import {
|
|
49
|
+
BEDROCK_MODEL_ID_ENV,
|
|
50
|
+
isBedrockAnthropicId,
|
|
51
|
+
isVertexAnthropicId,
|
|
52
|
+
VERTEX_MODEL_ID_ENV,
|
|
53
|
+
} from "#app/models";
|
|
54
|
+
import { AGENT_ACTIVITY_TIMEOUT_MS, getIdleMs, markActivity } from "#app/utils/activity";
|
|
55
|
+
import { preflightClaudeSubscription } from "#app/utils/claudeSubscription";
|
|
56
|
+
import { formatJsonValue, log } from "#app/utils/cli";
|
|
57
|
+
import { installFromNpmTarball } from "#app/utils/install";
|
|
58
|
+
import { findProviderErrorMatch } from "#app/utils/providerErrors";
|
|
59
|
+
import { installBundledSkills } from "#app/utils/skills";
|
|
60
|
+
import {
|
|
61
|
+
DEFAULT_MAX_RETAINED_BYTES,
|
|
62
|
+
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
|
63
|
+
SpawnTimeoutError,
|
|
64
|
+
spawn,
|
|
65
|
+
TailBuffer,
|
|
66
|
+
} from "#app/utils/subprocess";
|
|
67
|
+
import { resolveTerraformMcp, TERRAFORM_MCP_SERVER_NAME } from "#app/utils/terraformMcp";
|
|
68
|
+
import { ThinkingTimer } from "#app/utils/timer";
|
|
69
|
+
import type { TodoTracker } from "#app/utils/todoTracking";
|
|
70
|
+
import { getDevDependencyVersion } from "#app/utils/version";
|
|
71
|
+
import { applyClaudeVertexEnv } from "#app/utils/vertex";
|
|
72
|
+
|
|
73
|
+
async function installClaudeCli(): Promise<string> {
|
|
74
|
+
return await installFromNpmTarball({
|
|
75
|
+
packageName: "@anthropic-ai/claude-code",
|
|
76
|
+
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
|
|
77
|
+
// 2.1.113+ ships a native binary (bin/claude.exe) instead of cli.js; the
|
|
78
|
+
// package postinstall copies it from the platform optionalDependency, so we
|
|
79
|
+
// need installDependencies to run that postinstall.
|
|
80
|
+
executablePath: "bin/claude.exe",
|
|
81
|
+
installDependencies: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Native claude-code tools that execute arbitrary shell/code and therefore
|
|
87
|
+
* bypass Terramend's security boundary (the restricted MCP `shell` tool with a
|
|
88
|
+
* filtered, secret-free env). These run inside the agent process with full env,
|
|
89
|
+
* so leaving any of them enabled defeats both `shell: "disabled"` AND the
|
|
90
|
+
* env-filtering that the MCP shell relies on even when shell is enabled.
|
|
91
|
+
*
|
|
92
|
+
* As of claude-code 2.1.150 the exec surface is no longer just `Bash`:
|
|
93
|
+
* - `Monitor` runs a shell command/script (the `command` field)
|
|
94
|
+
* - `REPL` runs arbitrary JavaScript (can `require("node:child_process")`)
|
|
95
|
+
* - `Workflow` orchestrates subagents/pipelines that can reach the above
|
|
96
|
+
* Each is denied at top level and inside `Agent(...)` (Task subagents), mirroring
|
|
97
|
+
* the existing `Bash` / `Agent(Bash)` pair. Denying a tool that isn't registered
|
|
98
|
+
* in a given run is a harmless no-op, so this list is also forward-safe.
|
|
99
|
+
*
|
|
100
|
+
* `CLAUDE_EXEC_TOOL_DENY_RULES` is wired into TWO surfaces: `--disallowedTools`
|
|
101
|
+
* (removes the tools from the advertised list) and managed-settings.json
|
|
102
|
+
* `permissions.deny` (the authoritative, bypass-immune deny — see
|
|
103
|
+
* buildManagedSettings). The flag alone proved insufficient: under
|
|
104
|
+
* `--dangerously-skip-permissions` the native Bash tool ran despite
|
|
105
|
+
* `--disallowedTools Bash`, leaking a per-run secret marker.
|
|
106
|
+
*/
|
|
107
|
+
const CLAUDE_EXEC_TOOLS = ["Bash", "Monitor", "REPL", "Workflow"] as const;
|
|
108
|
+
export const CLAUDE_EXEC_TOOL_DENY_RULES = [
|
|
109
|
+
...CLAUDE_EXEC_TOOLS,
|
|
110
|
+
...CLAUDE_EXEC_TOOLS.map((t) => `Agent(${t})`),
|
|
111
|
+
];
|
|
112
|
+
const CLAUDE_DISALLOWED_TOOLS = CLAUDE_EXEC_TOOL_DENY_RULES.join(",");
|
|
113
|
+
|
|
114
|
+
// ── config ─────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
// Claude Code expands `${VAR}` in .mcp.json values, including HTTP-server
|
|
117
|
+
// `headers` (code.claude.com/docs/en/mcp-configuration "Environment variable
|
|
118
|
+
// expansion"), so the on-disk mcp.json carries only this placeholder — never the
|
|
119
|
+
// raw token, which travels via MCP_SERVER_TOKEN_ENV on the agent's spawn env.
|
|
120
|
+
const TERRAMEND_MCP_AUTH_HEADER = `Bearer \${${MCP_SERVER_TOKEN_ENV}}`;
|
|
121
|
+
|
|
122
|
+
export function writeMcpConfig(ctx: AgentRunContext): string {
|
|
123
|
+
const configDir = join(ctx.tmpdir, ".claude");
|
|
124
|
+
mkdirSync(configDir, { recursive: true });
|
|
125
|
+
const configPath = join(configDir, "mcp.json");
|
|
126
|
+
// P2.2 — opt-in second server: HashiCorp's terraform-mcp-server (registry
|
|
127
|
+
// toolset, docker stdio) for live module/provider knowledge.
|
|
128
|
+
const terraformMcp = resolveTerraformMcp(ctx.payload);
|
|
129
|
+
if (terraformMcp.kind === "docker_missing") log.info(`» ${terraformMcp.note}`);
|
|
130
|
+
writeFileSync(
|
|
131
|
+
configPath,
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
mcpServers: {
|
|
134
|
+
[terramendMcpName]: {
|
|
135
|
+
type: "http",
|
|
136
|
+
url: ctx.mcpServerUrl,
|
|
137
|
+
headers: { Authorization: TERRAMEND_MCP_AUTH_HEADER },
|
|
138
|
+
},
|
|
139
|
+
...(terraformMcp.kind === "available"
|
|
140
|
+
? {
|
|
141
|
+
[TERRAFORM_MCP_SERVER_NAME]: {
|
|
142
|
+
type: "stdio",
|
|
143
|
+
command: terraformMcp.command,
|
|
144
|
+
args: terraformMcp.args,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
return configPath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Drop the PreToolUse gate script + its `--settings` JSON into the per-run
|
|
156
|
+
* tmpdir and return the absolute path to the settings file. The script
|
|
157
|
+
* blocks state-mutating MCP tool calls when `agent_id` is non-empty (i.e.,
|
|
158
|
+
* the call originates inside a Task/Agent subagent dispatch). See
|
|
159
|
+
* action/agents/claudePretoolGate.ts for the contract.
|
|
160
|
+
*
|
|
161
|
+
* Two paths register the gate:
|
|
162
|
+
* 1. flag settings (`--settings <path>`) — covers non-CI runs (`pnpm dev:run`,
|
|
163
|
+
* local dev) where `installManagedSettings` is a no-op.
|
|
164
|
+
* 2. managed settings (/etc/claude-code/managed-settings.json) — covers CI,
|
|
165
|
+
* where `allowManagedHooksOnly: true` filters flag-settings hooks. The
|
|
166
|
+
* same hook entry is embedded in `buildManagedSettings` below.
|
|
167
|
+
*
|
|
168
|
+
* The flag settings also carry the native exec-tool `permissions.deny`
|
|
169
|
+
* (via `buildClaudePretoolGateSettings`) so non-CI runs (where managed
|
|
170
|
+
* settings are absent) still block native Bash et al. at a settings-source
|
|
171
|
+
* deny, not just the `--disallowedTools` cliArg deny that proved leaky under
|
|
172
|
+
* `--dangerously-skip-permissions`.
|
|
173
|
+
*/
|
|
174
|
+
function writePretoolGateAssets(ctx: AgentRunContext): {
|
|
175
|
+
scriptPath: string;
|
|
176
|
+
settingsPath: string;
|
|
177
|
+
} {
|
|
178
|
+
const scriptPath = join(ctx.tmpdir, CLAUDE_PRETOOL_GATE_FILENAME);
|
|
179
|
+
writeFileSync(scriptPath, CLAUDE_PRETOOL_GATE_SOURCE);
|
|
180
|
+
chmodSync(scriptPath, 0o755);
|
|
181
|
+
const settingsPath = join(ctx.tmpdir, "terramend-claude-settings.json");
|
|
182
|
+
const settings = buildClaudePretoolGateSettings(scriptPath, CLAUDE_EXEC_TOOL_DENY_RULES);
|
|
183
|
+
writeFileSync(settingsPath, JSON.stringify(settings));
|
|
184
|
+
return { scriptPath, settingsPath };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build the `--agents` JSON definition for the `reviewfrog` subagent.
|
|
189
|
+
*
|
|
190
|
+
* The Claude Code path always runs against an Anthropic model (see
|
|
191
|
+
* resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run
|
|
192
|
+
* on Sonnet, the orchestrator stays on whatever model `--model` was passed.
|
|
193
|
+
*
|
|
194
|
+
* Per-call model override is also possible (Task tool's `model` arg accepts
|
|
195
|
+
* 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here —
|
|
196
|
+
* we don't pass it; the per-subagent `model` field is the right default.
|
|
197
|
+
*
|
|
198
|
+
* The non-mutative + non-recursive contract is enforced by the prose system
|
|
199
|
+
* prompt baked into the agent — see action/agents/reviewer.ts for why we
|
|
200
|
+
* no longer wire per-agent `disallowedTools` here.
|
|
201
|
+
*/
|
|
202
|
+
export function buildAgentsJson(): string {
|
|
203
|
+
const agents = {
|
|
204
|
+
[REVIEWER_AGENT_NAME]: {
|
|
205
|
+
description:
|
|
206
|
+
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
|
|
207
|
+
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
|
208
|
+
prompt: REVIEWER_SYSTEM_PROMPT,
|
|
209
|
+
model: "claude-sonnet-4-6",
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
return JSON.stringify(agents);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── model helpers ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
// claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers
|
|
218
|
+
export function stripProviderPrefix(specifier: string): string {
|
|
219
|
+
const slashIndex = specifier.indexOf("/");
|
|
220
|
+
return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `high` is the model's tuned default ("equivalent to not setting the parameter"
|
|
224
|
+
// per Anthropic docs). `max` is "absolute maximum capability with no constraints
|
|
225
|
+
// on token spending" — meaningfully slower and burns more thinking budget per
|
|
226
|
+
// turn. We default everyone to `high`; PRs that genuinely need full-send can
|
|
227
|
+
// opt in via a future per-run override rather than paying the wall-time cost on
|
|
228
|
+
// every Opus run.
|
|
229
|
+
function resolveEffort(_model: string | undefined): "high" {
|
|
230
|
+
return "high";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
interface ContentBlock {
|
|
236
|
+
type: string;
|
|
237
|
+
text?: string;
|
|
238
|
+
id?: string;
|
|
239
|
+
name?: string;
|
|
240
|
+
input?: unknown;
|
|
241
|
+
tool_use_id?: string;
|
|
242
|
+
content?: string | unknown;
|
|
243
|
+
is_error?: boolean;
|
|
244
|
+
[key: string]: unknown;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// SDK schema (per claude-agent-sdk docs) puts `session_id` and
|
|
248
|
+
// `parent_tool_use_id` at the top level of every Assistant/User/System/Result
|
|
249
|
+
// message, not inside `message`. Subagent events carry a non-null
|
|
250
|
+
// `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id.
|
|
251
|
+
interface ClaudeSystemEvent {
|
|
252
|
+
type: "system";
|
|
253
|
+
session_id?: string;
|
|
254
|
+
parent_tool_use_id?: string | null;
|
|
255
|
+
[key: string]: unknown;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ClaudeAssistantEvent {
|
|
259
|
+
type: "assistant";
|
|
260
|
+
session_id?: string;
|
|
261
|
+
parent_tool_use_id?: string | null;
|
|
262
|
+
message?: {
|
|
263
|
+
role?: string;
|
|
264
|
+
content?: ContentBlock[];
|
|
265
|
+
model?: string;
|
|
266
|
+
usage?: {
|
|
267
|
+
input_tokens?: number;
|
|
268
|
+
output_tokens?: number;
|
|
269
|
+
cache_creation_input_tokens?: number;
|
|
270
|
+
cache_read_input_tokens?: number;
|
|
271
|
+
};
|
|
272
|
+
[key: string]: unknown;
|
|
273
|
+
};
|
|
274
|
+
[key: string]: unknown;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface ClaudeUserEvent {
|
|
278
|
+
type: "user";
|
|
279
|
+
session_id?: string;
|
|
280
|
+
parent_tool_use_id?: string | null;
|
|
281
|
+
message?: {
|
|
282
|
+
role?: string;
|
|
283
|
+
content?: ContentBlock[];
|
|
284
|
+
[key: string]: unknown;
|
|
285
|
+
};
|
|
286
|
+
[key: string]: unknown;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface ClaudeResultEvent {
|
|
290
|
+
type: "result";
|
|
291
|
+
subtype?: string;
|
|
292
|
+
// claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
|
|
293
|
+
// an upstream provider fails mid-stream. `api_error_status` carries the
|
|
294
|
+
// provider HTTP status (e.g. 401 for invalid API key). per the official
|
|
295
|
+
// SDK types, `api_error_status` is `number | null`, and the `error_*`
|
|
296
|
+
// subtypes carry their actionable payload in `errors: string[]` instead
|
|
297
|
+
// of `result`.
|
|
298
|
+
is_error?: boolean;
|
|
299
|
+
api_error_status?: number | null;
|
|
300
|
+
errors?: string[];
|
|
301
|
+
result?: string;
|
|
302
|
+
session_id?: string;
|
|
303
|
+
num_turns?: number;
|
|
304
|
+
total_cost_usd?: number;
|
|
305
|
+
total_input_tokens?: number;
|
|
306
|
+
total_output_tokens?: number;
|
|
307
|
+
usage?: {
|
|
308
|
+
input_tokens?: number;
|
|
309
|
+
output_tokens?: number;
|
|
310
|
+
cache_read_input_tokens?: number;
|
|
311
|
+
cache_creation_input_tokens?: number;
|
|
312
|
+
};
|
|
313
|
+
[key: string]: unknown;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// additional event types emitted by Claude CLI (handled as no-ops / debug)
|
|
317
|
+
interface ClaudeStreamEvent {
|
|
318
|
+
type: "stream_event";
|
|
319
|
+
[key: string]: unknown;
|
|
320
|
+
}
|
|
321
|
+
interface ClaudeToolProgressEvent {
|
|
322
|
+
type: "tool_progress";
|
|
323
|
+
[key: string]: unknown;
|
|
324
|
+
}
|
|
325
|
+
interface ClaudeToolUseSummaryEvent {
|
|
326
|
+
type: "tool_use_summary";
|
|
327
|
+
[key: string]: unknown;
|
|
328
|
+
}
|
|
329
|
+
interface ClaudeAuthStatusEvent {
|
|
330
|
+
type: "auth_status";
|
|
331
|
+
[key: string]: unknown;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type ClaudeEvent =
|
|
335
|
+
| ClaudeSystemEvent
|
|
336
|
+
| ClaudeAssistantEvent
|
|
337
|
+
| ClaudeUserEvent
|
|
338
|
+
| ClaudeResultEvent
|
|
339
|
+
| ClaudeStreamEvent
|
|
340
|
+
| ClaudeToolProgressEvent
|
|
341
|
+
| ClaudeToolUseSummaryEvent
|
|
342
|
+
| ClaudeAuthStatusEvent;
|
|
343
|
+
|
|
344
|
+
// ── runner ──────────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
type RunParams = {
|
|
347
|
+
label: string;
|
|
348
|
+
cmd: string;
|
|
349
|
+
args: string[];
|
|
350
|
+
cwd: string;
|
|
351
|
+
env: Record<string, string | undefined>;
|
|
352
|
+
todoTracker?: TodoTracker | undefined;
|
|
353
|
+
onActivityTimeout?: (() => void) | undefined;
|
|
354
|
+
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
|
|
361
|
+
* dropping any partial first line. used in the exit-non-zero stdout fallback
|
|
362
|
+
* so we never surface a truncated NDJSON event to operators —
|
|
363
|
+
* `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
|
|
364
|
+
* syntactically broken JSON fragment. code units rather than bytes because
|
|
365
|
+
* `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
|
|
366
|
+
* content the effective byte budget can be up to 4× the nominal limit.
|
|
367
|
+
*/
|
|
368
|
+
export function tailLines(text: string, maxCodeUnits: number): string {
|
|
369
|
+
if (text.length <= maxCodeUnits) return text;
|
|
370
|
+
const tail = text.slice(-maxCodeUnits);
|
|
371
|
+
const firstNewline = tail.indexOf("\n");
|
|
372
|
+
// if no newline in window or it's at the very start, return as-is;
|
|
373
|
+
// otherwise drop the partial first line.
|
|
374
|
+
return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
|
378
|
+
const startTime = performance.now();
|
|
379
|
+
let eventCount = 0;
|
|
380
|
+
|
|
381
|
+
// per-session labeler so parallel subagent log lines can be differentiated.
|
|
382
|
+
// claude-agent-sdk runs subagents inside the orchestrator's session — they
|
|
383
|
+
// share `session_id` — and stamps every subagent message with a non-null
|
|
384
|
+
// `parent_tool_use_id` pointing at the Agent tool_use that spawned them.
|
|
385
|
+
// we bind each Agent tool_use id to its dispatched label up front, then
|
|
386
|
+
// labelFor short-circuits to the direct mapping when parent_tool_use_id is
|
|
387
|
+
// set. orchestrator events (parent_tool_use_id === null) flow through the
|
|
388
|
+
// sessionID path and bind to ORCHESTRATOR_LABEL on first sighting.
|
|
389
|
+
const labeler = new SessionLabeler();
|
|
390
|
+
function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string {
|
|
391
|
+
return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null);
|
|
392
|
+
}
|
|
393
|
+
function withLabel(label: string, message: string): string {
|
|
394
|
+
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// one ThinkingTimer per session — sharing a single timer across sessions
|
|
398
|
+
// conflated cross-session interleaving as parent thinking time. each timer
|
|
399
|
+
// formats its log lines through the session label so attribution is visible.
|
|
400
|
+
const thinkingTimers = new Map<string, ThinkingTimer>();
|
|
401
|
+
function timerFor(label: string): ThinkingTimer {
|
|
402
|
+
let t = thinkingTimers.get(label);
|
|
403
|
+
if (!t) {
|
|
404
|
+
const formatLine = (line: string) =>
|
|
405
|
+
label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
|
|
406
|
+
t = new ThinkingTimer(formatLine);
|
|
407
|
+
thinkingTimers.set(label, t);
|
|
408
|
+
}
|
|
409
|
+
return t;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let finalOutput = "";
|
|
413
|
+
let sessionId: string | undefined;
|
|
414
|
+
let resultErrorSubtype: string | null = null;
|
|
415
|
+
// captures the structured error string from a result event with
|
|
416
|
+
// `is_error: true` (e.g. mid-stream provider auth failures the CLI
|
|
417
|
+
// surfaces as `subtype: "success"` synthetic-stop events, or the
|
|
418
|
+
// `errors[]` array from `error_*` subtypes). preferred over raw
|
|
419
|
+
// stdout/stderr in the exit-non-zero path so the GitHub Actions
|
|
420
|
+
// `##[error]` line shows the actionable message instead of an 8KB+
|
|
421
|
+
// NDJSON dump.
|
|
422
|
+
let lastResultError: string | null = null;
|
|
423
|
+
// set only for synthetic-stop `subtype: "success"` + `is_error: true`
|
|
424
|
+
// events, where `accumulatedTokens` from prior `assistant` events is
|
|
425
|
+
// stale and logging it would mislead operators into thinking billable
|
|
426
|
+
// tokens were spent on a successful turn. deliberately NOT set for
|
|
427
|
+
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
|
428
|
+
// because those runs genuinely consumed tokens and operators need
|
|
429
|
+
// billing visibility for them.
|
|
430
|
+
let syntheticStopFailure = false;
|
|
431
|
+
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
432
|
+
// Claude CLI reports a single end-of-run `total_cost_usd` on the result
|
|
433
|
+
// event. per-message events don't carry cost, so there's nothing to sum —
|
|
434
|
+
// we just capture the final value when it arrives.
|
|
435
|
+
let accumulatedCostUsd = 0;
|
|
436
|
+
let tokensLogged = false;
|
|
437
|
+
|
|
438
|
+
function buildUsage(): AgentUsage | undefined {
|
|
439
|
+
const totalInput =
|
|
440
|
+
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
|
|
441
|
+
return totalInput > 0 || accumulatedTokens.output > 0
|
|
442
|
+
? {
|
|
443
|
+
agent: "claude",
|
|
444
|
+
inputTokens: totalInput,
|
|
445
|
+
outputTokens: accumulatedTokens.output,
|
|
446
|
+
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
|
|
447
|
+
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
|
|
448
|
+
costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined,
|
|
449
|
+
}
|
|
450
|
+
: undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const handlers = {
|
|
454
|
+
system: (event: ClaudeSystemEvent) => {
|
|
455
|
+
// claude-agent-sdk only emits system:init for the top-level query, so
|
|
456
|
+
// this binds the orchestrator label and never appears in subagent flow.
|
|
457
|
+
// we still route through eventLabel so a subagent system event (if the
|
|
458
|
+
// SDK ever adds one) wouldn't go silently misattributed.
|
|
459
|
+
const label = eventLabel(event);
|
|
460
|
+
log.debug(withLabel(label, `» ${params.label} system event`));
|
|
461
|
+
},
|
|
462
|
+
assistant: (event: ClaudeAssistantEvent) => {
|
|
463
|
+
const content = event.message?.content;
|
|
464
|
+
if (!content) return;
|
|
465
|
+
|
|
466
|
+
const label = eventLabel(event);
|
|
467
|
+
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
|
|
468
|
+
|
|
469
|
+
for (const block of content) {
|
|
470
|
+
if (block.type === "text" && block.text?.trim()) {
|
|
471
|
+
const message = block.text.trim();
|
|
472
|
+
log.box(message, { title: boxTitle });
|
|
473
|
+
// only the orchestrator's text becomes the run's "output" — subagent
|
|
474
|
+
// report-back text would otherwise clobber the parent's final answer.
|
|
475
|
+
if (label === ORCHESTRATOR_LABEL) {
|
|
476
|
+
finalOutput = message;
|
|
477
|
+
}
|
|
478
|
+
} else if (block.type === "tool_use") {
|
|
479
|
+
const toolName = block.name || "unknown";
|
|
480
|
+
if (params.onToolUse) {
|
|
481
|
+
params.onToolUse({
|
|
482
|
+
toolName,
|
|
483
|
+
input: block.input,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
timerFor(label).markToolCall();
|
|
487
|
+
const inputFormatted = formatJsonValue(block.input || {});
|
|
488
|
+
const toolCallLine =
|
|
489
|
+
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
|
490
|
+
log.info(withLabel(label, toolCallLine));
|
|
491
|
+
|
|
492
|
+
// when the orchestrator dispatches a subagent, bind the Agent
|
|
493
|
+
// tool_use id to the dispatched label so future events carrying
|
|
494
|
+
// `parent_tool_use_id === block.id` resolve directly to the right
|
|
495
|
+
// lens. v2.1.63+ renamed the tool to "Agent"; older versions
|
|
496
|
+
// emitted "Task". match both for forward-compat.
|
|
497
|
+
if (
|
|
498
|
+
(toolName === "Task" || toolName === "Agent") &&
|
|
499
|
+
block.input &&
|
|
500
|
+
typeof block.input === "object"
|
|
501
|
+
) {
|
|
502
|
+
const taskInput = block.input as {
|
|
503
|
+
description?: string;
|
|
504
|
+
subagent_type?: string;
|
|
505
|
+
prompt?: string;
|
|
506
|
+
};
|
|
507
|
+
const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null);
|
|
508
|
+
log.info(
|
|
509
|
+
withLabel(
|
|
510
|
+
label,
|
|
511
|
+
`» dispatching subagent: ${dispatchedLabel}` +
|
|
512
|
+
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : ""),
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// agent's explicit MCP report_progress takes priority over todo tracking
|
|
518
|
+
if (toolName.includes("report_progress") && params.todoTracker) {
|
|
519
|
+
log.debug("» report_progress detected, disabling todo tracking");
|
|
520
|
+
params.todoTracker.cancel();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// parse TodoWrite events for live progress tracking. only honor the
|
|
524
|
+
// orchestrator's todos — subagents emit their own todo lists which
|
|
525
|
+
// would otherwise clobber the visible progress comment.
|
|
526
|
+
if (
|
|
527
|
+
toolName === "TodoWrite" &&
|
|
528
|
+
params.todoTracker?.enabled &&
|
|
529
|
+
label === ORCHESTRATOR_LABEL
|
|
530
|
+
) {
|
|
531
|
+
params.todoTracker.update(block.input);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// accumulate per-message usage if available. capture cache fields too
|
|
537
|
+
// so the fallback token table (used when no final `result` event fires)
|
|
538
|
+
// still reports the full breakdown instead of silently dropping cache.
|
|
539
|
+
const msgUsage = event.message?.usage;
|
|
540
|
+
if (msgUsage) {
|
|
541
|
+
accumulatedTokens.input += msgUsage.input_tokens || 0;
|
|
542
|
+
accumulatedTokens.output += msgUsage.output_tokens || 0;
|
|
543
|
+
accumulatedTokens.cacheRead += msgUsage.cache_read_input_tokens || 0;
|
|
544
|
+
accumulatedTokens.cacheWrite += msgUsage.cache_creation_input_tokens || 0;
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
user: (event: ClaudeUserEvent) => {
|
|
548
|
+
const content = event.message?.content;
|
|
549
|
+
if (!content) return;
|
|
550
|
+
|
|
551
|
+
const label = eventLabel(event);
|
|
552
|
+
|
|
553
|
+
for (const block of content) {
|
|
554
|
+
if (typeof block === "string") continue;
|
|
555
|
+
if (block.type === "tool_result") {
|
|
556
|
+
timerFor(label).markToolResult();
|
|
557
|
+
|
|
558
|
+
const outputContent =
|
|
559
|
+
typeof block.content === "string"
|
|
560
|
+
? block.content
|
|
561
|
+
: Array.isArray(block.content)
|
|
562
|
+
? (block.content as unknown[])
|
|
563
|
+
.map((entry: unknown) =>
|
|
564
|
+
typeof entry === "string"
|
|
565
|
+
? entry
|
|
566
|
+
: typeof entry === "object" && entry !== null && "text" in entry
|
|
567
|
+
? String((entry as { text: unknown }).text)
|
|
568
|
+
: JSON.stringify(entry),
|
|
569
|
+
)
|
|
570
|
+
.join("\n")
|
|
571
|
+
: String(block.content);
|
|
572
|
+
|
|
573
|
+
if (block.is_error) {
|
|
574
|
+
log.info(withLabel(label, `» tool error: ${outputContent}`));
|
|
575
|
+
} else {
|
|
576
|
+
log.debug(withLabel(label, `» tool output: ${outputContent}`));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
result: (event: ClaudeResultEvent) => {
|
|
582
|
+
if (event.session_id) sessionId = event.session_id;
|
|
583
|
+
const subtype = event.subtype || "unknown";
|
|
584
|
+
const numTurns = event.num_turns || 0;
|
|
585
|
+
|
|
586
|
+
// claude CLI emits synthetic-stop result events with `subtype: "success"`
|
|
587
|
+
// but `is_error: true` when an upstream provider fails mid-stream (e.g.
|
|
588
|
+
// 401 from anthropic). short-circuit before the usage/token-table path
|
|
589
|
+
// so we don't log a usage table for a failed attempt and so downstream
|
|
590
|
+
// (`resultErrorSubtype` branch) surfaces the structured error. gated on
|
|
591
|
+
// `subtype === "success"` because the `error_*` subtypes also set
|
|
592
|
+
// `is_error: true` but carry their payload in `errors: string[]` and
|
|
593
|
+
// are handled by the dedicated branches below.
|
|
594
|
+
if (event.is_error === true && subtype === "success") {
|
|
595
|
+
const apiStatus = event.api_error_status;
|
|
596
|
+
lastResultError =
|
|
597
|
+
event.result?.trim() ||
|
|
598
|
+
`claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
|
|
599
|
+
resultErrorSubtype = subtype;
|
|
600
|
+
syntheticStopFailure = true;
|
|
601
|
+
log.info(
|
|
602
|
+
`» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`,
|
|
603
|
+
);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (subtype === "success") {
|
|
608
|
+
// extract detailed usage from result event (most accurate source).
|
|
609
|
+
// note: `input` here is non-cached input tokens only, matching the
|
|
610
|
+
// semantics of OpenCode's step_finish.tokens.input — the logTokenTable
|
|
611
|
+
// helper sums Input + Cache Read + Cache Write + Output into the Total
|
|
612
|
+
// column so consumers get the real billable figure.
|
|
613
|
+
const usage = event.usage;
|
|
614
|
+
const inputTokens = usage?.input_tokens || 0;
|
|
615
|
+
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
616
|
+
const cacheWrite = usage?.cache_creation_input_tokens || 0;
|
|
617
|
+
const outputTokens = usage?.output_tokens || 0;
|
|
618
|
+
// guard against NaN/Infinity from malformed CLI output poisoning the total
|
|
619
|
+
const costUsd =
|
|
620
|
+
typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd)
|
|
621
|
+
? event.total_cost_usd
|
|
622
|
+
: 0;
|
|
623
|
+
|
|
624
|
+
accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
|
|
625
|
+
accumulatedCostUsd = costUsd;
|
|
626
|
+
|
|
627
|
+
log.info(`» ${params.label} result: subtype=${subtype}, turns=${numTurns}`);
|
|
628
|
+
|
|
629
|
+
if (!tokensLogged) {
|
|
630
|
+
logTokenTable({
|
|
631
|
+
input: inputTokens,
|
|
632
|
+
cacheRead,
|
|
633
|
+
cacheWrite,
|
|
634
|
+
output: outputTokens,
|
|
635
|
+
costUsd,
|
|
636
|
+
});
|
|
637
|
+
tokensLogged = true;
|
|
638
|
+
}
|
|
639
|
+
} else if (subtype === "error_max_turns") {
|
|
640
|
+
resultErrorSubtype = subtype;
|
|
641
|
+
lastResultError = event.errors?.join("\n").trim() || null;
|
|
642
|
+
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
|
|
643
|
+
} else if (subtype === "error_during_execution") {
|
|
644
|
+
resultErrorSubtype = subtype;
|
|
645
|
+
lastResultError = event.errors?.join("\n").trim() || null;
|
|
646
|
+
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
|
|
647
|
+
} else if (subtype.startsWith("error")) {
|
|
648
|
+
resultErrorSubtype = subtype;
|
|
649
|
+
lastResultError = event.errors?.join("\n").trim() || null;
|
|
650
|
+
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
|
651
|
+
} else {
|
|
652
|
+
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (event.result?.trim()) {
|
|
656
|
+
finalOutput = event.result.trim();
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
// additional Claude CLI event types — debug-logged only
|
|
660
|
+
stream_event: () => {},
|
|
661
|
+
tool_progress: () => {},
|
|
662
|
+
tool_use_summary: () => {},
|
|
663
|
+
auth_status: () => {},
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const recentStderr: string[] = [];
|
|
667
|
+
// ring buffer of recent non-JSON stdout lines. Claude CLI prints
|
|
668
|
+
// human-readable TTY chrome (status bubbles, quota notices, etc.)
|
|
669
|
+
// alongside the NDJSON event stream. when the CLI exits non-zero without
|
|
670
|
+
// emitting a structured error event, these lines are the only actionable
|
|
671
|
+
// signal — preferring them over the NDJSON tail keeps progress comments
|
|
672
|
+
// readable. issue #643.
|
|
673
|
+
const recentNonJsonStdout: string[] = [];
|
|
674
|
+
|
|
675
|
+
let lastProviderError: string | null = null;
|
|
676
|
+
|
|
677
|
+
// capped accumulator — see opencode.ts for rationale (issue #680).
|
|
678
|
+
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
|
679
|
+
let stdoutBuffer = "";
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const result = await spawn({
|
|
683
|
+
cmd: params.cmd,
|
|
684
|
+
args: params.args,
|
|
685
|
+
cwd: params.cwd,
|
|
686
|
+
env: params.env,
|
|
687
|
+
// flat agent idle budget — long synchronous MCP tool calls (issue #760)
|
|
688
|
+
// sit well under it, so no per-toolcall suspend bracketing is needed.
|
|
689
|
+
activityTimeout: AGENT_ACTIVITY_TIMEOUT_MS,
|
|
690
|
+
onActivityTimeout: params.onActivityTimeout,
|
|
691
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
692
|
+
// run claude in its own process group so SIGKILL on activity timeout /
|
|
693
|
+
// outer cancellation reaches any subprocesses it spawns (rg, file
|
|
694
|
+
// watchers, mcp transports, etc). claude (2.1.113+) is now a native
|
|
695
|
+
// binary like opencode-ai/bin/opencode, so detached + killGroup is
|
|
696
|
+
// required to avoid orphaning the binary and its children.
|
|
697
|
+
killGroup: true,
|
|
698
|
+
// claude already drains every chunk via onStdout (NDJSON parsing) and
|
|
699
|
+
// onStderr (recentStderr ring buffer). retaining a second copy in the
|
|
700
|
+
// spawn wrapper would grow unbounded for long sessions and previously
|
|
701
|
+
// crashed the wrapper with RangeError. see issue #680.
|
|
702
|
+
retain: "none",
|
|
703
|
+
onStdout: async (chunk) => {
|
|
704
|
+
const text = chunk.toString();
|
|
705
|
+
output.append(text);
|
|
706
|
+
markActivity();
|
|
707
|
+
|
|
708
|
+
stdoutBuffer += text;
|
|
709
|
+
const lines = stdoutBuffer.split("\n");
|
|
710
|
+
stdoutBuffer = lines.pop() || "";
|
|
711
|
+
|
|
712
|
+
for (const line of lines) {
|
|
713
|
+
const trimmed = line.trim();
|
|
714
|
+
if (!trimmed) continue;
|
|
715
|
+
|
|
716
|
+
let event: ClaudeEvent;
|
|
717
|
+
try {
|
|
718
|
+
event = JSON.parse(trimmed) as ClaudeEvent;
|
|
719
|
+
} catch {
|
|
720
|
+
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
|
721
|
+
recentNonJsonStdout.push(trimmed);
|
|
722
|
+
if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift();
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
eventCount++;
|
|
727
|
+
log.debug(JSON.stringify(event, null, 2));
|
|
728
|
+
|
|
729
|
+
const timeSinceLastActivity = getIdleMs();
|
|
730
|
+
if (timeSinceLastActivity > 10000) {
|
|
731
|
+
log.info(
|
|
732
|
+
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
markActivity();
|
|
736
|
+
|
|
737
|
+
const handler = handlers[event.type as keyof typeof handlers];
|
|
738
|
+
if (!handler) {
|
|
739
|
+
log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
(handler as (e: ClaudeEvent) => void)(event);
|
|
744
|
+
} catch (err) {
|
|
745
|
+
log.info(
|
|
746
|
+
`» ${params.label} handler for type=${event.type} threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
onStderr: (chunk) => {
|
|
752
|
+
const trimmed = chunk.trim();
|
|
753
|
+
if (!trimmed) return;
|
|
754
|
+
|
|
755
|
+
recentStderr.push(trimmed);
|
|
756
|
+
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
|
|
757
|
+
|
|
758
|
+
const match = findProviderErrorMatch(trimmed);
|
|
759
|
+
if (match) {
|
|
760
|
+
lastProviderError = match.label;
|
|
761
|
+
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
|
|
762
|
+
} else {
|
|
763
|
+
log.debug(trimmed);
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
if (result.exitCode === 0) {
|
|
769
|
+
await params.todoTracker?.flush();
|
|
770
|
+
} else {
|
|
771
|
+
params.todoTracker?.cancel();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const duration = performance.now() - startTime;
|
|
775
|
+
log.info(
|
|
776
|
+
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`,
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
if (eventCount === 0) {
|
|
780
|
+
const stderrContext = recentStderr.join("\n");
|
|
781
|
+
const diagnosis = lastProviderError
|
|
782
|
+
? `provider error: ${lastProviderError}`
|
|
783
|
+
: "unknown cause (no stdout events received)";
|
|
784
|
+
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
|
|
785
|
+
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// skip the fallback token table only for the synthetic-stop
|
|
789
|
+
// `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
|
|
790
|
+
// prior `assistant` events is stale there and logging it would mislead
|
|
791
|
+
// operators into thinking billable tokens were spent on a successful turn.
|
|
792
|
+
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
|
793
|
+
// represent runs that genuinely consumed tokens, so they still get the
|
|
794
|
+
// table for billing visibility.
|
|
795
|
+
if (
|
|
796
|
+
!tokensLogged &&
|
|
797
|
+
!syntheticStopFailure &&
|
|
798
|
+
(accumulatedTokens.input > 0 ||
|
|
799
|
+
accumulatedTokens.output > 0 ||
|
|
800
|
+
accumulatedTokens.cacheRead > 0 ||
|
|
801
|
+
accumulatedTokens.cacheWrite > 0)
|
|
802
|
+
) {
|
|
803
|
+
logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
|
|
804
|
+
tokensLogged = true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const usage = buildUsage();
|
|
808
|
+
|
|
809
|
+
if (result.exitCode !== 0) {
|
|
810
|
+
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
|
811
|
+
// prefer the structured `lastResultError` (parsed from a result event
|
|
812
|
+
// with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
|
|
813
|
+
// event stream — dumping it into a GitHub Actions `##[error]` line both
|
|
814
|
+
// hides the actionable provider message and pollutes the run log. cap
|
|
815
|
+
// the stdout fallback to the last 2KB so it stays readable when neither
|
|
816
|
+
// a structured error nor stderr is available.
|
|
817
|
+
//
|
|
818
|
+
// result.stdout / result.stderr are empty because we pass retain:"none"
|
|
819
|
+
// to spawn (see issue #680); the agent layer keeps its own bounded
|
|
820
|
+
// mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
|
|
821
|
+
const stdoutSnapshot = output.toString();
|
|
822
|
+
const stderrSnapshot = recentStderr.join("\n");
|
|
823
|
+
const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
|
|
824
|
+
// prefer non-JSON stdout (human-readable TTY chrome the CLI prints,
|
|
825
|
+
// including status bubbles and quota notices) over the raw NDJSON
|
|
826
|
+
// tail. when the CLI exits 1 without emitting `is_error` (issue #643),
|
|
827
|
+
// the NDJSON fallback would otherwise dump 2KB of `system/init` events
|
|
828
|
+
// into the progress comment with no mention of the actual cause.
|
|
829
|
+
const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
|
|
830
|
+
const errorMessage =
|
|
831
|
+
lastResultError ||
|
|
832
|
+
stderrSnapshot ||
|
|
833
|
+
nonJsonStdoutSnapshot ||
|
|
834
|
+
truncatedStdout ||
|
|
835
|
+
`unknown error - no output from Claude CLI${errorContext}`;
|
|
836
|
+
log.error(
|
|
837
|
+
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`,
|
|
838
|
+
);
|
|
839
|
+
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
|
840
|
+
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
|
841
|
+
return {
|
|
842
|
+
success: false,
|
|
843
|
+
output: finalOutput || stdoutSnapshot,
|
|
844
|
+
error: errorMessage,
|
|
845
|
+
usage,
|
|
846
|
+
sessionId,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (eventCount === 0 && lastProviderError) {
|
|
851
|
+
return {
|
|
852
|
+
success: false,
|
|
853
|
+
output: finalOutput || output.toString(),
|
|
854
|
+
error: `provider error: ${lastProviderError}`,
|
|
855
|
+
usage,
|
|
856
|
+
sessionId,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (resultErrorSubtype) {
|
|
861
|
+
return {
|
|
862
|
+
success: false,
|
|
863
|
+
output: finalOutput || output.toString(),
|
|
864
|
+
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
|
865
|
+
usage,
|
|
866
|
+
sessionId,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { success: true, output: finalOutput || output.toString(), usage, sessionId };
|
|
871
|
+
} catch (error) {
|
|
872
|
+
params.todoTracker?.cancel();
|
|
873
|
+
const duration = performance.now() - startTime;
|
|
874
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
875
|
+
const isActivityTimeout =
|
|
876
|
+
error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
|
|
877
|
+
|
|
878
|
+
const stderrContext = recentStderr.slice(-10).join("\n");
|
|
879
|
+
const diagnosis = lastProviderError
|
|
880
|
+
? `likely cause: ${lastProviderError}`
|
|
881
|
+
: eventCount === 0
|
|
882
|
+
? "Claude produced 0 stdout events - check if the API is reachable"
|
|
883
|
+
: `${eventCount} events were processed before the hang`;
|
|
884
|
+
|
|
885
|
+
log.info(
|
|
886
|
+
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`,
|
|
887
|
+
);
|
|
888
|
+
log.info(`» diagnosis: ${diagnosis}`);
|
|
889
|
+
if (stderrContext)
|
|
890
|
+
log.info(
|
|
891
|
+
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
success: false,
|
|
896
|
+
output: finalOutput || output.toString(),
|
|
897
|
+
error: `${errorMessage} [${diagnosis}]`,
|
|
898
|
+
usage: buildUsage(),
|
|
899
|
+
sessionId,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── managed settings ────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
const MANAGED_SETTINGS_DIR = "/etc/claude-code";
|
|
907
|
+
const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
|
|
908
|
+
|
|
909
|
+
// managed-settings.json has absolute highest precedence in Claude Code's config hierarchy.
|
|
910
|
+
// it cannot be overridden by user, project, or local settings — safe against malicious PRs.
|
|
911
|
+
//
|
|
912
|
+
// permissions.deny blocks native tools (Read, Grep, Edit, Glob) from accessing /proc and /sys,
|
|
913
|
+
// the git surfaces (blanket Edit(.git/**) write deny + narrow .git/config read deny — see
|
|
914
|
+
// nativeFsDenies.ts), and any path passed in via ctx.secretDenyPaths (codex auth dir, vertex
|
|
915
|
+
// creds dir, etc.).
|
|
916
|
+
// sandbox.filesystem.denyRead blocks the Bash tool sandbox from reading those paths.
|
|
917
|
+
// allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override
|
|
918
|
+
// our deny rules — safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant.
|
|
919
|
+
// allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules.
|
|
920
|
+
// Per Claude Code permissions docs, Read(...) deny ALSO blocks file-reading Bash commands
|
|
921
|
+
// (cat, head, tail, sed) and survives bypassPermissions mode. See wiki/security.md and
|
|
922
|
+
// wiki/codex-auth.md.
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* env var carrying the gate-server URL to the Claude subprocess. the Stop
|
|
926
|
+
* hook curls it on every stop; an absent value disables the hook (e.g.
|
|
927
|
+
* non-CI local dev paths that don't install managed settings either).
|
|
928
|
+
*/
|
|
929
|
+
const STOP_HOOK_GATE_URL_ENV = "TERRAMEND_GATE_URL";
|
|
930
|
+
// `_TOKEN` suffix is intentional: filterEnv() strips it from the agent's shell
|
|
931
|
+
// sandbox, so only the Stop hook (a child of this process) can authenticate to
|
|
932
|
+
// the gate server. See gateServer.ts.
|
|
933
|
+
const STOP_HOOK_GATE_TOKEN_ENV = "TERRAMEND_GATE_TOKEN";
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* managed Stop hook. swaps the old `--resume <sessionId>` follow-up
|
|
937
|
+
* subprocesses (reflection + every gate retry — cost audit on PR #792
|
|
938
|
+
* showed reflection alone burned ~$0.85 / 111K cache_write per Opus run,
|
|
939
|
+
* almost all of it wasted re-running `getAttachmentMessages` in the fresh
|
|
940
|
+
* process) for a `{decision: "block", reason: ...}` injection inside the
|
|
941
|
+
* live `queryLoop`. existing session context is already in the prompt
|
|
942
|
+
* cache so only the new reason text is fresh cache_write.
|
|
943
|
+
*
|
|
944
|
+
* the script is intentionally minimal — all decision logic lives in the
|
|
945
|
+
* sidecar gate server (`gateServer.ts`), which reads live `ctx.toolState`
|
|
946
|
+
* mutations from the same process the MCP server runs in. budget +
|
|
947
|
+
* one-shot tracking lives there too, so re-fires across multiple stops in
|
|
948
|
+
* one session are safe. claude-code's 8-consecutive-block override is the
|
|
949
|
+
* last-line backstop.
|
|
950
|
+
*/
|
|
951
|
+
export function buildStopHookScript(): string {
|
|
952
|
+
return [
|
|
953
|
+
"#!/usr/bin/env bash",
|
|
954
|
+
"set -euo pipefail",
|
|
955
|
+
`url="\${${STOP_HOOK_GATE_URL_ENV}:-}"`,
|
|
956
|
+
`tok="\${${STOP_HOOK_GATE_TOKEN_ENV}:-}"`,
|
|
957
|
+
'if [ -z "$url" ]; then exit 0; fi',
|
|
958
|
+
"cat >/dev/null",
|
|
959
|
+
'response=$(curl -fsS --max-time 30 -H "Authorization: Bearer $tok" "$url" 2>/dev/null || printf \'{"block":false}\')',
|
|
960
|
+
'block=$(printf "%s" "$response" | jq -r ".block // false")',
|
|
961
|
+
'if [ "$block" != "true" ]; then exit 0; fi',
|
|
962
|
+
'reason=$(printf "%s" "$response" | jq -r ".reason // \\"\\"")',
|
|
963
|
+
'if [ -z "$reason" ]; then exit 0; fi',
|
|
964
|
+
'jq -n --arg reason "$reason" \'{decision: "block", reason: $reason}\'',
|
|
965
|
+
"",
|
|
966
|
+
].join("\n");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export interface ManagedSettingsParams {
|
|
970
|
+
ctx: AgentRunContext;
|
|
971
|
+
stopHookPath: string | null;
|
|
972
|
+
pretoolGateScriptPath: string;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function buildManagedSettings(params: ManagedSettingsParams): Record<string, unknown> {
|
|
976
|
+
const secretDenyPaths = params.ctx.secretDenyPaths ?? [];
|
|
977
|
+
const toolDeny = secretDenyPaths.flatMap((path) => [
|
|
978
|
+
`Read(${path}/**)`,
|
|
979
|
+
`Read(/${path}/**)`,
|
|
980
|
+
`Grep(${path}/**)`,
|
|
981
|
+
`Grep(/${path}/**)`,
|
|
982
|
+
`Edit(${path}/**)`,
|
|
983
|
+
`Edit(/${path}/**)`,
|
|
984
|
+
`Glob(${path}/**)`,
|
|
985
|
+
`Glob(/${path}/**)`,
|
|
986
|
+
]);
|
|
987
|
+
// single builder for both the PreToolUse gate hook and the native exec-tool
|
|
988
|
+
// deny — both fields are consumed here (and identically in the flag-settings
|
|
989
|
+
// path via writePretoolGateAssets), keeping CLAUDE_EXEC_TOOL_DENY_RULES the
|
|
990
|
+
// single source.
|
|
991
|
+
const gate = buildClaudePretoolGateSettings(
|
|
992
|
+
params.pretoolGateScriptPath,
|
|
993
|
+
CLAUDE_EXEC_TOOL_DENY_RULES,
|
|
994
|
+
);
|
|
995
|
+
const base: Record<string, unknown> = {
|
|
996
|
+
allowManagedPermissionRulesOnly: true,
|
|
997
|
+
allowManagedHooksOnly: true,
|
|
998
|
+
permissions: {
|
|
999
|
+
deny: [
|
|
1000
|
+
// native exec tools — the authoritative, bypass-immune deny.
|
|
1001
|
+
// `--disallowedTools` (a cliArg-source deny) leaked under
|
|
1002
|
+
// `--dangerously-skip-permissions`; policySettings denies survive
|
|
1003
|
+
// bypassPermissions mode. covers top-level + Agent(...) subagent use.
|
|
1004
|
+
...gate.permissions.deny,
|
|
1005
|
+
"Read(//proc/**)",
|
|
1006
|
+
"Read(//sys/**)",
|
|
1007
|
+
"Grep(//proc/**)",
|
|
1008
|
+
"Grep(//sys/**)",
|
|
1009
|
+
"Edit(//proc/**)",
|
|
1010
|
+
"Edit(//sys/**)",
|
|
1011
|
+
"Glob(//proc/**)",
|
|
1012
|
+
"Glob(//sys/**)",
|
|
1013
|
+
// git surfaces — blanket Edit(.git/**) write deny (nothing legit
|
|
1014
|
+
// writes .git via native tools; real commits go through MCP git tools
|
|
1015
|
+
// outside this gate) + narrow Read/Grep/Glob(.git/config) read deny.
|
|
1016
|
+
// mirrors opencode's edit-blanket / read-narrow split. canonical:
|
|
1017
|
+
// action/agents/nativeFsDenies.ts.
|
|
1018
|
+
...GIT_NATIVE_WRITE_DENY_CLAUDE,
|
|
1019
|
+
...GIT_NATIVE_READ_DENY_CLAUDE,
|
|
1020
|
+
...toolDeny,
|
|
1021
|
+
],
|
|
1022
|
+
},
|
|
1023
|
+
sandbox: {
|
|
1024
|
+
filesystem: {
|
|
1025
|
+
denyRead: ["/proc", "/sys", ...secretDenyPaths],
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
// PreToolUse gate replicated into managed settings so it survives the
|
|
1030
|
+
// `allowManagedHooksOnly: true` policy gate (see
|
|
1031
|
+
// src/utils/hooks/hooksConfigSnapshot.ts in claude-code source). the Stop
|
|
1032
|
+
// hook (gate-server retries) is layered into the same `hooks` object when
|
|
1033
|
+
// present so both fire under managed settings.
|
|
1034
|
+
const hooks: Record<string, unknown> = {
|
|
1035
|
+
...gate.hooks,
|
|
1036
|
+
};
|
|
1037
|
+
if (params.stopHookPath) {
|
|
1038
|
+
hooks.Stop = [
|
|
1039
|
+
{
|
|
1040
|
+
hooks: [{ type: "command", command: params.stopHookPath }],
|
|
1041
|
+
},
|
|
1042
|
+
];
|
|
1043
|
+
}
|
|
1044
|
+
base.hooks = hooks;
|
|
1045
|
+
return base;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function installManagedSettings(params: ManagedSettingsParams): void {
|
|
1049
|
+
if (process.env.CI !== "true") return;
|
|
1050
|
+
|
|
1051
|
+
const content = JSON.stringify(buildManagedSettings(params), null, 2);
|
|
1052
|
+
try {
|
|
1053
|
+
execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
|
|
1054
|
+
execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], {
|
|
1055
|
+
input: content,
|
|
1056
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
1057
|
+
});
|
|
1058
|
+
log.debug(`» wrote managed settings to ${MANAGED_SETTINGS_PATH}`);
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
log.warning(`» failed to install managed settings: ${err}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// ── agent ───────────────────────────────────────────────────────────────────────
|
|
1065
|
+
|
|
1066
|
+
export const claude = agent({
|
|
1067
|
+
name: "claude",
|
|
1068
|
+
install: installClaudeCli,
|
|
1069
|
+
run: async (ctx) => {
|
|
1070
|
+
const cliPath = await installClaudeCli();
|
|
1071
|
+
|
|
1072
|
+
const specifier = ctx.resolvedModel;
|
|
1073
|
+
// claude-code on Bedrock takes the bare AWS model ID — no provider prefix
|
|
1074
|
+
// to strip, since the ID is already in `provider.model` form (e.g.
|
|
1075
|
+
// `eu.anthropic.claude-opus-4-7`). detect via the env-var sentinel: if
|
|
1076
|
+
// BEDROCK_MODEL_ID is set and matches the resolved specifier, this is a
|
|
1077
|
+
// bedrock route. see `wiki/model-resolution.md` for the routing pattern.
|
|
1078
|
+
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
|
|
1079
|
+
const isBedrockRoute =
|
|
1080
|
+
specifier !== undefined &&
|
|
1081
|
+
bedrockModelId !== undefined &&
|
|
1082
|
+
bedrockModelId === specifier &&
|
|
1083
|
+
isBedrockAnthropicId(specifier);
|
|
1084
|
+
const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
|
|
1085
|
+
const isVertexRoute =
|
|
1086
|
+
specifier !== undefined &&
|
|
1087
|
+
vertexModelId !== undefined &&
|
|
1088
|
+
vertexModelId === specifier &&
|
|
1089
|
+
isVertexAnthropicId(specifier);
|
|
1090
|
+
const model = !specifier
|
|
1091
|
+
? undefined
|
|
1092
|
+
: isBedrockRoute
|
|
1093
|
+
? specifier
|
|
1094
|
+
: isVertexRoute
|
|
1095
|
+
? undefined
|
|
1096
|
+
: stripProviderPrefix(specifier);
|
|
1097
|
+
|
|
1098
|
+
const homeEnv = {
|
|
1099
|
+
HOME: ctx.tmpdir,
|
|
1100
|
+
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
|
|
1104
|
+
|
|
1105
|
+
installBundledSkills({ home: homeEnv.HOME });
|
|
1106
|
+
|
|
1107
|
+
const mcpConfigPath = writeMcpConfig(ctx);
|
|
1108
|
+
const effort = resolveEffort(model);
|
|
1109
|
+
|
|
1110
|
+
// PreToolUse gate that hard-blocks state-mutating MCP tool calls from
|
|
1111
|
+
// subagents (the `agent_id` field is non-empty in the hook input only
|
|
1112
|
+
// for subagent-originated calls — verified against
|
|
1113
|
+
// yasasbanukaofficial/claude-code src/utils/hooks.ts createBaseHookInput).
|
|
1114
|
+
// Wired via two surfaces so it fires in both CI and local (see
|
|
1115
|
+
// writePretoolGateAssets / buildManagedSettings comments).
|
|
1116
|
+
const pretoolGate = writePretoolGateAssets(ctx);
|
|
1117
|
+
|
|
1118
|
+
// reflection + every gate retry (dirty tree, unsubmitted review, summary
|
|
1119
|
+
// stale) move from post-exit `--resume <sessionId>` subprocesses to a
|
|
1120
|
+
// managed Stop hook that curls a sidecar gate server. see
|
|
1121
|
+
// `buildStopHookScript` for the cost rationale (PR #792 audit) and
|
|
1122
|
+
// `gateServer.ts` for the decision policy.
|
|
1123
|
+
const stopHookPath = join(ctx.tmpdir, "terramend-stop-hook.sh");
|
|
1124
|
+
writeFileSync(stopHookPath, buildStopHookScript(), { mode: 0o755 });
|
|
1125
|
+
|
|
1126
|
+
installManagedSettings({ ctx, stopHookPath, pretoolGateScriptPath: pretoolGate.scriptPath });
|
|
1127
|
+
|
|
1128
|
+
// base args shared between initial run and continue runs
|
|
1129
|
+
const baseArgs = [
|
|
1130
|
+
"--output-format",
|
|
1131
|
+
"stream-json",
|
|
1132
|
+
"--dangerously-skip-permissions",
|
|
1133
|
+
"--mcp-config",
|
|
1134
|
+
mcpConfigPath,
|
|
1135
|
+
"--settings",
|
|
1136
|
+
pretoolGate.settingsPath,
|
|
1137
|
+
"--verbose",
|
|
1138
|
+
"--effort",
|
|
1139
|
+
effort,
|
|
1140
|
+
"--disallowedTools",
|
|
1141
|
+
CLAUDE_DISALLOWED_TOOLS,
|
|
1142
|
+
"--agents",
|
|
1143
|
+
buildAgentsJson(),
|
|
1144
|
+
];
|
|
1145
|
+
|
|
1146
|
+
if (model) {
|
|
1147
|
+
baseArgs.push("--model", model);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
|
|
1151
|
+
// security is enforced via managed-settings.json, --disallowedTools (native exec tools), and MCP tool filtering.
|
|
1152
|
+
//
|
|
1153
|
+
// bedrock route: claude-code reads `CLAUDE_CODE_USE_BEDROCK=1` to switch
|
|
1154
|
+
// its provider implementation from the direct Anthropic API to Bedrock.
|
|
1155
|
+
// AWS_BEARER_TOKEN_BEDROCK / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY +
|
|
1156
|
+
// AWS_REGION are already in process.env from the workflow's `env:` block.
|
|
1157
|
+
// see https://docs.claude.com/en/docs/claude-code/amazon-bedrock.
|
|
1158
|
+
//
|
|
1159
|
+
// we only force CLAUDE_CODE_USE_BEDROCK=1 when this is a Terramend-routed
|
|
1160
|
+
// bedrock run; if the user has set the env var manually for some other
|
|
1161
|
+
// reason (e.g. always-Bedrock org policy), `...process.env` already
|
|
1162
|
+
// carries it through and we don't disturb it.
|
|
1163
|
+
const repoDir = process.cwd();
|
|
1164
|
+
|
|
1165
|
+
// PWD must match the spawn cwd (see opencode.ts for the analogous fix).
|
|
1166
|
+
// claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
|
|
1167
|
+
// additional-working-directory when it differs from `process.cwd()` (per
|
|
1168
|
+
// the bundled cli.js — `let H=process.env.PWD; if(H && H !== Y7() && ...)
|
|
1169
|
+
// j.set(H, {path: H, source: "session"})`). Inheriting harness PWD via
|
|
1170
|
+
// `...process.env` ends up adding the wrong dir to the agent's allowed
|
|
1171
|
+
// working set under `pnpm runtest` / `pnpm dev:run`, which silently confuses
|
|
1172
|
+
// path-relative tools.
|
|
1173
|
+
const env: Record<string, string | undefined> = {
|
|
1174
|
+
...process.env,
|
|
1175
|
+
...homeEnv,
|
|
1176
|
+
PWD: repoDir,
|
|
1177
|
+
};
|
|
1178
|
+
if (isBedrockRoute) {
|
|
1179
|
+
env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
1180
|
+
}
|
|
1181
|
+
if (isVertexRoute) {
|
|
1182
|
+
applyClaudeVertexEnv(env);
|
|
1183
|
+
env.ANTHROPIC_MODEL = specifier;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
|
|
1187
|
+
// token when both are set, so we strip the API key to fall through to the
|
|
1188
|
+
// Max-subscription path. bedrock route uses AWS creds and is excluded.
|
|
1189
|
+
// the strip is gated on a 1-token preflight: an exhausted (session/weekly
|
|
1190
|
+
// limit) or revoked subscription would otherwise kill the run at its first
|
|
1191
|
+
// model call with a working API key sitting unused in env.
|
|
1192
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env.ANTHROPIC_API_KEY) {
|
|
1193
|
+
const preflight = await preflightClaudeSubscription({
|
|
1194
|
+
token: env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
1195
|
+
model,
|
|
1196
|
+
});
|
|
1197
|
+
if (preflight.usable) {
|
|
1198
|
+
log.debug(
|
|
1199
|
+
"» CLAUDE_CODE_OAUTH_TOKEN present — stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used",
|
|
1200
|
+
);
|
|
1201
|
+
delete env.ANTHROPIC_API_KEY;
|
|
1202
|
+
} else {
|
|
1203
|
+
log.info(
|
|
1204
|
+
`» Claude subscription unusable (${preflight.reason}) — falling back to ANTHROPIC_API_KEY`,
|
|
1205
|
+
);
|
|
1206
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
log.info(`» effort: ${effort}`);
|
|
1211
|
+
log.debug(`» starting Terramend (Claude Code): ${cliPath} ${baseArgs.join(" ")}`);
|
|
1212
|
+
log.debug(`» working directory: ${repoDir}`);
|
|
1213
|
+
|
|
1214
|
+
// gate server lives only as long as the claude subprocess does. the
|
|
1215
|
+
// Stop hook curls `gateServer.url` and turns the response into its
|
|
1216
|
+
// `{decision: "block", reason}` payload (or exits 0 to allow stop).
|
|
1217
|
+
await using gateServer = await startGateServer(ctx);
|
|
1218
|
+
|
|
1219
|
+
const result = await runClaude({
|
|
1220
|
+
label: "Terramend",
|
|
1221
|
+
cmd: cliPath,
|
|
1222
|
+
cwd: repoDir,
|
|
1223
|
+
env: {
|
|
1224
|
+
...env,
|
|
1225
|
+
[STOP_HOOK_GATE_URL_ENV]: gateServer.url,
|
|
1226
|
+
[STOP_HOOK_GATE_TOKEN_ENV]: gateServer.token,
|
|
1227
|
+
// the MCP client (this Claude process) expands ${TERRAMEND_MCP_TOKEN}
|
|
1228
|
+
// in mcp.json headers from here; filterEnv() strips it from the MCP
|
|
1229
|
+
// shell sandbox so a sandboxed command can't read it.
|
|
1230
|
+
[MCP_SERVER_TOKEN_ENV]: ctx.mcpServerToken,
|
|
1231
|
+
},
|
|
1232
|
+
todoTracker: ctx.todoTracker,
|
|
1233
|
+
onActivityTimeout: ctx.onActivityTimeout,
|
|
1234
|
+
onToolUse: ctx.onToolUse,
|
|
1235
|
+
args: [...baseArgs, "-p", ctx.instructions.full],
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// every follow-up turn (reflection + gate retries) has already happened
|
|
1239
|
+
// inside this single subprocess via the Stop hook, so usage aggregation
|
|
1240
|
+
// and resume orchestration are no-ops. all that remains is the terminal
|
|
1241
|
+
// hard-fail render: when the budget exhausted with `stopHook` /
|
|
1242
|
+
// `unsubmittedReview` still failing, flip `success` to false with the
|
|
1243
|
+
// same error shape `runPostRunRetryLoop` produced pre-migration.
|
|
1244
|
+
return finalizeAgentResult({ ctx, result });
|
|
1245
|
+
},
|
|
1246
|
+
});
|