diff --git a/DEV-LOG.md b/DEV-LOG.md index 2009971f5..bfa77f2d6 100644 --- a/DEV-LOG.md +++ b/DEV-LOG.md @@ -1,5 +1,106 @@ # DEV-LOG +## Daemon + Remote Control Server 还原 (2026-04-07) + +**分支**: `feat/daemon-remote-control-server` + +### 背景 + +`src/commands.ts` 注册了 `remoteControlServer` 命令(双重门控 `feature('DAEMON') && feature('BRIDGE_MODE')`),但 `src/commands/remoteControlServer/` 目录缺失,`src/daemon/main.ts` 和 `src/daemon/workerRegistry.ts` 均为 stub。官方 CLI 2.1.92 中情况一致——Anthropic 已预留注册点和底层 `runBridgeHeadless()` 实现,但中间层(daemon supervisor + command 入口)未发布。 + +通过逐级反向追踪调用链还原完整实现: +``` +/remote-control-server (slash command) + → spawn: claude daemon start + → daemonMain() (supervisor,管理 worker 生命周期) + → spawn: claude --daemon-worker=remoteControl + → runDaemonWorker('remoteControl') + → runBridgeHeadless(opts, signal) ← 已有完整实现 + → runBridgeLoop() → 接受远程会话 +``` + +### 实现 + +#### 1. Worker Registry(`src/daemon/workerRegistry.ts`) + +从 stub 还原为 worker 分发器: +- `runDaemonWorker(kind)` 按 `kind` 分发到不同 worker 实现 +- `runRemoteControlWorker()` 从环境变量(`DAEMON_WORKER_*`)读取配置,构造 `HeadlessBridgeOpts`,调用 `runBridgeHeadless()` +- 区分 permanent(`EXIT_CODE_PERMANENT = 78`)和 transient 错误,supervisor 据此决定重试或 park +- SIGTERM/SIGINT 信号处理,通过 `AbortController` 传递给 bridge loop + +#### 2. Daemon Supervisor(`src/daemon/main.ts`) + +从 stub 还原为完整 supervisor 进程: +- `daemonMain(args)` 支持子命令:`start`(启动)、`status`、`stop`、`--help` +- `runSupervisor()` spawn `remoteControl` worker 子进程,通过环境变量传递配置 +- 指数退避重启(2s → 120s),10s 内连续崩溃 5 次则 park worker +- permanent exit code(78)直接 park,不重试 +- graceful shutdown:SIGTERM → 转发给 worker → 30s grace → SIGKILL +- CLI 参数支持:`--dir`、`--spawn-mode`、`--capacity`、`--permission-mode`、`--sandbox`、`--name` + +#### 3. Remote Control Server 命令(`src/commands/remoteControlServer/`) + +**`index.ts`** — Command 注册: +- 类型 `local-jsx`,名称 `/remote-control-server`,别名 `/rcs` +- 双 feature 门控:`feature('DAEMON') && feature('BRIDGE_MODE')` + `isBridgeEnabled()` +- lazy load `remoteControlServer.tsx` + +**`remoteControlServer.tsx`** — REPL 内 UI: +- 首次调用:前置检查(bridge 可用性 + OAuth token)→ spawn daemon 子进程 +- 再次调用:弹出管理对话框(停止/重启/继续),显示 PID 和最近 5 行日志 +- 模块级 state 跨调用保持 daemon 进程引用 +- graceful stop:SIGTERM → 10s grace → SIGKILL + +#### 4. Feature Flag 启用 + +`build.ts` / `scripts/dev.ts`:`DEFAULT_BUILD_FEATURES` / `DEFAULT_FEATURES` 新增 `DAEMON` + +DAEMON 仅有编译时 feature flag 门控,无 GrowthBook gate。 + +### 与 `/remote-control` 的区别 + +| | `/remote-control` | `/remote-control-server` (daemon) | +|---|---|---| +| 模式 | 单会话,REPL 内交互式 bridge | 多会话,daemon 持久化服务器 | +| 生命周期 | 跟 REPL 会话绑定 | 独立后台进程,崩溃自动重启 | +| 并发 | 1 个远程连接 | 默认 4 个,可配置 `--capacity` | +| 隔离 | 共享当前目录 | 支持 `worktree` 模式隔离 | +| 底层 | `initReplBridge()` | `runBridgeHeadless()` → `runBridgeLoop()` | + +### 修改文件 + +| 文件 | 变更 | +|------|------| +| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 `DAEMON` | +| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 `DAEMON` | +| `src/daemon/main.ts` | 从 stub 还原为 supervisor 实现 | +| `src/daemon/workerRegistry.ts` | 从 stub 还原为 worker 分发器 | +| `src/commands/remoteControlServer/index.ts` | **新增** command 注册 | +| `src/commands/remoteControlServer/remoteControlServer.tsx` | **新增** REPL UI | + +### 验证 + +| 项目 | 结果 | +|------|------| +| `bun run build` | ✅ 成功 (490 files) | +| tsc 新文件检查 | ✅ 无新增类型错误 | + +### 使用方式 + +```bash +# CLI 直接启动 daemon +bun run dev daemon start +bun run dev daemon start --spawn-mode=worktree --capacity=8 + +# REPL 内 +/remote-control-server # 或 /rcs +``` + +前提:需要 Anthropic OAuth 登录(`claude login`)。 + +--- + ## /ultraplan 启用 + GrowthBook Fallback 加固 + Away Summary 改进 (2026-04-06) **分支**: `feat/ultraplan-enablement` diff --git a/build.ts b/build.ts index 861d34f14..4e8e0d260 100644 --- a/build.ts +++ b/build.ts @@ -28,6 +28,8 @@ const DEFAULT_BUILD_FEATURES = [ 'KAIROS_BRIEF', 'AWAY_SUMMARY', 'ULTRAPLAN', + // P2: daemon + remote control server + 'DAEMON', ] // Collect FEATURE_* env vars → Bun.build features diff --git a/scripts/dev.ts b/scripts/dev.ts index 5007bb184..0b35f4bc8 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -35,6 +35,8 @@ const DEFAULT_FEATURES = [ // P1: API-dependent features "EXTRACT_MEMORIES", "VERIFICATION_AGENT", "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", + // P2: daemon + remote control server + "DAEMON", ]; // Any env var matching FEATURE_=1 will also enable that feature. diff --git a/src/commands/remoteControlServer/index.ts b/src/commands/remoteControlServer/index.ts new file mode 100644 index 000000000..6c78d7ef3 --- /dev/null +++ b/src/commands/remoteControlServer/index.ts @@ -0,0 +1,26 @@ +import { feature } from 'bun:bundle' +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' +import type { Command } from '../../commands.js' + +function isEnabled(): boolean { + if (!feature('DAEMON') || !feature('BRIDGE_MODE')) { + return false + } + return isBridgeEnabled() +} + +const remoteControlServer = { + type: 'local-jsx', + name: 'remote-control-server', + aliases: ['rcs'], + description: + 'Start a persistent Remote Control server (daemon) that accepts multiple sessions', + isEnabled, + get isHidden() { + return !isEnabled() + }, + immediate: true, + load: () => import('./remoteControlServer.js'), +} satisfies Command + +export default remoteControlServer diff --git a/src/commands/remoteControlServer/remoteControlServer.tsx b/src/commands/remoteControlServer/remoteControlServer.tsx new file mode 100644 index 000000000..cd562c3e5 --- /dev/null +++ b/src/commands/remoteControlServer/remoteControlServer.tsx @@ -0,0 +1,278 @@ +import { spawn, 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 '../../ink.js'; +import { useKeybindings } from '../../keybindings/useKeybinding.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(daemonStatus); + const [error, setError] = useState(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 ; + } + + 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 ( + + + + Remote Control Server is{' '} + + running + + {daemonProcess ? ` (PID: ${daemonProcess.pid})` : ''} + + {logPreview.length > 0 && ( + + Recent logs: + {logPreview.map((line, i) => ( + + {line} + + ))} + + )} + + + Stop server + + + Restart server + + + Continue + + + Enter to select · Esc to continue + + + ); +} + +/** + * Check prerequisites for starting the Remote Control Server. + */ +async function checkPrerequisites(): Promise { + 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 execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`]; + + const child = spawn(process.execPath, execArgs, { + 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, signal) => { + daemonProcess = null; + daemonStatus = 'stopped'; + daemonLogs.push(`[daemon] exited (code=${code}, 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 { + return ; +} diff --git a/src/daemon/main.ts b/src/daemon/main.ts index ce3a9fb51..d7697a281 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -1,3 +1,305 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const daemonMain: (args: string[]) => Promise = () => Promise.resolve(); +import { spawn, type ChildProcess } from 'child_process' +import { resolve } from 'path' +import { errorMessage } from '../utils/errors.js' + +/** + * Exit code used by workers for permanent (non-retryable) failures. + * @see workerRegistry.ts EXIT_CODE_PERMANENT + */ +const EXIT_CODE_PERMANENT = 78 + +/** + * Backoff config for restarting crashed workers. + */ +const BACKOFF_INITIAL_MS = 2_000 +const BACKOFF_CAP_MS = 120_000 +const BACKOFF_MULTIPLIER = 2 +const MAX_RAPID_FAILURES = 5 // Park worker after this many fast crashes + +interface WorkerState { + kind: string + process: ChildProcess | null + backoffMs: number + failureCount: number + parked: boolean + lastStartTime: number +} + +/** + * Daemon supervisor entry point. Called from `cli.tsx` via: + * `claude daemon [subcommand]` + * + * Starts and supervises long-running workers. Currently spawns one + * `remoteControl` worker that runs the headless bridge server. + * + * Subcommands: + * (none) — start the supervisor with default workers + * start — same as no subcommand + * status — print worker status (TODO: IPC) + * stop — send SIGTERM to supervisor (TODO: PID file) + */ +export async function daemonMain(args: string[]): Promise { + const subcommand = args[0] || 'start' + + switch (subcommand) { + case 'start': + await runSupervisor(args.slice(1)) + break + case 'status': + console.log('daemon status: not yet implemented (requires IPC)') + break + case 'stop': + console.log('daemon stop: not yet implemented (requires PID file)') + break + case '--help': + case '-h': + printHelp() + break + default: + console.error(`Unknown daemon subcommand: ${subcommand}`) + printHelp() + process.exitCode = 1 + } +} + +function printHelp(): void { + console.log(` +Claude Code Daemon — persistent background supervisor + +USAGE + claude daemon [subcommand] [options] + +SUBCOMMANDS + start Start the daemon supervisor (default) + status Show worker status + stop Stop the daemon + +OPTIONS + --dir Working directory (default: current) + --spawn-mode Worker spawn mode: same-dir | worktree (default: same-dir) + --capacity Max concurrent sessions per worker (default: 4) + --permission-mode Permission mode for spawned sessions + --sandbox Enable sandbox mode + --name Session name + -h, --help Show this help +`) +} + +/** + * Parse supervisor arguments from CLI. + */ +function parseSupervisorArgs(args: string[]): Record { + const result: Record = {} + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--dir' && i + 1 < args.length) { + result.dir = resolve(args[++i]!) + } else if (arg.startsWith('--dir=')) { + result.dir = resolve(arg.slice('--dir='.length)) + } else if (arg === '--spawn-mode' && i + 1 < args.length) { + result.spawnMode = args[++i]! + } else if (arg.startsWith('--spawn-mode=')) { + result.spawnMode = arg.slice('--spawn-mode='.length) + } else if (arg === '--capacity' && i + 1 < args.length) { + result.capacity = args[++i]! + } else if (arg.startsWith('--capacity=')) { + result.capacity = arg.slice('--capacity='.length) + } else if (arg === '--permission-mode' && i + 1 < args.length) { + result.permissionMode = args[++i]! + } else if (arg.startsWith('--permission-mode=')) { + result.permissionMode = arg.slice('--permission-mode='.length) + } else if (arg === '--sandbox') { + result.sandbox = '1' + } else if (arg === '--name' && i + 1 < args.length) { + result.name = args[++i]! + } else if (arg.startsWith('--name=')) { + result.name = arg.slice('--name='.length) + } + } + return result +} + +/** + * Run the daemon supervisor loop. Spawns workers and restarts them + * on crash with exponential backoff. + */ +async function runSupervisor(args: string[]): Promise { + const config = parseSupervisorArgs(args) + const dir = config.dir || resolve('.') + + console.log(`[daemon] supervisor starting in ${dir}`) + + const workers: WorkerState[] = [ + { + kind: 'remoteControl', + process: null, + backoffMs: BACKOFF_INITIAL_MS, + failureCount: 0, + parked: false, + lastStartTime: 0, + }, + ] + + const controller = new AbortController() + + // Graceful shutdown + const shutdown = () => { + console.log('[daemon] supervisor shutting down...') + controller.abort() + for (const w of workers) { + if (w.process && !w.process.killed) { + w.process.kill('SIGTERM') + } + } + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + // Spawn and supervise workers + for (const worker of workers) { + if (!controller.signal.aborted) { + spawnWorker(worker, dir, config, controller.signal) + } + } + + // Wait for abort signal + await new Promise(resolve => { + if (controller.signal.aborted) { + resolve() + return + } + controller.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + + // Wait for all workers to exit + await Promise.all( + workers + .filter(w => w.process && !w.process.killed) + .map( + w => + new Promise(resolve => { + if (!w.process) { + resolve() + return + } + w.process.on('exit', () => resolve()) + // Force kill after grace period + setTimeout(() => { + if (w.process && !w.process.killed) { + w.process.kill('SIGKILL') + } + resolve() + }, 30_000) + }), + ), + ) + + console.log('[daemon] supervisor stopped') +} + +/** + * Spawn a worker child process with the appropriate env vars. + */ +function spawnWorker( + worker: WorkerState, + dir: string, + config: Record, + signal: AbortSignal, +): void { + if (signal.aborted || worker.parked) return + + worker.lastStartTime = Date.now() + + const env: Record = { + ...process.env, + DAEMON_WORKER_DIR: dir, + DAEMON_WORKER_NAME: config.name, + DAEMON_WORKER_SPAWN_MODE: config.spawnMode || 'same-dir', + DAEMON_WORKER_CAPACITY: config.capacity || '4', + DAEMON_WORKER_PERMISSION: config.permissionMode, + DAEMON_WORKER_SANDBOX: config.sandbox || '0', + DAEMON_WORKER_CREATE_SESSION: '1', + 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, + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + worker.process = child + + // Pipe worker stdout/stderr to supervisor with prefix + child.stdout?.on('data', (data: Buffer) => { + const lines = data.toString().trimEnd().split('\n') + for (const line of lines) { + console.log(` ${line}`) + } + }) + child.stderr?.on('data', (data: Buffer) => { + const lines = data.toString().trimEnd().split('\n') + for (const line of lines) { + console.error(` ${line}`) + } + }) + + child.on('exit', (code, sig) => { + worker.process = null + + if (signal.aborted) { + // Supervisor is shutting down, don't restart + return + } + + if (code === EXIT_CODE_PERMANENT) { + console.error( + `[daemon] worker '${worker.kind}' exited with permanent error — parking`, + ) + worker.parked = true + return + } + + // Check for rapid failure (crashed within 10s of starting) + const runDuration = Date.now() - worker.lastStartTime + if (runDuration < 10_000) { + worker.failureCount++ + if (worker.failureCount >= MAX_RAPID_FAILURES) { + console.error( + `[daemon] worker '${worker.kind}' failed ${worker.failureCount} times rapidly — parking`, + ) + worker.parked = true + return + } + } else { + // Ran for a reasonable time, reset failure count + worker.failureCount = 0 + worker.backoffMs = BACKOFF_INITIAL_MS + } + + console.log( + `[daemon] worker '${worker.kind}' exited (code=${code}, signal=${sig}), restarting in ${worker.backoffMs}ms`, + ) + + setTimeout(() => { + if (!signal.aborted && !worker.parked) { + spawnWorker(worker, dir, config, signal) + } + }, worker.backoffMs) + + // Exponential backoff + worker.backoffMs = Math.min( + worker.backoffMs * BACKOFF_MULTIPLIER, + BACKOFF_CAP_MS, + ) + }) +} diff --git a/src/daemon/workerRegistry.ts b/src/daemon/workerRegistry.ts index 46659908a..19072180e 100644 --- a/src/daemon/workerRegistry.ts +++ b/src/daemon/workerRegistry.ts @@ -1,3 +1,112 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const runDaemonWorker: (workerId: string) => Promise = () => Promise.resolve(); +import { resolve } from 'path' +import { + type HeadlessBridgeOpts, + BridgeHeadlessPermanentError, + runBridgeHeadless, +} from '../bridge/bridgeMain.js' +import { getClaudeAIOAuthTokens } from '../utils/auth.js' +import { errorMessage } from '../utils/errors.js' + +/** + * Exit codes the supervisor uses to decide retry vs park. + * Permanent errors (trust not accepted, no git repo for worktree) use + * EXIT_CODE_PERMANENT so the supervisor doesn't waste cycles retrying. + */ +const EXIT_CODE_PERMANENT = 78 // EX_CONFIG from sysexits.h +const EXIT_CODE_TRANSIENT = 1 + +/** + * Daemon worker entry point. Called from `cli.tsx` via: + * `claude --daemon-worker=` + * + * The supervisor spawns this as a child process. Each `kind` maps to a + * different long-running task. Currently only `remoteControl` is implemented + * — it runs the headless bridge loop that accepts remote sessions. + */ +export async function runDaemonWorker(kind?: string): Promise { + if (!kind) { + console.error('Error: --daemon-worker requires a worker kind') + process.exitCode = EXIT_CODE_PERMANENT + return + } + + switch (kind) { + case 'remoteControl': + await runRemoteControlWorker() + break + default: + console.error(`Error: unknown daemon worker kind '${kind}'`) + process.exitCode = EXIT_CODE_PERMANENT + } +} + +/** + * Remote Control worker — runs `runBridgeHeadless()` with config from + * environment variables set by the daemon supervisor. + * + * Environment variables (set by daemonMain): + * DAEMON_WORKER_DIR — working directory + * DAEMON_WORKER_NAME — optional session name + * DAEMON_WORKER_SPAWN_MODE — 'same-dir' | 'worktree' + * DAEMON_WORKER_CAPACITY — max concurrent sessions + * DAEMON_WORKER_PERMISSION — permission mode + * DAEMON_WORKER_SANDBOX — '1' for sandbox mode + * DAEMON_WORKER_TIMEOUT_MS — session timeout in ms + * DAEMON_WORKER_CREATE_SESSION — '1' to pre-create session on start + */ +async function runRemoteControlWorker(): Promise { + const dir = process.env.DAEMON_WORKER_DIR || resolve('.') + const name = process.env.DAEMON_WORKER_NAME || undefined + const spawnMode = + (process.env.DAEMON_WORKER_SPAWN_MODE as 'same-dir' | 'worktree') || + 'same-dir' + const capacity = parseInt(process.env.DAEMON_WORKER_CAPACITY || '4', 10) + const permissionMode = process.env.DAEMON_WORKER_PERMISSION || undefined + const sandbox = process.env.DAEMON_WORKER_SANDBOX === '1' + const sessionTimeoutMs = process.env.DAEMON_WORKER_TIMEOUT_MS + ? parseInt(process.env.DAEMON_WORKER_TIMEOUT_MS, 10) + : undefined + const createSessionOnStart = process.env.DAEMON_WORKER_CREATE_SESSION !== '0' + + const controller = new AbortController() + + // Graceful shutdown on SIGTERM/SIGINT from supervisor + const onSignal = () => controller.abort() + process.on('SIGTERM', onSignal) + process.on('SIGINT', onSignal) + + const opts: HeadlessBridgeOpts = { + dir, + name, + spawnMode, + capacity, + permissionMode, + sandbox, + sessionTimeoutMs, + createSessionOnStart, + getAccessToken: () => getClaudeAIOAuthTokens()?.accessToken, + onAuth401: async (_failedToken: string) => { + // In daemon context, re-check auth — supervisor may have refreshed token. + const tokens = getClaudeAIOAuthTokens() + return !!tokens?.accessToken + }, + log: (s: string) => { + console.log(`[remoteControl] ${s}`) + }, + } + + try { + await runBridgeHeadless(opts, controller.signal) + } catch (err) { + if (err instanceof BridgeHeadlessPermanentError) { + console.error(`[remoteControl] permanent error: ${err.message}`) + process.exitCode = EXIT_CODE_PERMANENT + } else { + console.error(`[remoteControl] transient error: ${errorMessage(err)}`) + process.exitCode = EXIT_CODE_TRANSIENT + } + } finally { + process.off('SIGTERM', onSignal) + process.off('SIGINT', onSignal) + } +}