fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构

- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
  - buildCliLaunch(): 标准化启动规范(execArgv snapshot + bundled mode 检测)
  - spawnCli(): 统一 spawn(自动 windowsHide)
  - quoteCliLaunch(): tmux shell 引用
- 修复 --daemon-worker=kind 等号格式解析(cli.tsx)
- 修复 daemon/bg fast path 缺少 setShellIfWindows()(Windows git-bash 发现)
- 修复 checkPathExists 用 execSync('dir') 改为 existsSync(消除 cmd.exe 弹窗)
- 修复 CLAUDE_CODE_GIT_BASH_PATH env 传播给子进程
- 7 个 spawn 站点迁移到 CliLaunchSpec
- BgEngine 接口新增 supportsInteractiveInput capability
- daemon bg 无 -p 时 detached 引擎给出清晰错误提示
This commit is contained in:
unraid
2026-04-14 22:16:35 +08:00
parent 4c409df35d
commit c5f52cd668
11 changed files with 269 additions and 58 deletions

View File

@@ -12,6 +12,7 @@ import {
logEventAsync,
} from '../services/analytics/index.js'
import { isInBundledMode } from '../utils/bundledMode.js'
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
import { logForDebugging } from '../utils/debug.js'
import { rcLog } from './rcDebugLog.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
@@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
/**
* Returns the args that must precede CLI flags when spawning a child claude
* process. In compiled binaries, process.execPath is the claude binary itself
* and args go directly to it. In npm installs (node running cli.js),
* process.execPath is the node runtime — the child spawn must pass the script
* path as the first arg, otherwise node interprets --sdk-url as a node option
* and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
* process. Delegates to the centralized cliLaunch module which handles
* bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak
* quirk. See anthropics/claude-code#28334.
*/
function spawnScriptArgs(): string[] {
if (isInBundledMode() || !process.argv[1]) {
return []
}
return [process.argv[1]]
const bootstrap = [...getBootstrapArgs()]
const script = getScriptPath()
if (script) bootstrap.push(script)
return bootstrap
}
/** Attempt to spawn a session; returns error string if spawn throws. */

View File

