import { spawn, execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; export const LIFECYCLE_MODE_FLAGS = new Map([ ["--background", "background"], ["--status", "status"], ["--stop", "stop"], ["--logs", "logs"], ]); const AGENT_PROVIDERS = [ { id: "opencode", env: [ "OPENCODE", "OPENCODE_BIN_PATH", "OPENCODE_SERVER", "OPENCODE_APP_INFO", "OPENCODE_MODES", ], processes: ["opencode"], }, { id: "claude-code", env: ["CLAUDECODE"], processes: ["claude"], }, { id: "cursor", env: ["CURSOR_TRACE_ID"], processes: [], }, { id: "github-copilot", matchesEnv: (env) => env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat", processes: [], }, { id: "generic", env: ["AGENT", "AI_AGENT"], processes: [], }, ]; const MAX_ANCESTRY_HOPS = 24; const readProcessParent = (pid) => { try { const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); const commandEnd = stat.lastIndexOf(")"); const commandStart = stat.indexOf("("); if (commandStart === -1 || commandEnd === -1) { return undefined; } const parentPid = Number.parseInt( stat.slice(commandEnd + 2).split(" ")[1] ?? "", 10 ); return Number.isFinite(parentPid) ? { command: stat.slice(commandStart + 1, commandEnd).toLowerCase(), parentPid, } : undefined; } catch { return undefined; } }; export const detectCodingAgent = (env = process.env, pid = process.pid) => { if (env.HA_CODING_AGENT === "0") { return undefined; } const envMatch = AGENT_PROVIDERS.find( (provider) => provider.matchesEnv?.(env) || provider.env?.some((name) => env[name]) ); if (envMatch) { return envMatch.id; } if (env.HA_CODING_AGENT === "1") { return "unknown"; } let currentPid = pid; for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) { const processInfo = readProcessParent(currentPid); if (!processInfo) { return undefined; } const processMatch = AGENT_PROVIDERS.find((provider) => provider.processes.some((name) => processInfo.command.includes(name)) ); if (processMatch) { return processMatch.id; } if (processInfo.parentPid <= 1) { return undefined; } currentPid = processInfo.parentPid; } return undefined; }; const canPromptForConflict = () => Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent()); const formatCommand = (command) => !("NO_COLOR" in process.env) ? `\u001b[1;36m${command}\u001b[0m` : `\`${command}\``; const confirmStopConflict = async (ownerDescription, stopCommand) => { const readline = createInterface({ input: process.stdin, output: process.stderr, }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30_000); try { process.stderr.write( `${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n` ); const answer = await readline.question("Stop it and continue? [y/N] ", { signal: controller.signal, }); return ["y", "yes"].includes(answer.trim().toLowerCase()); } catch (err) { if (err?.name !== "AbortError") { throw err; } process.stderr.write("\n"); return false; } finally { clearTimeout(timeout); readline.close(); } }; export const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); export const waitFor = async (predicate, intervalMs, timeoutMs) => { const deadline = Date.now() + timeoutMs; const poll = async () => { if (await predicate()) { return true; } if (Date.now() >= deadline) { return false; } await sleep(intervalMs); return poll(); }; return poll(); }; export const isProcessAlive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) { return false; } try { process.kill(pid, 0); return true; } catch (err) { // EPERM means the process exists but is owned by someone else. return err.code === "EPERM"; } }; export const processStartTime = (pid) => { if (!isProcessAlive(pid)) { return undefined; } try { const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; } catch { try { return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return undefined; } } }; export const isProcessRecordAlive = ({ pid, startTime }) => Boolean(startTime) && processStartTime(pid) === startTime; export const readProcessRecord = (file) => { try { const data = JSON.parse(fs.readFileSync(file, "utf8")); return data && Number.isInteger(data.pid) ? data : undefined; } catch (err) { if (err.code === "ENOENT" || err instanceof SyntaxError) { return undefined; } throw err; } }; export const writeProcessRecord = (file, data) => { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; try { fs.writeFileSync(temporary, JSON.stringify(data)); fs.renameSync(temporary, file); } finally { removeFileIfExists(temporary); } }; export const removeProcessRecord = (file) => { removeFileIfExists(file); }; export const acquireProcessRecord = (file, data) => { fs.mkdirSync(path.dirname(file), { recursive: true }); for (let attempt = 0; attempt < 2; attempt++) { try { const fd = fs.openSync(file, "wx"); try { fs.writeFileSync(fd, JSON.stringify(data)); } finally { fs.closeSync(fd); } return { acquired: true }; } catch (err) { if (err.code !== "EEXIST") { throw err; } const existing = readProcessRecord(file); if (existing && isProcessRecordAlive(existing)) { return { acquired: false, existing }; } if (!existing && isRecentFile(file)) { return { acquired: false }; } const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => { const current = readProcessRecord(file); if ( current && (current.token !== existing?.token || isProcessRecordAlive(current)) ) { return false; } removeProcessRecord(file); return true; }); if (!removed.acquired || !removed.value) { return { acquired: false, existing: readProcessRecord(file) }; } } } return { acquired: false, existing: readProcessRecord(file) }; }; export const releaseProcessRecord = (file, token, onRelease) => { if (!token) { return; } withExclusiveFileLockSync(`${file}.cleanup`, () => { const existing = readProcessRecord(file); if (!existing || existing.token === token) { onRelease?.(); if (existing) { removeProcessRecord(file); } } }); }; const removeFileIfExists = (file) => { try { fs.rmSync(file); } catch (err) { if (err.code !== "ENOENT") { throw err; } } }; const isRecentFile = (file) => { try { return Date.now() - fs.statSync(file).mtimeMs < 5000; } catch (err) { if (err.code === "ENOENT") { return false; } throw err; } }; export const withExclusiveFileLockSync = (file, operation) => { fs.mkdirSync(path.dirname(file), { recursive: true }); for (let attempt = 0; attempt < 2; attempt++) { let fd; try { fd = fs.openSync(file, "wx"); } catch (err) { if (err.code !== "EEXIST") { throw err; } const owner = readProcessRecord(file); if (owner && isProcessRecordAlive(owner)) { return { acquired: false }; } if (!owner && isRecentFile(file)) { return { acquired: false }; } removeFileIfExists(file); continue; } try { fs.writeFileSync( fd, JSON.stringify({ pid: process.pid, startTime: processStartTime(process.pid), }) ); return { acquired: true, value: operation() }; } finally { try { fs.closeSync(fd); } finally { removeFileIfExists(file); } } } return { acquired: false }; }; export const spawnForeground = ({ cmd, args, cwd, env, processGroup = false, onSpawn, }) => new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, detached: processGroup, env, stdio: "inherit", }); let settled = false; const forwardSigint = () => signalProcess(child.pid, "SIGINT", processGroup); const forwardSigterm = () => signalProcess(child.pid, "SIGTERM", processGroup); const forwardSighup = () => signalProcess(child.pid, "SIGHUP", processGroup); const removeSignalHandlers = () => { process.off("SIGINT", forwardSigint); process.off("SIGTERM", forwardSigterm); process.off("SIGHUP", forwardSighup); }; if (processGroup) { process.on("SIGINT", forwardSigint); process.on("SIGTERM", forwardSigterm); process.on("SIGHUP", forwardSighup); } child.once("spawn", () => { try { onSpawn?.(child); } catch (err) { settled = true; signalProcess(child.pid, "SIGTERM", processGroup); removeSignalHandlers(); reject(err); } }); child.once("error", (err) => { if (!settled) { settled = true; removeSignalHandlers(); process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`); resolve(1); } }); child.once("exit", (code) => { if (!settled) { settled = true; removeSignalHandlers(); resolve(code ?? 1); } }); }); export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) => new Promise((resolve, reject) => { fs.mkdirSync(path.dirname(logFile), { recursive: true }); const fd = fs.openSync(logFile, "w"); let child; try { child = spawn(cmd, args, { cwd, detached: true, env, stdio: ["ignore", fd, fd], }); } finally { fs.closeSync(fd); } child.once("spawn", () => { child.unref(); resolve(child); }); child.once("error", reject); }); const signalProcess = (pid, signal, processGroup) => { if (processGroup) { try { process.kill(-pid, signal); return; } catch { // Fall back to the process itself. } } try { process.kill(pid, signal); } catch { // Already gone. } }; export const terminateProcess = async ({ pid, isStopped, processGroup = true, graceMs = 10_000, }) => { signalProcess(pid, "SIGTERM", processGroup); if (await waitFor(isStopped, 300, graceMs)) { return true; } signalProcess(pid, "SIGKILL", processGroup); await sleep(300); return await isStopped(); }; const sameProcessRecord = (current, expected) => Boolean(expected?.token) && current?.token === expected.token; const stopProcessRecord = async (file, owner) => { let current = readProcessRecord(file); if (!current) { return true; } if (!sameProcessRecord(current, owner)) { return false; } if (!isProcessRecordAlive(current)) { releaseProcessRecord(file, current.token); return true; } const stopped = await terminateProcess({ pid: current.pid, processGroup: current.processGroup ?? false, isStopped: () => { const latest = readProcessRecord(file); return ( !sameProcessRecord(latest, owner) || !latest || !isProcessRecordAlive(latest) ); }, }); current = readProcessRecord(file); if (current && !sameProcessRecord(current, owner)) { return false; } if (!stopped && current) { return false; } releaseProcessRecord(file, owner.token); return true; }; export const offerToStopProcessRecord = async ({ file, owner, ownerDescription, stopCommand, }) => { if ( !owner || !stopCommand || !canPromptForConflict() || !(await confirmStopConflict(ownerDescription, stopCommand)) ) { return false; } return stopProcessRecord(file, owner); }; export const terminateDetachedProcess = (child) => terminateProcess({ pid: child.pid, processGroup: true, isStopped: () => !isProcessAlive(child.pid), }); export const outputLog = (logFile, follow, missingMessage) => { if (!fs.existsSync(logFile)) { process.stdout.write(missingMessage); return Promise.resolve(0); } if (!follow) { process.stdout.write(fs.readFileSync(logFile, "utf8")); return Promise.resolve(0); } return new Promise((resolve) => { const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" }); let settled = false; tail.once("error", () => { if (!settled) { settled = true; process.stdout.write(fs.readFileSync(logFile, "utf8")); resolve(0); } }); tail.once("exit", (code) => { if (!settled) { settled = true; resolve(code ?? 1); } }); }); }; export const runCli = (main) => { main().then( (code) => { process.exitCode = code; }, (err) => { process.stderr.write(`${err?.stack || err}\n`); process.exitCode = 1; } ); };