#!/usr/bin/env node /** * @file launchd-service.js * @description The macOS twin of `systemd-service.js`: a per-user LaunchAgent * (`~/Library/LaunchAgents/io.smartgift..plist`) that starts the AgentHub * dashboard at login and keeps it running, without Claude Code being opened * first. Same exports and the same return values as the systemd module, so * `service-manager.js` can hand either one to the bootstrap, the CLI and the * doctor. Every `launchctl` call goes through one seam (`__setExecImpl`) so * tests never touch a real launchd. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const MARKER = ""; const kind = "launchd"; const label = "LaunchAgent"; function jobLabel(name) { return `io.smartgift.${name}`; } function unitPath(name) { return path.join(os.homedir(), "Library", "LaunchAgents", `${jobLabel(name)}.plist`); } function logPath() { return path.join(os.homedir(), "Library", "Logs", "agenthub.log"); } function domain() { return `gui/${process.getuid()}`; } let execImpl = (args, opts) => spawnSync("launchctl", args, opts); function __setExecImpl(fn) { execImpl = fn; } function __reset() { execImpl = (args, opts) => spawnSync("launchctl", args, opts); } function launchctl(args) { return execImpl(args, { encoding: "utf8", stdio: "pipe" }); } /** * A LaunchAgent lives in the user's GUI domain, which exists only while that * user is logged in at the console. Over a bare SSH login there is none, and * the caller falls back to its detached spawn. */ function systemctlAvailable() { if (process.platform !== "darwin") return false; return launchctl(["print", domain()]).status === 0; } function readPlist(name) { try { return fs.readFileSync(unitPath(name), "utf8"); } catch { return null; } } function unitIsOurs(name) { const body = readPlist(name); return !!body && body.includes(MARKER); } const xmlEscape = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); const xmlUnescape = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); function workingDirOf(body) { const m = body.match(/WorkingDirectory<\/key>\s*([^<]*)<\/string>/); return m ? xmlUnescape(m[1]) : null; } /** Same contract as the systemd twin: pure fs, never a foreign plist. */ function unitIsStale(name, root) { const body = readPlist(name); if (!body || !body.includes(MARKER)) return false; return workingDirOf(body) !== root; } /** The directory the plist runs from — ours or not (the cache pruner's guard). */ function unitRoot(name) { const body = readPlist(name); return body ? workingDirOf(body) : null; } function isLoaded(name) { return launchctl(["print", `${domain()}/${jobLabel(name)}`]).status === 0; } function unitIsActive(name) { if (!systemctlAvailable()) return false; const res = launchctl(["print", `${domain()}/${jobLabel(name)}`]); return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || "")); } /** Is our plist set to load at login — the Settings "start automatically" * switch. A `launchctl disable` override is what turns it off. */ function unitIsEnabled(name) { if (!systemctlAvailable() || !unitIsOurs(name)) return false; const res = launchctl(["print-disabled", domain()]); const line = String(res.stdout || "") .split("\n") .find((l) => l.includes(`"${jobLabel(name)}"`)); return !line || !/=>\s*(disabled|true)/.test(line); } /** Enable or disable loading at login — never loads or unloads the job, so * the server answering the switch stays up either way. */ function setUnitEnabled(name, on) { if (!systemctlAvailable()) return "unavailable"; if (!unitIsOurs(name)) return "foreign"; const res = launchctl([on ? "enable" : "disable", `${domain()}/${jobLabel(name)}`]); return res.status === 0 ? "ok" : "failed"; } /** * `argv` is the program and its arguments as separate strings (the systemd * twin's `execStart` is one line; a path with a space would split here). * `KeepAlive` true is `Restart=always`. `AbandonProcessGroup` keeps the lane * panes alive: they run in a tmux server the dashboard spawned, and launchd * otherwise kills a stopped job's whole process group — every running agent * with it, on every update. */ function unitContent({ name, workingDir, argv, env }) { const str = (v) => `${xmlEscape(v)}`; const envLines = Object.entries(env) .map(([k, v]) => `\t\t${xmlEscape(k)}\n\t\t${str(v)}`) .join("\n"); return ` ${MARKER} Label ${str(jobLabel(name))} ProgramArguments ${argv.map((a) => `\t\t${str(a)}`).join("\n")} WorkingDirectory ${str(workingDir)} EnvironmentVariables ${envLines} RunAtLoad KeepAlive ThrottleInterval 3 AbandonProcessGroup StandardOutPath ${str(logPath())} StandardErrorPath ${str(logPath())} `; } /** * Writes the plist and clears any "disabled" override (unless `enable: * false`, which leaves it as it is) — and starts nothing. * A loaded job keeps running its OLD definition until `restartUserService`; * an unloaded one is picked up at the next login. Same split, same reason, as * the systemd twin. * * @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"} */ function writeUserService({ name, workingDir, argv, env = {}, enable = true }) { if (!systemctlAvailable()) return "unavailable"; const body = unitContent({ name, workingDir, argv, env }); const target = unitPath(name); try { const current = readPlist(name); if (current !== null) { if (!current.includes(MARKER)) return "foreign"; if (current === body) return "unchanged"; } fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(logPath()), { recursive: true }); fs.writeFileSync(target, body, "utf8"); if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]); return "written"; } catch { return "failed"; } } /** * Reload the job from its plist. `kickstart -k` would restart the process but * keep the definition launchd already holds — the old plugin root — so it is * bootout + bootstrap. Unlike systemd, the caller is not reaped with the job: * it runs in its own process group and the job abandons its group anyway. */ function restartUserService(name) { if (!systemctlAvailable()) return "unavailable"; launchctl(["bootout", `${domain()}/${jobLabel(name)}`]); // not loaded: fine const res = launchctl(["bootstrap", domain(), unitPath(name)]); return res.status === 0 ? "restarted" : "failed"; } /** Write, then load and start. For `agenthub service install` and a bootstrap * that owns no running job. */ function installUserService(opts) { const result = writeUserService(opts); if (result === "written" || (result === "unchanged" && !isLoaded(opts.name))) { if (restartUserService(opts.name) !== "restarted") return "failed"; } else if (result === "unchanged") { launchctl(["kickstart", `${domain()}/${jobLabel(opts.name)}`]); // start if stopped } return result; } /** @returns {"removed"|"absent"|"foreign"|"unavailable"} */ function uninstallUserService(name) { if (!systemctlAvailable()) return "unavailable"; const body = readPlist(name); if (body === null) return "absent"; if (!body.includes(MARKER)) return "foreign"; launchctl(["bootout", `${domain()}/${jobLabel(name)}`]); fs.rmSync(unitPath(name), { force: true }); return "removed"; } /** A LaunchAgent starts at login by definition; there is no linger to grant. */ function enableLinger() { return true; } module.exports = { kind, label, systemctlAvailable, unitIsOurs, unitIsStale, unitRoot, unitIsActive, unitIsEnabled, setUnitEnabled, writeUserService, restartUserService, installUserService, uninstallUserService, enableLinger, unitPath, logPath, __setExecImpl, __reset, };