Files
AgentHub/scripts/launchd-service.js
T
nntrivi2001 952e8584f0 feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
2026-09-25 17:30:28 +07:00

242 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
/**
* @file launchd-service.js
* @description The macOS twin of `systemd-service.js`: a per-user LaunchAgent
* (`~/Library/LaunchAgents/io.smartgift.<name>.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ĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const MARKER = "<!-- agenthub-managed-unit — regenerated on install, do not edit by hand -->";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const xmlUnescape = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
function workingDirOf(body) {
const m = body.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/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 || ""));
}
/**
* `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) => `<string>${xmlEscape(v)}</string>`;
const envLines = Object.entries(env)
.map(([k, v]) => `\t\t<key>${xmlEscape(k)}</key>\n\t\t${str(v)}`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
${MARKER}
<plist version="1.0">
<dict>
<key>Label</key>
${str(jobLabel(name))}
<key>ProgramArguments</key>
<array>
${argv.map((a) => `\t\t${str(a)}`).join("\n")}
</array>
<key>WorkingDirectory</key>
${str(workingDir)}
<key>EnvironmentVariables</key>
<dict>
${envLines}
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>3</integer>
<key>AbandonProcessGroup</key>
<true/>
<key>StandardOutPath</key>
${str(logPath())}
<key>StandardErrorPath</key>
${str(logPath())}
</dict>
</plist>
`;
}
/**
* Writes the plist and clears any "disabled" override — 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 = {} }) {
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");
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,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
unitPath,
logPath,
__setExecImpl,
__reset,
};