@@ -28,6 +28,8 @@ import {
2828 type PermissionUpdate ,
2929 type Query as ClaudeQuery ,
3030 type Settings as ClaudeSdkSettings ,
31+ type SpawnOptions as ClaudeSpawnOptions ,
32+ type SpawnedProcess as ClaudeSpawnedProcess ,
3133 type SDKAssistantMessage ,
3234 type SDKAPIRetryMessage ,
3335 type SDKMessage ,
@@ -39,6 +41,8 @@ import type {
3941 AskUserQuestionInput ,
4042 WebSearchOutput ,
4143} 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" ;
4246import { parseCliArgs } from "@t3tools/shared/cliArgs" ;
4347import { AgentScope } from "@t3tools/shared/AgentScope" ;
4448import * as HostProcess from "@t3tools/shared/HostProcess" ;
@@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
108112import { compileClaudeModelSelection } from "../../claudeModelOptions.ts" ;
109113import * as ServerConfig from "../../config.ts" ;
110114import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion" ;
115+ import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup" ;
111116import {
112117 claudeSignedOutMessage ,
113118 makeClaudeEnvironment ,
@@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];
225230// mcp_set_servers once each server connected or hit its own 30 s timeout.
226231const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds" ;
227232
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+
228264export const ClaudeProviderCapabilitiesV2 = {
229265 sessions : {
230266 supportsMultipleProviderThreadsPerSession : false ,
@@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer<
661697 const crypto = yield * Crypto . Crypto ;
662698 const { native : nativeEventLogger } = yield * ProviderEventLoggers . ProviderEventLoggers ;
663699 const agentScope = yield * AgentScope ;
700+ const platform = yield * HostProcess . Platform ;
664701
665702 return ClaudeAgentSdkQueryRunner . of ( {
666703 allocateSessionId : crypto
@@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer<
689726 // of the environment every command the agent runs inherits.
690727 const { mcpServers, ...queryOptions } = input . options ;
691728 // 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.
695731 const binaryPath = queryOptions . pathToClaudeCodeExecutable ;
696732 const launch =
697733 binaryPath === undefined ||
@@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer<
712748 pathToClaudeCodeExecutable : launch . command ,
713749 executableArgs : [ ...launch . args , ...( queryOptions . executableArgs ?? [ ] ) ] ,
714750 } ;
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 ) ;
715756 // The CLI is running once query() returns, so it is closed if anything
716757 // before the session is handed out fails or is interrupted.
717758 const queryRuntime = yield * Effect . uninterruptibleMask ( ( restore ) =>
718759 Effect . try ( {
719- try : ( ) => query ( { prompt, options } ) ,
760+ try : ( ) =>
761+ query ( {
762+ prompt,
763+ options :
764+ spawnClaudeCodeProcess === undefined
765+ ? options
766+ : { ...options , spawnClaudeCodeProcess } ,
767+ } ) ,
720768 catch : ( cause ) => queryRunnerError ( cause , "query" ) ,
721769 } ) . pipe (
722770 Effect . tap ( ( queryRuntime ) =>
0 commit comments