| ... | ... | @@ -28,6 +28,8 @@ import { |
| 28 | 28 | type PermissionUpdate, |
| 29 | 29 | type Query as ClaudeQuery, |
| 30 | 30 | type Settings as ClaudeSdkSettings, |
| 31 | type SpawnOptions as ClaudeSpawnOptions, |
| 32 | type SpawnedProcess as ClaudeSpawnedProcess, |
| 31 | 33 | type SDKAssistantMessage, |
| 32 | 34 | type SDKAPIRetryMessage, |
| 33 | 35 | type SDKMessage, |
| ... | ... | @@ -39,6 +41,8 @@ import type { |
| 39 | 41 | AskUserQuestionInput, |
| 40 | 42 | WebSearchOutput, |
| 41 | 43 | } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; |
| 44 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. |
| 45 | import * as NodeChildProcess from "node:child_process"; |
| 42 | 46 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; |
| 43 | 47 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 44 | 48 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| ... | ... | @@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; |
| 108 | 112 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 109 | 113 | import * as ServerConfig from "../../config.ts"; |
| 110 | 114 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 115 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 111 | 116 | import { |
| 112 | 117 | claudeSignedOutMessage, |
| 113 | 118 | makeClaudeEnvironment, |
| ... | ... | @@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; |
| 225 | 230 | // mcp_set_servers once each server connected or hit its own 30 s timeout. |
| 226 | 231 | const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; |
| 227 | 232 | |
| 233 | /** |
| 234 | * Starts the Claude CLI as the leader of its own process group. When the CLI |
| 235 | * exits it stops the MCP servers it started, but not their children: a |
| 236 | * plugin's `bun run start` wrapper dies and its `bun server.ts` keeps running, |
| 237 | * writing to a pipe nobody reads (#3089). Those stay in the CLI's group, so |
| 238 | * the rest of the group gets SIGKILL once the CLI exits for any reason. |
| 239 | * Commands the agent runs through its Bash tool start their own groups, so |
| 240 | * background jobs it launched are not part of this. |
| 241 | * |
| 242 | * The SDK reads stderr only from its own spawn, where it redacts credentials |
| 243 | * before adding the tail to exit errors. Here stderr is dropped instead, so |
| 244 | * exit errors carry only the code or signal. |
| 245 | */ |
| 246 | function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { |
| 247 | const child = NodeChildProcess.spawn(options.command, options.args, { |
| 248 | cwd: options.cwd, |
| 249 | env: options.env, |
| 250 | signal: options.signal, |
| 251 | stdio: ["pipe", "pipe", "ignore"], |
| 252 | detached: true, |
| 253 | }); |
| 254 | child.once("exit", () => { |
| 255 | try { |
| 256 | signalProcessGroup(child.pid ?? 0, "SIGKILL"); |
| 257 | } catch { |
| 258 | // The group is already empty. |
| 259 | } |
| 260 | }); |
| 261 | return child; |
| 262 | } |
| 263 | |
| 228 | 264 | export const ClaudeProviderCapabilitiesV2 = { |
| 229 | 265 | sessions: { |
| 230 | 266 | supportsMultipleProviderThreadsPerSession: false, |
| ... | ... | @@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer< |
| 661 | 697 | const crypto = yield* Crypto.Crypto; |
| 662 | 698 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 663 | 699 | const agentScope = yield* AgentScope; |
| 700 | const platform = yield* HostProcess.Platform; |
| 664 | 701 | |
| 665 | 702 | return ClaudeAgentSdkQueryRunner.of({ |
| 666 | 703 | allocateSessionId: crypto |
| ... | ... | @@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer< |
| 689 | 726 | // of the environment every command the agent runs inherits. |
| 690 | 727 | const { mcpServers, ...queryOptions } = input.options; |
| 691 | 728 | // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, |
| 692 | | // which lets the agent scope wrapper go in front of the CLI while the |
| 693 | | // SDK keeps its own stderr capture and exit reporting. The SDK runs a |
| 694 | | // script entry through node instead, so those launch unwrapped. |
| 729 | // which lets the agent scope wrapper go in front of the CLI. The SDK |
| 730 | // runs a script entry through node instead, so those launch unwrapped. |
| 695 | 731 | const binaryPath = queryOptions.pathToClaudeCodeExecutable; |
| 696 | 732 | const launch = |
| 697 | 733 | binaryPath === undefined || |
| ... | ... | @@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer< |
| 712 | 748 | pathToClaudeCodeExecutable: launch.command, |
| 713 | 749 | executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], |
| 714 | 750 | }; |
| 751 | // A spawner the caller passed keeps its process. Windows has no process |
| 752 | // groups, so there the SDK spawns the CLI itself. |
| 753 | const spawnClaudeCodeProcess = |
| 754 | options.spawnClaudeCodeProcess ?? |
| 755 | (platform === "win32" ? undefined : spawnClaudeProcessGroup); |
| 715 | 756 | // The CLI is running once query() returns, so it is closed if anything |
| 716 | 757 | // before the session is handed out fails or is interrupted. |
| 717 | 758 | const queryRuntime = yield* Effect.uninterruptibleMask((restore) => |
| 718 | 759 | Effect.try({ |
| 719 | | try: () => query({ prompt, options }), |
| 760 | try: () => |
| 761 | query({ |
| 762 | prompt, |
| 763 | options: |
| 764 | spawnClaudeCodeProcess === undefined |
| 765 | ? options |
| 766 | : { ...options, spawnClaudeCodeProcess }, |
| 767 | }), |
| 720 | 768 | catch: (cause) => queryRunnerError(cause, "query"), |
| 721 | 769 | }).pipe( |
| 722 | 770 | Effect.tap((queryRuntime) => |