Files
claude-code/src/commands/remoteControlServer/remoteControlServer.tsx
unraid c5f52cd668 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 引擎给出清晰错误提示
2026-04-14 22:16:35 +08:00

280 lines
7.9 KiB
TypeScript

import { type ChildProcess } from 'child_process';
import { resolve } from 'path';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { getBridgeDisabledReason, isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
import { BRIDGE_LOGIN_INSTRUCTION } from '../../bridge/types.js';
import { Dialog } from '../../components/design-system/Dialog.js';
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';
type ServerStatus = 'stopped' | 'starting' | 'running' | 'error';
type Props = {
onDone: LocalJSXCommandOnDone;
};
/**
* /remote-control-server command — manages the daemon-backed persistent bridge server.
*
* When invoked, it starts the daemon supervisor as a child process, which in
* turn spawns remoteControl workers that run headless bridge loops. The server
* accepts multiple concurrent remote sessions.
*
* If the server is already running, shows a management dialog with status
* and options to stop or continue.
*/
// Module-level state to track the daemon process across invocations
let daemonProcess: ChildProcess | null = null;
let daemonStatus: ServerStatus = 'stopped';
let daemonLogs: string[] = [];
const MAX_LOG_LINES = 50;
function RemoteControlServer({ onDone }: Props): React.ReactNode {
const [status, setStatus] = useState<ServerStatus>(daemonStatus);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// If already running, show management dialog
if (daemonProcess && !daemonProcess.killed) {
setStatus('running');
return;
}
let cancelled = false;
void (async () => {
// Pre-flight checks
const checkError = await checkPrerequisites();
if (cancelled) return;
if (checkError) {
onDone(checkError, { display: 'system' });
return;
}
// Start the daemon
setStatus('starting');
try {
startDaemon();
if (!cancelled) {
setStatus('running');
daemonStatus = 'running';
onDone('Remote Control Server started. Use /remote-control-server to manage.', { display: 'system' });
}
} catch (err) {
if (!cancelled) {
const msg = errorMessage(err);
setStatus('error');
setError(msg);
daemonStatus = 'error';
onDone(`Remote Control Server failed to start: ${msg}`, {
display: 'system',
});
}
}
})();
return () => {
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (status === 'running' && daemonProcess && !daemonProcess.killed) {
return <ServerManagementDialog onDone={onDone} />;
}
if (status === 'error' && error) {
return null;
}
return null;
}
/**
* Dialog shown when /remote-control-server is used while the daemon is running.
*/
function ServerManagementDialog({ onDone }: Props): React.ReactNode {
useRegisterOverlay('remote-control-server-dialog');
const [focusIndex, setFocusIndex] = useState(2);
const logPreview = daemonLogs.slice(-5);
function handleStop(): void {
stopDaemon();
onDone('Remote Control Server stopped.', { display: 'system' });
}
function handleRestart(): void {
stopDaemon();
try {
startDaemon();
onDone('Remote Control Server restarted.', { display: 'system' });
} catch (err) {
onDone(`Failed to restart: ${errorMessage(err)}`, { display: 'system' });
}
}
function handleContinue(): void {
onDone(undefined, { display: 'skip' });
}
const ITEM_COUNT = 3;
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT),
'select:previous': () => setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
'select:accept': () => {
if (focusIndex === 0) {
handleStop();
} else if (focusIndex === 1) {
handleRestart();
} else {
handleContinue();
}
},
},
{ context: 'Select' },
);
return (
<Dialog title="Remote Control Server" onCancel={handleContinue} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>
Remote Control Server is{' '}
<Text bold color="success">
running
</Text>
{daemonProcess ? ` (PID: ${daemonProcess.pid})` : ''}
</Text>
{logPreview.length > 0 && (
<Box flexDirection="column">
<Text dimColor>Recent logs:</Text>
{logPreview.map((line, i) => (
<Text key={i} dimColor>
{line}
</Text>
))}
</Box>
)}
<Box flexDirection="column">
<ListItem isFocused={focusIndex === 0}>
<Text>Stop server</Text>
</ListItem>
<ListItem isFocused={focusIndex === 1}>
<Text>Restart server</Text>
</ListItem>
<ListItem isFocused={focusIndex === 2}>
<Text>Continue</Text>
</ListItem>
</Box>
<Text dimColor>Enter to select · Esc to continue</Text>
</Box>
</Dialog>
);
}
/**
* Check prerequisites for starting the Remote Control Server.
*/
async function checkPrerequisites(): Promise<string | null> {
const disabledReason = await getBridgeDisabledReason();
if (disabledReason) {
return disabledReason;
}
if (!getBridgeAccessToken()) {
return BRIDGE_LOGIN_INSTRUCTION;
}
return null;
}
/**
* Start the daemon supervisor as a child process.
*/
function startDaemon(): void {
const dir = resolve('.');
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawnCli(launch, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
daemonProcess = child;
daemonLogs = [];
child.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().trimEnd().split('\n');
for (const line of lines) {
daemonLogs.push(line);
if (daemonLogs.length > MAX_LOG_LINES) {
daemonLogs.shift();
}
}
});
child.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().trimEnd().split('\n');
for (const line of lines) {
daemonLogs.push(`[err] ${line}`);
if (daemonLogs.length > MAX_LOG_LINES) {
daemonLogs.shift();
}
}
});
child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
daemonProcess = null;
daemonStatus = 'stopped';
daemonLogs.push(`[daemon] exited (code=${code ?? 'unknown'}, signal=${signal})`);
});
child.on('error', (err: Error) => {
daemonProcess = null;
daemonStatus = 'error';
daemonLogs.push(`[daemon] error: ${err.message}`);
});
}
/**
* Stop the daemon supervisor.
*/
function stopDaemon(): void {
if (daemonProcess && !daemonProcess.killed) {
daemonProcess.kill('SIGTERM');
// Force kill after 10s grace
const pid = daemonProcess.pid;
setTimeout(() => {
try {
if (pid) process.kill(pid, 0); // Check if still alive
if (daemonProcess && !daemonProcess.killed) {
daemonProcess.kill('SIGKILL');
}
} catch {
// Process already gone
}
}, 10_000);
}
daemonProcess = null;
daemonStatus = 'stopped';
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: ToolUseContext & LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
return <RemoteControlServer onDone={onDone} />;
}