feat: start-at-login is a Settings switch, off by default (v2.5.0)

- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
  GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
  the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
  for the running server when none exists and enables it, off disables it;
  neither starts or stops the server. A foreign unit is never touched
  (409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
  installs or starts one only while the user has it enabled, and a rewrite
  keeps the enabled state (writeUserService({ enable })). An install that
  already had the unit enabled stays on. A stale-but-enabled unit beside a
  live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js

Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
This commit is contained in:
2026-10-06 18:53:10 +07:00
parent 4aae61297f
commit 03ecc9e29a
25 changed files with 620 additions and 36 deletions
+26 -3
View File
@@ -105,6 +105,26 @@ function unitIsActive(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).
@@ -153,14 +173,15 @@ ${envLines}
}
/**
* Writes the plist and clears any "disabled" override — and starts nothing.
* 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 = {} }) {
function writeUserService({ name, workingDir, argv, env = {}, enable = true }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ name, workingDir, argv, env });
const target = unitPath(name);
@@ -173,7 +194,7 @@ function writeUserService({ name, workingDir, argv, env = {} }) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
fs.writeFileSync(target, body, "utf8");
launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
return "written";
} catch {
return "failed";
@@ -229,6 +250,8 @@ module.exports = {
unitIsStale,
unitRoot,
unitIsActive,
unitIsEnabled,
setUnitEnabled,
writeUserService,
restartUserService,
installUserService,
+15 -11
View File
@@ -8,11 +8,11 @@
*
* The worker installs runtime dependencies, removes hook entries left behind by
* an older `npm run install-hooks` (they would double-count every event), puts
* the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd
* `--user` service or a macOS LaunchAgent when available (see
* `service-manager.js`), so it survives
* reboots without Claude Code being opened first; otherwise a detached spawn
* scoped to this session, same as before.
* the `agenthub` CLI on PATH, and starts the dashboard server — through the
* systemd `--user` service or macOS LaunchAgent (see `service-manager.js`)
* only while the user has turned start-at-login on (Settings → About, OFF by
* default), so it survives reboots without Claude Code being opened first;
* otherwise a detached spawn scoped to this session.
*
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
* the plugin cache, which Claude Code replaces wholesale on every plugin
@@ -738,13 +738,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// the restart is deferred to `pendingSystemdRestart` below, because
// starting it now would kill this process mid-bootstrap. Otherwise
// there is no cgroup to be reaped by, and install-and-start is fine.
const result = systemdOwned
? svc.writeUserService(unit)
: svc.systemctlAvailable()
? svc.installUserService(unit)
: "unavailable";
// Start-at-login is OPT-IN (Settings → About, `/api/settings/autostart`):
// the unit is installed or started only while the user has it enabled,
// and its enabled state is never changed here.
const autostart = svc.unitIsEnabled("agenthub");
let result = "off";
if (systemdOwned) result = svc.writeUserService({ ...unit, enable: autostart });
else if (autostart)
result = needsServer ? svc.installUserService(unit) : svc.writeUserService(unit);
else if (unitStale) svc.writeUserService({ ...unit, enable: false });
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
if (result === "written" && autostart) svc.enableLinger();
// Only when the RUNNING process has to change. A stale unit beside a
// healthy server is now corrected on disk and left to take effect at
// the next start — restarting a working dashboard to fix a file that
+22 -3
View File
@@ -103,6 +103,22 @@ function unitRoot(name) {
return match ? match[1].trim() : null;
}
/** Is our unit enabled to start at boot/login — the Settings "start
* automatically" switch. Costs a `systemctl` call. */
function unitIsEnabled(name) {
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "enabled";
}
/** Enable or disable our unit for boot — never starts or stops it, so the
* server answering the switch stays up either way. */
function setUnitEnabled(name, on) {
if (!systemctlAvailable()) return "unavailable";
if (!unitIsOurs(name)) return "foreign";
return systemctl([on ? "enable" : "disable", `${name}.service`]).status === 0 ? "ok" : "failed";
}
/** Is the unit currently running? Costs a `systemctl` call — never call it
* from the fast path. */
function unitIsActive(name) {
@@ -138,14 +154,15 @@ function systemctl(args) {
}
/**
* Writes (or refreshes) the unit file and enables it for boot — but starts
* Writes (or refreshes) the unit file and enables it for boot (unless
* `enable: false`, which leaves the enabled state as it is) — but starts
* nothing. Split out of `installUserService` for the plugin self-update, which
* must be able to point the unit at the new cache directory *before* anything
* stops the running server, and defer the restart to its very last act.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
function writeUserService({ name, description, workingDir, execStart, env = {}, enable = true }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ description, workingDir, execStart, env });
const target = unitPath(name);
@@ -158,7 +175,7 @@ function writeUserService({ name, description, workingDir, execStart, env = {} }
fs.mkdirSync(unitDir(), { recursive: true });
fs.writeFileSync(target, body, "utf8");
systemctl(["daemon-reload"]);
systemctl(["enable", `${name}.service`]);
if (enable) systemctl(["enable", `${name}.service`]);
return "written";
} catch {
return "failed";
@@ -231,6 +248,8 @@ module.exports = {
unitIsStale,
unitRoot,
unitIsActive,
unitIsEnabled,
setUnitEnabled,
writeUserService,
restartUserService,
installUserService,