| ... | @@ -28,6 +28,8 @@ import { | ... | @@ -28,6 +28,8 @@ import { |
| 28 | type PermissionUpdate, | 28 | type PermissionUpdate, |
| 29 | type Query as ClaudeQuery, | 29 | type Query as ClaudeQuery, |
| 30 | type Settings as ClaudeSdkSettings, | 30 | type Settings as ClaudeSdkSettings, |
| | 31 | type SpawnOptions as ClaudeSpawnOptions, |
| | 32 | type SpawnedProcess as ClaudeSpawnedProcess, |
| 31 | type SDKAssistantMessage, | 33 | type SDKAssistantMessage, |
| 32 | type SDKAPIRetryMessage, | 34 | type SDKAPIRetryMessage, |
| 33 | type SDKMessage, | 35 | type SDKMessage, |
| ... | @@ -39,6 +41,8 @@ import type { | ... | @@ -39,6 +41,8 @@ import type { |
| 39 | AskUserQuestionInput, | 41 | AskUserQuestionInput, |
| 40 | WebSearchOutput, | 42 | WebSearchOutput, |
| 41 | } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; | 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 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; | 46 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; |
| 43 | import { AgentScope } from "@t3tools/shared/AgentScope"; | 47 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 44 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 48 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| ... | @@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; | ... | @@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; |
| 108 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; | 112 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 109 | import * as ServerConfig from "../../config.ts"; | 113 | import * as ServerConfig from "../../config.ts"; |
| 110 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | 114 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| | 115 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 111 | import { | 116 | import { |
| 112 | claudeSignedOutMessage, | 117 | claudeSignedOutMessage, |
| 113 | makeClaudeEnvironment, | 118 | makeClaudeEnvironment, |
| ... | @@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; | ... | @@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; |
| 225 | // mcp_set_servers once each server connected or hit its own 30 s timeout. | 230 | // mcp_set_servers once each server connected or hit its own 30 s timeout. |
| 226 | const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; | 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 | export const ClaudeProviderCapabilitiesV2 = { | 264 | export const ClaudeProviderCapabilitiesV2 = { |
| 229 | sessions: { | 265 | sessions: { |
| 230 | supportsMultipleProviderThreadsPerSession: false, | 266 | supportsMultipleProviderThreadsPerSession: false, |
| ... | @@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer< | ... | @@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer< |
| 661 | const crypto = yield* Crypto.Crypto; | 697 | const crypto = yield* Crypto.Crypto; |
| 662 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; | 698 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 663 | const agentScope = yield* AgentScope; | 699 | const agentScope = yield* AgentScope; |
| | 700 | const platform = yield* HostProcess.Platform; |
| 664 | | 701 | |
| 665 | return ClaudeAgentSdkQueryRunner.of({ | 702 | return ClaudeAgentSdkQueryRunner.of({ |
| 666 | allocateSessionId: crypto | 703 | allocateSessionId: crypto |
| ... | @@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer< | ... | @@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer< |
| 689 | // of the environment every command the agent runs inherits. | 726 | // of the environment every command the agent runs inherits. |
| 690 | const { mcpServers, ...queryOptions } = input.options; | 727 | const { mcpServers, ...queryOptions } = input.options; |
| 691 | // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, | 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 | 729 | // which lets the agent scope wrapper go in front of the CLI. The SDK |
| 693 | // SDK keeps its own stderr capture and exit reporting. The SDK runs a | 730 | // runs a script entry through node instead, so those launch unwrapped. |
| 694 | // script entry through node instead, so those launch unwrapped. | | |
| 695 | const binaryPath = queryOptions.pathToClaudeCodeExecutable; | 731 | const binaryPath = queryOptions.pathToClaudeCodeExecutable; |
| 696 | const launch = | 732 | const launch = |
| 697 | binaryPath === undefined || | 733 | binaryPath === undefined || |
| ... | @@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer< | ... | @@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer< |
| 712 | pathToClaudeCodeExecutable: launch.command, | 748 | pathToClaudeCodeExecutable: launch.command, |
| 713 | executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], | 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 | // The CLI is running once query() returns, so it is closed if anything | 756 | // The CLI is running once query() returns, so it is closed if anything |
| 716 | // before the session is handed out fails or is interrupted. | 757 | // before the session is handed out fails or is interrupted. |
| 717 | const queryRuntime = yield* Effect.uninterruptibleMask((restore) => | 758 | const queryRuntime = yield* Effect.uninterruptibleMask((restore) => |
| 718 | Effect.try({ | 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 | catch: (cause) => queryRunnerError(cause, "query"), | 768 | catch: (cause) => queryRunnerError(cause, "query"), |
| 721 | }).pipe( | 769 | }).pipe( |
| 722 | Effect.tap((queryRuntime) => | 770 | Effect.tap((queryRuntime) => |