@@ -279,6 +279,32 @@ export async function killHandler(target: string | undefined): Promise<void> {
export async function handleBgStart(args: string[]): Promise<void> {
const engine = await selectEngine()
// Strip --bg/--background from args (for backward-compat shortcut)
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
// Engines without interactive TTY input (e.g. detached) require -p/--print
// or piped input. Tmux provides a virtual terminal so it works without -p.
if (
!engine.supportsInteractiveInput &&
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
) {
console.error(
'Error: Background sessions with detached engine require -p/--print flag.\n' +
'The detached engine has no terminal for interactive input.\n\n' +
'Usage:\n' +
' claude daemon bg -p "your prompt here"\n' +
' echo "prompt" | claude daemon bg --pipe',
)
if (process.platform !== 'win32') {
console.error(
'\nAlternatively, install tmux for interactive background sessions:\n' +
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
)
}
process.exitCode = 1
return
}
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
const logPath = join(
getClaudeConfigHomeDir(),
@@ -287,9 +313,6 @@ export async function handleBgStart(args: string[]): Promise<void> {
`${sessionName}.log`,
)
// Strip --bg/--background from args (for backward-compat shortcut)
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
try {
const result = await engine.start({
sessionName,

View File

@@ -41,6 +41,8 @@ export interface BgStartResult {
export interface BgEngine {
readonly name: 'tmux' | 'detached'
/** Whether the engine provides a TTY for interactive REPL input. */
readonly supportsInteractiveInput: boolean
available(): Promise<boolean>
start(opts: BgStartOptions): Promise<BgStartResult>
attach(session: SessionEntry): Promise<void>

View File

@@ -1,6 +1,6 @@
import { spawn } from 'child_process'
import { openSync, closeSync, mkdirSync } from 'fs'
import { closeSync, mkdirSync, openSync } from 'fs'
import { dirname } from 'path'
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
import type {
BgEngine,
BgStartOptions,
@@ -11,6 +11,7 @@ import { tailLog } from '../tail.js'
export class DetachedEngine implements BgEngine {
readonly name = 'detached' as const
readonly supportsInteractiveInput = false
async available(): Promise<boolean> {
return true
@@ -20,17 +21,19 @@ export class DetachedEngine implements BgEngine {
mkdirSync(dirname(opts.logPath), { recursive: true })
const logFd = openSync(opts.logPath, 'a')
const entrypoint = process.argv[1]!
const child = spawn(process.execPath, [entrypoint, ...opts.args], {
detached: true,
stdio: ['ignore', logFd, logFd],
const launch = buildCliLaunch(opts.args, {
env: {
...opts.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
CLAUDE_CODE_SESSION_LOG: opts.logPath,
} as Record<string, string>,
} as NodeJS.ProcessEnv,
})
const child = spawnCli(launch, {
detached: true,
stdio: ['ignore', logFd, logFd],
cwd: opts.cwd,
})

View File

@@ -1,6 +1,6 @@
import { spawnSync } from 'child_process'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { quote } from '../../../utils/bash/shellQuote.js'
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
import type {
BgEngine,
BgStartOptions,
@@ -10,6 +10,7 @@ import type {
export class TmuxEngine implements BgEngine {
readonly name = 'tmux' as const
readonly supportsInteractiveInput = true
async available(): Promise<boolean> {
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
@@ -17,21 +18,22 @@ export class TmuxEngine implements BgEngine {
}
async start(opts: BgStartOptions): Promise<BgStartResult> {
const entrypoint = process.argv[1]!
const cmd = quote([process.execPath, entrypoint, ...opts.args])
const launch = buildCliLaunch(opts.args, {
env: {
...opts.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
CLAUDE_CODE_SESSION_LOG: opts.logPath,
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
} as NodeJS.ProcessEnv,
})
const tmuxEnv: Record<string, string | undefined> = {
...opts.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
CLAUDE_CODE_SESSION_LOG: opts.logPath,
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
}
const cmd = quoteCliLaunch(launch)
const result = spawnSync(
'tmux',
['new-session', '-d', '-s', opts.sessionName, cmd],
{ stdio: 'inherit', env: tmuxEnv },
{ stdio: 'inherit', env: launch.env },
)
if (result.status !== 0) {

View File

@@ -1,4 +1,3 @@
import { spawn } from 'child_process';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { resolve } from 'path';
@@ -8,6 +7,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { findGitRoot } from '../../utils/git.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
@@ -65,9 +65,9 @@ export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }:
const dir = defaultDir || resolve('.');
try {
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawn(process.execPath, execArgs, {
const child = spawnCli(launch, {
cwd: dir,
stdio: 'ignore',
detached: true,

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from 'child_process';
import { type ChildProcess } from 'child_process';
import { resolve } from 'path';
import * as React from 'react';
import { useEffect, useState } from 'react';
@@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { errorMessage } from '../../utils/errors.js';
@@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise<string | null> {
function startDaemon(): void {
const dir = resolve('.');
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawn(process.execPath, execArgs, {
const child = spawnCli(launch, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,

View File

@@ -1,5 +1,6 @@
import { spawn, type ChildProcess } from 'child_process'
import { type ChildProcess } from 'child_process'
import { resolve } from 'path'
import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js'
import { errorMessage } from '../utils/errors.js'
import {
writeDaemonState,
@@ -335,17 +336,11 @@ function spawnWorker(
CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
}
// Build the worker command: reuse the same entrypoint with --daemon-worker flag
const execArgs = [
...process.execArgv,
process.argv[1]!,
`--daemon-worker=${worker.kind}`,
]
console.log(`[daemon] spawning worker '${worker.kind}'`)
const child = spawn(process.execPath, execArgs, {
env,
const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env })
const child = spawnCli(launch, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
})

View File

@@ -121,9 +121,10 @@ async function main(): Promise<void> {
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
// workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') {
if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) {
const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1];
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
await runDaemonWorker(args[1]);
await runDaemonWorker(kind);
return;
}
@@ -184,6 +185,8 @@ async function main(): Promise<void> {
profileCheckpoint('cli_daemon_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
setShellIfWindows();
const { initSinks } = await import('../utils/sinks.js');
initSinks();
const { daemonMain } = await import('../daemon/main.js');
@@ -196,6 +199,8 @@ async function main(): Promise<void> {
profileCheckpoint('cli_daemon_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
setShellIfWindows();
const bg = await import('../cli/bg.js');
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'));
return;
@@ -211,6 +216,8 @@ async function main(): Promise<void> {
profileCheckpoint('cli_daemon_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { setShellIfWindows } = await import('../utils/windowsPaths.js');
setShellIfWindows();
const { initSinks } = await import('../utils/sinks.js');
initSinks();
const { daemonMain } = await import('../daemon/main.js');

180
src/utils/cliLaunch.ts Normal file
View File

@@ -0,0 +1,180 @@
import { type ChildProcess, spawn, type SpawnOptions } from 'child_process'
import { isInBundledMode } from './bundledMode.js'
import { quote } from './bash/shellQuote.js'
/**
* CliLaunchSpec — normalized descriptor for spawning a child CLI process.
*
* Every site that re-execs the CLI (daemon workers, bg sessions, bridge
* sessions, assistant/RCS daemon launchers) should use this instead of
* manually assembling `[...process.execArgv, process.argv[1]!, ...]`.
*
* Centralizing the bootstrap contract prevents the class of bugs where
* individual spawn sites forget execArgv, windowsHide, or env propagation.
*/
export interface CliLaunchSpec {
/** Runtime binary path (e.g. bun, node). */
execPath: string
/** Full argument list including bootstrap args and CLI args. */
args: string[]
/** Environment for the child process. */
env: NodeJS.ProcessEnv
/** Whether to hide the console window on Windows. */
windowsHide: boolean
}
// ---------------------------------------------------------------------------
// Frozen bootstrap snapshot — computed once at module load time.
//
// Bun quirk (https://github.com/oven-sh/bun/issues/11673): in single-file
// executables, app arguments from process.argv can leak into process.execArgv.
// We snapshot and filter once, so every child gets a clean, stable set of
// runtime flags regardless of when buildCliLaunch is called.
// ---------------------------------------------------------------------------
/**
* Filter out leaked application arguments from process.execArgv.
* Only keep known runtime flags: -d (defines), --feature, --inspect variants.
*/
function sanitizeExecArgv(raw: readonly string[]): string[] {
const result: string[] = []
for (let i = 0; i < raw.length; i++) {
const arg = raw[i]!
// Bun define flags: -d KEY:VALUE or -dKEY:VALUE
if (arg === '-d' || arg.startsWith('-d ') || arg.startsWith('-d\t')) {
result.push(arg)
if (arg === '-d' && i + 1 < raw.length) {
result.push(raw[++i]!)
}
continue
}
if (arg.startsWith('-d') && arg.includes(':')) {
result.push(arg)
continue
}
// Bun feature flags: --feature NAME
if (arg === '--feature') {
result.push(arg)
if (i + 1 < raw.length) {
result.push(raw[++i]!)
}
continue
}
// Node/Bun inspect flags
if (/^--inspect(-brk)?(=|$)/.test(arg)) {
result.push(arg)
continue
}
// Keep other known runtime flags (e.g. --conditions, --experimental-*)
if (arg.startsWith('--') && !arg.includes('=') && i + 1 < raw.length) {
// Unknown two-part flag — skip conservatively in bundled mode only
if (isInBundledMode()) continue
result.push(arg)
result.push(raw[++i]!)
continue
}
if (arg.startsWith('-') && !isInBundledMode()) {
result.push(arg)
}
}
return result
}
const BOOTSTRAP_ARGS: readonly string[] = Object.freeze(
sanitizeExecArgv(process.execArgv),
)
const SCRIPT_PATH: string | undefined = process.argv[1]
const EXEC_PATH: string = process.execPath
const IS_WINDOWS = process.platform === 'win32'
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build a normalized launch spec for spawning a child CLI process.
*
* @param cliArgs Arguments to pass to the CLI entrypoint (e.g. ['daemon', 'start'])
* @param opts.env Override environment (defaults to process.env)
*/
export function buildCliLaunch(
cliArgs: string[],
opts?: { env?: NodeJS.ProcessEnv },
): CliLaunchSpec {
const baseEnv = opts?.env ?? process.env
// In bundled mode the execPath IS the CLI binary — no script path needed.
// In script mode (dev / npm) we need the script path between runtime flags
// and CLI args so the runtime knows which file to execute.
const args: string[] =
isInBundledMode() || !SCRIPT_PATH
? [...BOOTSTRAP_ARGS, ...cliArgs]
: [...BOOTSTRAP_ARGS, SCRIPT_PATH, ...cliArgs]
// Ensure Windows children can discover git-bash without shelling out
const env: NodeJS.ProcessEnv = { ...baseEnv }
if (IS_WINDOWS) {
if (
process.env.CLAUDE_CODE_GIT_BASH_PATH &&
!env.CLAUDE_CODE_GIT_BASH_PATH
) {
env.CLAUDE_CODE_GIT_BASH_PATH = process.env.CLAUDE_CODE_GIT_BASH_PATH
}
if (process.env.SHELL && !env.SHELL) {
env.SHELL = process.env.SHELL
}
}
return {
execPath: EXEC_PATH,
args,
env,
windowsHide: IS_WINDOWS,
}
}
/**
* Spawn a child CLI process from a launch spec.
*
* Callers provide transport-level options (stdio, detached, cwd) while the
* spec handles bootstrap concerns (execPath, args, env, windowsHide).
*
* Windows note: `detached: true` on Windows creates a new console window
* (unlike Unix where it only creates a new process group). Node.js uses
* `windowsHide` to pass CREATE_NO_WINDOW, but Bun may not implement it.
* As a fallback, we always set both `windowsHide: true` and keep
* `detached` as-is — the child needs `detached` to outlive the parent.
*/
export function spawnCli(
spec: CliLaunchSpec,
spawnOpts: Omit<SpawnOptions, 'windowsHide'>,
): ChildProcess {
return spawn(spec.execPath, spec.args, {
...spawnOpts,
env: { ...spec.env, ...(spawnOpts.env as NodeJS.ProcessEnv) },
windowsHide: spec.windowsHide,
})
}
/**
* Quote a launch spec into a single shell command string (for tmux).
*/
export function quoteCliLaunch(spec: CliLaunchSpec): string {
return quote([spec.execPath, ...spec.args])
}
/**
* Get the frozen bootstrap args snapshot.
* Useful for call sites that need the raw args (e.g. bridgeMain deps).
*/
export function getBootstrapArgs(): readonly string[] {
return BOOTSTRAP_ARGS
}
/**
* Get the script path (process.argv[1] at startup).
* Returns undefined in bundled mode.
*/
export function getScriptPath(): string | undefined {
return SCRIPT_PATH
}

View File

@@ -1,3 +1,4 @@
import { existsSync } from 'fs'
import memoize from 'lodash-es/memoize.js'
import * as path from 'path'
import * as pathWin32 from 'path/win32'
@@ -8,17 +9,13 @@ import { memoizeWithLRU } from './memoize.js'
import { getPlatform } from './platform.js'
/**
* Check if a file or directory exists on Windows using the dir command
* @param path - The path to check
* @returns true if the path exists, false otherwise
* Check if a file or directory exists on Windows.
* Uses fs.existsSync instead of `dir` shell command to avoid spawning
* cmd.exe — which can cause brief console window flashes in detached
* or windowsHide child processes.
*/
function checkPathExists(path: string): boolean {
try {
execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
return true
} catch {
return false
}
function checkPathExists(filePath: string): boolean {
return existsSync(filePath)
}
/**
@@ -88,6 +85,8 @@ export function setShellIfWindows(): void {
if (getPlatform() === 'windows') {
const gitBashPath = findGitBashPath()
process.env.SHELL = gitBashPath
// Propagate to child processes so they skip filesystem probing
process.env.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath
logForDebugging(`Using bash path: "${gitBashPath}"`)
}
}