* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
3.6 KiB
TypeScript
158 lines
3.6 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
|
|
import { join, dirname } from 'path'
|
|
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|
|
|
/**
|
|
* Daemon state persisted to disk so that `status` / `stop` can work
|
|
* from a different CLI process than the one that started the daemon.
|
|
*/
|
|
export interface DaemonStateData {
|
|
pid: number
|
|
cwd: string
|
|
startedAt: string
|
|
workerKinds: string[]
|
|
lastStatus: 'running' | 'stopped' | 'error'
|
|
}
|
|
|
|
export type DaemonStatus = 'running' | 'stopped' | 'stale'
|
|
|
|
/**
|
|
* Returns the path to the daemon state file for a given daemon name.
|
|
*/
|
|
export function getDaemonStateFilePath(name = 'remote-control'): string {
|
|
return join(getClaudeConfigHomeDir(), 'daemon', `${name}.json`)
|
|
}
|
|
|
|
/**
|
|
* Write daemon state to disk. Called by the supervisor on startup.
|
|
*/
|
|
export function writeDaemonState(
|
|
state: DaemonStateData,
|
|
name = 'remote-control',
|
|
): void {
|
|
const filePath = getDaemonStateFilePath(name)
|
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8')
|
|
}
|
|
|
|
/**
|
|
* Read daemon state from disk. Returns null if no state file exists.
|
|
*/
|
|
export function readDaemonState(
|
|
name = 'remote-control',
|
|
): DaemonStateData | null {
|
|
const filePath = getDaemonStateFilePath(name)
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf-8')
|
|
return JSON.parse(raw) as DaemonStateData
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the daemon state file.
|
|
*/
|
|
export function removeDaemonState(name = 'remote-control'): void {
|
|
const filePath = getDaemonStateFilePath(name)
|
|
try {
|
|
unlinkSync(filePath)
|
|
} catch {
|
|
// File may not exist — that's fine
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a process with the given PID is alive.
|
|
*/
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query the daemon status by reading the state file and probing the PID.
|
|
*
|
|
* Returns:
|
|
* - { status: 'running', state } — PID is alive
|
|
* - { status: 'stopped' } — no state file
|
|
* - { status: 'stale' } — state file exists but PID is dead (auto-cleaned)
|
|
*/
|
|
export function queryDaemonStatus(name = 'remote-control'): {
|
|
status: DaemonStatus
|
|
state?: DaemonStateData
|
|
} {
|
|
const state = readDaemonState(name)
|
|
if (!state) {
|
|
return { status: 'stopped' }
|
|
}
|
|
|
|
if (isProcessAlive(state.pid)) {
|
|
return { status: 'running', state }
|
|
}
|
|
|
|
// Stale — process is dead but state file remains
|
|
removeDaemonState(name)
|
|
return { status: 'stale' }
|
|
}
|
|
|
|
/**
|
|
* Stop a running daemon by sending SIGTERM, waiting, then SIGKILL if needed.
|
|
* Cleans up the state file afterward.
|
|
*
|
|
* @returns true if the daemon was stopped, false if it wasn't running
|
|
*/
|
|
export async function stopDaemonByPid(
|
|
name = 'remote-control',
|
|
timeoutMs = 10_000,
|
|
): Promise<boolean> {
|
|
const state = readDaemonState(name)
|
|
if (!state) {
|
|
return false
|
|
}
|
|
|
|
const { pid } = state
|
|
|
|
if (!isProcessAlive(pid)) {
|
|
removeDaemonState(name)
|
|
return false
|
|
}
|
|
|
|
// Send SIGTERM
|
|
try {
|
|
process.kill(pid, 'SIGTERM')
|
|
} catch {
|
|
removeDaemonState(name)
|
|
return false
|
|
}
|
|
|
|
// Wait for exit with timeout
|
|
const deadline = Date.now() + timeoutMs
|
|
const pollInterval = 200
|
|
|
|
while (Date.now() < deadline) {
|
|
if (!isProcessAlive(pid)) {
|
|
removeDaemonState(name)
|
|
return true
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
|
}
|
|
|
|
// Force kill
|
|
try {
|
|
process.kill(pid, 'SIGKILL')
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
|
|
// Brief wait for SIGKILL to take effect
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
|
|
removeDaemonState(name)
|
|
return true
|
|
}
|