Compare commits

..

1 Commits

Author SHA1 Message Date
nntrivi2001 03ecc9e29a 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)
2026-10-06 18:53:10 +07:00
25 changed files with 620 additions and 36 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
service install`), so the dashboard comes back after every login with no
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
Claude Code session, and launchd restarts it if it dies. Lane panes survive
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
+4 -1
View File
@@ -203,7 +203,10 @@ agenthub service uninstall # disable and remove it
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. Plugin installs get the same persistence automatically — see
once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
## The CLI
+4 -1
View File
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
Bản cài qua plugin cũng được tự khởi động tương tự — xem
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
`docs/PLUGINS.md`.
@@ -0,0 +1,58 @@
/**
* @file AutostartSettings — Settings → About's "Start automatically" switch:
* whether the dashboard starts at boot/login through the per-user service
* (systemd `--user` unit, or a LaunchAgent on macOS), over
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
* stops the running server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AutostartStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.autostart
.get()
.then(setStatus)
.catch(() => {});
}, []);
const change = async (enabled: boolean) => {
setSaving(true);
try {
setStatus(await api.settings.autostart.set(enabled));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const note = !status
? undefined
: !status.available
? t("autostart.unavailable")
: status.foreign
? t("autostart.foreign")
: t(`autostart.desc_${status.kind}`);
return (
<SettingsSection id="autostart" title={t("autostart.title")}>
<SettingsSwitchRow
label={t("autostart.label")}
description={note}
checked={!!status?.enabled}
disabled={!status?.available || status.foreign || saving}
onChange={(v) => void change(v)}
/>
</SettingsSection>
);
}
+8
View File
@@ -303,6 +303,14 @@
"typeToConfirm": "Type “{{phrase}}” to confirm:",
"phrase": "clear all data"
},
"autostart": {
"title": "Startup",
"label": "Start AgentHub automatically",
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
},
"about": {
"title": "About",
"description": "Server runtime information.",
+8
View File
@@ -303,6 +303,14 @@
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
"phrase": "xóa toàn bộ dữ liệu"
},
"autostart": {
"title": "Khởi động",
"label": "Tự động khởi động AgentHub",
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
},
"about": {
"title": "Giới thiệu",
"description": "Thông tin runtime của máy chủ.",
+22
View File
@@ -1114,6 +1114,18 @@ export const api = {
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
},
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
* by default; a change never starts or stops the running server. */
autostart: {
/** GET /api/settings/autostart */
get: () => request<AutostartStatus>("/settings/autostart"),
/** PUT /api/settings/autostart */
set: (enabled: boolean) =>
request<AutostartStatus>("/settings/autostart", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
@@ -4334,6 +4346,16 @@ export interface RemoteSourceInput {
enabled?: boolean;
}
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "launchd";
enabled: boolean;
foreign: boolean;
}
/** GET /api/settings/privacy. A field the environment set is `env_owned`
* and cannot be saved from the dashboard. */
export interface PrivacySettings {
+6
View File
@@ -104,6 +104,12 @@ export const SETTINGS_INDEX: {
titleKey: "about.title",
keywords: ["version", "server", "database", "uptime"],
},
{
section: "about",
titleKey: "autostart.label",
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
},
{
section: "appearance",
titleKey: "sagi.title",
+5 -1
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -107,6 +107,7 @@ import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
@@ -1121,6 +1122,9 @@ export function Settings({
{section === "about" && (
<section id="about" className="scroll-mt-24">
<div className="mb-4">
<AutostartSettings />
</div>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
{sysInfo ? (
<div className="card p-5">
+4 -3
View File
@@ -925,10 +925,11 @@ export function Workspace() {
<div
className={
sessionView
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
: viewportLocked
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
: "space-y-5"
}
>
@@ -7109,6 +7109,58 @@ exports[`screen snapshots > Settings — about 1`] = `
class="scroll-mt-24"
id="about"
>
<div
class="mb-4"
>
<section
class="card p-5 scroll-mt-24"
id="autostart"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
</div>
</section>
</div>
<p
class="text-xs text-fg-muted mb-4"
>
@@ -7181,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.4.0 (rebuild client to match)
UI build v2.5.0 (rebuild client to match)
</p>
</div>
<div
@@ -334,6 +334,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
rotate: r({ token: "t", enabled: true, env_owned: false }),
revoke: r({ enabled: false, env_owned: false }),
},
autostart: {
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
},
privacy: {
get: r({
redact_secrets: false,
+9
View File
@@ -1735,6 +1735,15 @@ POST /api/settings/cleanup
Body `{ abandon_hours?, purge_days?, dry_run? }`. `abandon_hours` marks active sessions silent that long as `abandoned` (and completes their working agents); `purge_days` deletes ended sessions (with their events and agents) that started more than that many days ago. `purge_days` must be a whole number ≥ 1, else `400 INVALID_PURGE_DAYS` — a fraction would purge almost everything. `dry_run: true` answers the same counts (`abandoned`, `purged_sessions`, `purged_events`, `purged_agents`, plus `dry_run: true`) and changes nothing.
#### Start at boot/login
```http
GET /api/settings/autostart
PUT /api/settings/autostart
```
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
---
### Tools
+13 -3
View File
@@ -130,8 +130,9 @@ waits on an install:
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — on Linux with `systemd --user` available, install/refresh a
`agenthub.service` unit — on macOS with a GUI login, the
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → About; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
with no Claude Code session involved. Everywhere else (Windows, no
@@ -242,7 +243,16 @@ throughout: a directory that will not delete is left alone, never an error.
### Auto-start on boot
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
the unit'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
bootstrap only installs or starts a unit that is enabled, never changes that
state (`writeUserService({ enable })` keeps it on a rewrite), and with it off
the server is the detached spawn below. An install that already had the unit
enabled before this switch existed keeps it on.
While it is on, every plugin bootstrap (not just the first) installs/refreshes a
systemd `--user` service (`~/.config/systemd/user/agenthub.service`, managed via
`scripts/systemd-service.js` — shared with the checkout path's
`agenthub service install`, see the README). Its `ExecStart`/`WorkingDirectory`
+83 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.4.0
version: 2.5.0
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -12585,6 +12585,88 @@ paths:
results:
- id: src_a
ok: true
/api/settings/autostart:
get:
tags:
- Settings
summary: Whether the dashboard starts at boot/login
description: The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.
operationId: getAutostart
responses:
'200':
description: Start-at-login state
content:
application/json:
schema:
type: object
required:
- available
- kind
- enabled
- foreign
properties:
available:
type: boolean
description: False with no systemd --user (Linux) or GUI login (macOS)
kind:
type: string
enum:
- systemd
- launchd
enabled:
type: boolean
foreign:
type: boolean
description: A unit named agenthub exists that AgentHub did not write
put:
tags:
- Settings
summary: Turn start-at-login on or off
description: Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.
operationId: setAutostart
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- enabled
properties:
enabled:
type: boolean
responses:
'200':
description: The new state
content:
application/json:
schema:
type: object
required:
- available
- kind
- enabled
- foreign
properties:
available:
type: boolean
description: False with no systemd --user (Linux) or GUI login (macOS)
kind:
type: string
enum:
- systemd
- launchd
enabled:
type: boolean
foreign:
type: boolean
description: A unit named agenthub exists that AgentHub did not write
'400':
description: 'EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session'
'403':
description: 'EBADORIGIN: another site asked'
'409':
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
/api/settings/codex-hooks:
get:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.4.0",
"version": "2.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.4.0",
"version": "2.5.0",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.4.0",
"version": "2.5.0",
"bin": {
"agenthub": "bin/agenthub.js"
},
+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,
+103
View File
@@ -0,0 +1,103 @@
/**
* @file Tests server/lib/autostart.js (Settings → About's "Start AgentHub
* automatically"): off by default, on writes + enables a unit for this server
* without starting it, off disables without stopping, and a foreign unit is
* never touched. Also the bootstrap's half: `writeUserService({enable:false})`
* leaves the enabled state alone. systemctl is faked; HOME is a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-autostart-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const svc = require("../../scripts/systemd-service");
const autostart = require("../lib/autostart");
svc.enableLinger = () => true; // the real one runs loginctl, outside the seam
let calls = [];
let enabled = false;
beforeEach(() => {
calls = [];
enabled = false;
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
if (args[0] === "--version") return { status: 0 };
calls.push(args.join(" "));
if (args[0] === "is-enabled")
return { status: 0, stdout: enabled ? "enabled\n" : "disabled\n" };
if (args[0] === "enable") enabled = true;
if (args[0] === "disable") enabled = false;
return { status: 0 };
});
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
const startsOrStops = (c) => /^(start|stop|restart)\b|--now/.test(c);
describe("autostart", linuxOnly, () => {
it("is off with no unit, and turning it off writes nothing", () => {
assert.equal(autostart.status().enabled, false);
autostart.setEnabled(false);
assert.equal(fs.existsSync(svc.unitPath("agenthub")), false);
});
it("on writes a unit for this server and enables it, starting nothing", () => {
const res = autostart.setEnabled(true);
assert.equal(res.enabled, true);
const body = fs.readFileSync(svc.unitPath("agenthub"), "utf8");
assert.match(
body,
new RegExp(`^WorkingDirectory=${path.resolve(__dirname, "..", "..")}$`, "m")
);
assert.ok(calls.includes("enable agenthub.service"));
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
});
it("off disables our unit and keeps it, stopping nothing", () => {
autostart.setEnabled(true);
calls = [];
const res = autostart.setEnabled(false);
assert.equal(res.enabled, false);
assert.ok(calls.includes("disable agenthub.service"));
assert.ok(!calls.some(startsOrStops), JSON.stringify(calls));
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
});
it("refuses a unit it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
assert.equal(autostart.status().foreign, true);
assert.throws(() => autostart.setEnabled(true), { code: "EFOREIGN" });
assert.equal(
fs.readFileSync(svc.unitPath("agenthub"), "utf8"),
"[Service]\nExecStart=/opt/mine\n"
);
});
it("writeUserService({enable:false}) repoints without enabling — the bootstrap's off path", () => {
svc.writeUserService({
name: "agenthub",
description: "x",
workingDir: "/plugin/root",
execStart: "/usr/bin/node /plugin/root/server/index.js",
enable: false,
});
assert.ok(!calls.some((c) => c.startsWith("enable")), JSON.stringify(calls));
assert.equal(svc.unitIsEnabled("agenthub"), false);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* @file autostart.js
* @description Settings → About's "Start automatically" switch: whether the
* dashboard starts at boot/login through the per-user service
* (`scripts/service-manager.js` — a systemd `--user` unit, or a LaunchAgent on
* macOS). The switch IS the unit's enabled state, nothing stored beside it.
* Turning it on writes a unit for THIS server when none exists, and enables
* it; turning it off disables it. Neither ever starts or stops anything — the
* server answering the click is the one that would die. Off by default: the
* plugin bootstrap installs no unit until the user opts in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const svc = require("../../scripts/service-manager");
const NAME = "agenthub";
const ROOT = path.resolve(__dirname, "..", "..");
/** A unit by our name that AgentHub did not write — never ours to touch. */
function isForeign() {
return fs.existsSync(svc.unitPath(NAME)) && !svc.unitIsOurs(NAME);
}
function status() {
const available = svc.systemctlAvailable();
return {
available,
kind: svc.kind,
enabled: available && svc.unitIsEnabled(NAME),
foreign: isForeign(),
};
}
/** The unit for the running server — same shape the bootstrap writes. */
function unitForThisServer() {
const entry = path.join(ROOT, "server", "index.js");
const env = { NODE_ENV: "production", PATH: process.env.PATH || "" };
for (const key of ["NODE_PATH", "DASHBOARD_CLIENT_DIST", "DASHBOARD_PORT"]) {
if (process.env[key]) env[key] = process.env[key];
}
return {
name: NAME,
description: "AgentHub dashboard server",
workingDir: ROOT,
execStart: `${process.execPath} ${entry}`,
argv: [process.execPath, entry],
env,
};
}
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
function setEnabled(on) {
if (!svc.systemctlAvailable()) {
throw fail("EUNAVAILABLE", `no ${svc.label} manager is available on this machine`);
}
let result;
if (svc.unitIsOurs(NAME)) result = svc.setUnitEnabled(NAME, on);
else if (isForeign()) result = "foreign";
else if (!on)
result = "ok"; // nothing installed: already off
else {
result = svc.writeUserService(unitForThisServer()); // writes + enables, starts nothing
if (result === "written") svc.enableLinger();
}
if (result === "foreign") {
throw fail("EFOREIGN", `${svc.unitPath(NAME)} was not written by AgentHub — not touching it`);
}
if (result === "failed" || result === "unavailable") {
throw fail("ESERVICE", `could not change the ${svc.label}`);
}
return status();
}
module.exports = { status, setEnabled };
+66
View File
@@ -4,6 +4,8 @@
* - GET /api/sessions/facets (Sessions)
* - GET /api/settings/claude-home (Settings)
* - PUT /api/settings/claude-home (Settings)
* - GET /api/settings/autostart (Settings)
* - PUT /api/settings/autostart (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1009,6 +1011,70 @@ const paths = {
},
},
"/api/settings/autostart": (() => {
const status = {
description: "Start-at-login state",
content: {
"application/json": {
schema: {
type: "object",
required: ["available", "kind", "enabled", "foreign"],
properties: {
available: {
type: "boolean",
description: "False with no systemd --user (Linux) or GUI login (macOS)",
},
kind: { type: "string", enum: ["systemd", "launchd"] },
enabled: { type: "boolean" },
foreign: {
type: "boolean",
description: "A unit named agenthub exists that AgentHub did not write",
},
},
},
},
},
};
return {
get: {
tags: ["Settings"],
summary: "Whether the dashboard starts at boot/login",
description:
"The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.",
operationId: "getAutostart",
responses: { 200: status },
},
put: {
tags: ["Settings"],
summary: "Turn start-at-login on or off",
description:
"Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.",
operationId: "setAutostart",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["enabled"],
properties: { enabled: { type: "boolean" } },
},
},
},
},
responses: {
200: { ...status, description: "The new state" },
400: {
description:
"EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session",
},
403: { description: "EBADORIGIN: another site asked" },
409: { description: "EFOREIGN: a unit named agenthub that AgentHub did not write" },
},
},
};
})(),
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
+19
View File
@@ -252,6 +252,25 @@ router.get("/antigravity-hooks", codexHooksRoute(antigravityHooks.status));
router.post("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.install));
router.delete("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.remove));
// GET/PUT /api/settings/autostart — start the dashboard at boot/login through
// the per-user service (server/lib/autostart.js). Off by default; a PUT only
// enables or disables the unit, it never starts or stops the server.
const autostart = require("../lib/autostart");
router.get("/autostart", (_req, res) => res.json(autostart.status()));
router.put("/autostart", sameOriginGuard, (req, res) => {
if (typeof req.body?.enabled !== "boolean") {
return res
.status(400)
.json({ error: { code: "EBADVALUE", message: "`enabled` must be a boolean" } });
}
try {
res.json(autostart.setEnabled(req.body.enabled));
} catch (err) {
const status = { EUNAVAILABLE: 400, EFOREIGN: 409 }[err.code] || 500;
res.status(status).json({ error: { code: err.code || "ESERVICE", message: err.message } });
}
});
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.