Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a |
+4
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||
|
||||
@@ -208,7 +208,7 @@ function QuotaPanel({
|
||||
onMouseLeave={onLeave}
|
||||
onFocus={onEnter}
|
||||
onBlur={onLeave}
|
||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
|
||||
@@ -330,7 +330,7 @@ export function UpdateNotifier() {
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
||||
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
@@ -370,7 +370,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-confirm-delete-title"
|
||||
aria-describedby="cc-confirm-delete-body"
|
||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
||||
{t("edit.confirmDelete")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||
{t("edit.confirmDeleteBody")}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ủ.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
@@ -1225,7 +1229,7 @@ function ClearDataDialog({
|
||||
aria-modal="true"
|
||||
aria-labelledby="clear-data-title"
|
||||
aria-describedby="clear-data-body"
|
||||
className="card w-full max-w-md space-y-4 border-status-danger/40 p-5"
|
||||
className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2
|
||||
|
||||
@@ -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.3 (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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-4
@@ -126,12 +126,13 @@ waits on an install:
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
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
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.4.0
|
||||
version: 2.5.3
|
||||
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:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.3",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
|
||||
@@ -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,
|
||||
|
||||
+83
-49
@@ -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
|
||||
@@ -26,6 +26,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const os = require("os");
|
||||
@@ -383,6 +384,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
|
||||
|
||||
/* ------------------------------------------------------------- dependencies */
|
||||
|
||||
/**
|
||||
* npm, run by the node executing this file: `{ file, args, env, shell }` for
|
||||
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
|
||||
* nothing depends on PATH — the self-update chain runs with the dashboard
|
||||
* server's environment, and on Windows that PATH need not hold the directory
|
||||
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
|
||||
* PATH is still prefixed with that node's directory so npm's scripts (the UI
|
||||
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
|
||||
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
|
||||
*/
|
||||
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
|
||||
const dir = path.dirname(execPath);
|
||||
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
|
||||
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
|
||||
const cli = [
|
||||
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
|
||||
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
|
||||
].find((p) => fs.existsSync(p));
|
||||
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
|
||||
const win = process.platform === "win32";
|
||||
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||
@@ -395,12 +419,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
const src = path.join(root, f);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const res = spawnSync(
|
||||
npm,
|
||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
||||
);
|
||||
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
||||
const res = spawnSync(npm.file, npm.args, {
|
||||
cwd: rt,
|
||||
stdio: "inherit",
|
||||
env: npm.env,
|
||||
shell: npm.shell,
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
@@ -738,13 +763,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
|
||||
@@ -825,6 +854,43 @@ function main() {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// Exported before the entry block below, not after it: `bootstrap()` requires
|
||||
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
|
||||
// and when this file is the entry that require resolves to whatever is on
|
||||
// module.exports at that moment — an empty object if it is assigned last.
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
npmCommand,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const force = process.argv.includes("--force");
|
||||
if (force || process.argv.includes("--worker")) {
|
||||
@@ -850,35 +916,3 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
@@ -125,10 +125,9 @@ function runtimeDepsStatus(rt) {
|
||||
|
||||
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
||||
function npmStatus() {
|
||||
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
// The same npm the bootstrap and the UI build run (`npmCommand`), not PATH's.
|
||||
const npm = boot.npmCommand(["-v"]);
|
||||
const res = spawnSync(npm.file, npm.args, { encoding: "utf8", env: npm.env, shell: npm.shell });
|
||||
if (res.status !== 0) {
|
||||
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ const boot = require("./plugin-bootstrap");
|
||||
const { resolveDashboardPort } = require("../server/lib/server-info");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const NPM = "npm";
|
||||
|
||||
function run(cmd, args, cwd, stdio) {
|
||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
||||
/** `cmd` is always NPM: run through `boot.npmCommand`, never a PATH lookup. */
|
||||
function run(_cmd, args, cwd, stdio) {
|
||||
const npm = boot.npmCommand(args);
|
||||
const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const { rotateIfLarge } = require("./log-rotate");
|
||||
const path = require("path");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const [, , pidArg, entry] = process.argv;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,16 @@ describe("native-dialog", () => {
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it("gives the Windows dialog a TopMost owner, so it does not open behind the browser", () => {
|
||||
for (const kind of ["dir", "file"]) {
|
||||
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||
assert.equal(cmd, "powershell.exe");
|
||||
const script = args.at(-1);
|
||||
assert.match(script, /\$o\.TopMost = \$true;.*\$o\.Show\(\);.*\$d\.ShowDialog\(\$o\)/);
|
||||
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"answers the chosen path, null on Cancel, and falls through a missing program",
|
||||
{
|
||||
@@ -95,10 +105,16 @@ describe("native-dialog", () => {
|
||||
|
||||
it("tells a Cancel from a failure per program", () => {
|
||||
assert.equal(nd.isCancel("zenity", ""), true);
|
||||
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
|
||||
assert.equal(
|
||||
nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"),
|
||||
true
|
||||
);
|
||||
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
|
||||
assert.equal(
|
||||
nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"),
|
||||
false
|
||||
);
|
||||
assert.equal(nd.isCancel("powershell.exe", ""), true);
|
||||
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
|
||||
});
|
||||
|
||||
@@ -651,3 +651,62 @@ describe("pruneRetiredPluginRoots", () => {
|
||||
assert.ok(fs.existsSync(sibling));
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmCommand", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-npmcmd-"));
|
||||
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const cliAt = (...parts) => {
|
||||
const p = path.join(tmp, ...parts);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, "");
|
||||
return p;
|
||||
};
|
||||
|
||||
it("runs the node's own bundled npm (Windows layout), no shell, no PATH lookup", () => {
|
||||
// The "'npm.cmd' is not recognized" failure: the update chain's PATH had no npm.
|
||||
const cli = cliAt("win", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const execPath = path.join(tmp, "win", "node.exe");
|
||||
const npm = boot.npmCommand(["install"], { execPath, env: { Path: "C:\\other" } });
|
||||
assert.equal(npm.file, execPath);
|
||||
assert.deepEqual(npm.args, [cli, "install"]);
|
||||
assert.equal(npm.shell, false);
|
||||
// Windows env keys keep their own case — a second "PATH" key would be ambiguous.
|
||||
assert.equal(npm.env.Path, [path.dirname(execPath), "C:\\other"].join(path.delimiter));
|
||||
assert.equal(npm.env.PATH, undefined);
|
||||
});
|
||||
|
||||
it("finds the Unix layout (bin/node + lib/node_modules/npm)", () => {
|
||||
const cli = cliAt("unix", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const execPath = path.join(tmp, "unix", "bin", "node");
|
||||
const npm = boot.npmCommand(["-v"], { execPath, env: { PATH: "/usr/bin" } });
|
||||
assert.deepEqual(npm.args, [
|
||||
path.join(tmp, "unix", "bin", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
"-v",
|
||||
]);
|
||||
assert.ok(fs.existsSync(npm.args[0]) && path.resolve(npm.args[0]) === cli);
|
||||
assert.equal(npm.env.PATH.split(path.delimiter)[0], path.dirname(execPath));
|
||||
});
|
||||
|
||||
it("falls back to npm on PATH when the node has no bundled npm", () => {
|
||||
const npm = boot.npmCommand(["-v"], { execPath: path.join(tmp, "bare", "node"), env: {} });
|
||||
assert.equal(npm.file, process.platform === "win32" ? "npm.cmd" : "npm");
|
||||
assert.deepEqual(npm.args, ["-v"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry scripts export before they run", () => {
|
||||
// plugin-bootstrap.js run as the entry calls bootstrap() → plugin-open.js,
|
||||
// which requires plugin-bootstrap back and gets whatever module.exports holds
|
||||
// at that moment. Assigned after the entry block it was still {}, and v2.5.1's
|
||||
// update died with "boot.npmCommand is not a function". Requiring the module
|
||||
// (as every other test here does) never takes that path, hence this check.
|
||||
for (const file of ["plugin-bootstrap.js", "plugin-open.js"]) {
|
||||
it(file, () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, "..", "..", "scripts", file), "utf8");
|
||||
const exportsAt = src.indexOf("\nmodule.exports = {");
|
||||
const entryAt = src.indexOf("\nif (require.main === module)");
|
||||
assert.ok(exportsAt > 0 && entryAt > 0, "both markers present");
|
||||
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Tests for server/lib/windows-hide.js: on win32 every child process,
|
||||
* async or sync, is started with `windowsHide: true`; elsewhere nothing changes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { install } = require("../lib/windows-hide");
|
||||
|
||||
function fakeCp() {
|
||||
const seen = [];
|
||||
class ChildProcess {
|
||||
spawn(options) {
|
||||
seen.push(options);
|
||||
}
|
||||
}
|
||||
const rec = (...args) => seen.push(args);
|
||||
return { seen, cp: { ChildProcess, spawnSync: rec, execFileSync: rec, execSync: rec } };
|
||||
}
|
||||
|
||||
test("win32: async spawns and every sync form get windowsHide", () => {
|
||||
const { seen, cp } = fakeCp();
|
||||
install(cp, "win32");
|
||||
install(cp, "win32"); // idempotent: no double wrap
|
||||
|
||||
new cp.ChildProcess().spawn({ file: "git", windowsHide: false });
|
||||
cp.execFileSync("git", ["status"]);
|
||||
cp.execFileSync("git", ["status"], { cwd: "x" });
|
||||
cp.execFileSync("git", { cwd: "x" });
|
||||
cp.execSync("dir");
|
||||
cp.spawnSync("git", ["log"], undefined);
|
||||
|
||||
assert.deepEqual(seen, [
|
||||
{ file: "git", windowsHide: true },
|
||||
["git", ["status"], { windowsHide: true }],
|
||||
["git", ["status"], { cwd: "x", windowsHide: true }],
|
||||
["git", { cwd: "x", windowsHide: true }],
|
||||
["dir", { windowsHide: true }],
|
||||
["git", ["log"], { windowsHide: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("other platforms are left alone", () => {
|
||||
const { cp } = fakeCp();
|
||||
const before = { ...cp, spawn: cp.ChildProcess.prototype.spawn };
|
||||
install(cp, "linux");
|
||||
assert.equal(cp.execSync, before.execSync);
|
||||
assert.equal(cp.ChildProcess.prototype.spawn, before.spawn);
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// First, before any module destructures child_process: no console window per
|
||||
// git/npm call on Windows (server/lib/windows-hide.js).
|
||||
require("./lib/windows-hide").install();
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||
|
||||
// A server started from inside a Claude Code session (the bootstrap fallback,
|
||||
|
||||
@@ -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 };
|
||||
+12
-9
@@ -9,6 +9,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const aiTools = require("./ai-tools");
|
||||
const {
|
||||
@@ -182,17 +183,18 @@ function normalizeTitle(value) {
|
||||
*/
|
||||
function canonicalCwd(cwd) {
|
||||
// A relative path must stay relative, so createLane still refuses it.
|
||||
if (!cwd.startsWith("/")) return cwd;
|
||||
const trimmed = cwd.replace(/\/+$/, "");
|
||||
// path.isAbsolute, not a leading "/": a Windows cwd is `D:\…`.
|
||||
if (!path.isAbsolute(cwd)) return cwd;
|
||||
const trimmed = path.resolve(cwd);
|
||||
// A path that does not exist yet resolves through its deepest existing
|
||||
// ancestor, so /link/new and /real/new still name the same lane.
|
||||
const path = require("node:path");
|
||||
const tail = [];
|
||||
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
||||
try {
|
||||
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
|
||||
return path.join(require("node:fs").realpathSync.native(dir), ...tail);
|
||||
} catch {
|
||||
if (dir === "/" || !dir) return trimmed;
|
||||
// A root (`/`, `D:\` — a drive that does not exist) is its own dirname.
|
||||
if (path.dirname(dir) === dir) return trimmed;
|
||||
tail.unshift(path.basename(dir));
|
||||
}
|
||||
}
|
||||
@@ -210,7 +212,7 @@ function createLane({
|
||||
tool = "claude",
|
||||
router_profile = null,
|
||||
} = {}) {
|
||||
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
|
||||
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
|
||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||
}
|
||||
validateKind(kind);
|
||||
@@ -603,12 +605,13 @@ function recoverInterruptedProvisioning() {
|
||||
*/
|
||||
function resolveLaneByCwd(cwd, tool) {
|
||||
if (!cwd || typeof cwd !== "string") return null;
|
||||
const target = cwd.replace(/\/+$/, "");
|
||||
// A Windows cwd ends in and nests under `\`.
|
||||
const target = cwd.replace(/[\\/]+$/, "");
|
||||
let best = null;
|
||||
for (const lane of listLanes()) {
|
||||
if (tool && lane.tool !== tool) continue;
|
||||
const base = lane.cwd.replace(/\/+$/, "");
|
||||
if (target === base || target.startsWith(`${base}/`)) {
|
||||
const base = lane.cwd.replace(/[\\/]+$/, "");
|
||||
if (target === base || target.startsWith(`${base}/`) || target.startsWith(base + path.sep)) {
|
||||
if (!best || base.length > best.cwd.length) best = lane;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,12 @@ function dialogCommands(kind, dir, platform = process.platform) {
|
||||
"-NoProfile",
|
||||
"-STA",
|
||||
"-Command",
|
||||
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
||||
// The server is a background process, and Windows will not let one
|
||||
// bring a window to the front: an ownerless dialog opened BEHIND the
|
||||
// browser (or hidden — the first ShowWindow of a process started
|
||||
// with windowsHide). An invisible TopMost owner, shown first, takes
|
||||
// that first ShowWindow and keeps the dialog on top.
|
||||
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o = New-Object System.Windows.Forms.Form; $o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.Show(); $o.Activate(); ${dialog}; if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Makes every child process on Windows start with `windowsHide: true`.
|
||||
* The dashboard runs detached, with no console of its own, so Windows gave
|
||||
* each console program it ran (git, npm, where, …) a NEW console window — a
|
||||
* flash that stole focus on every poll. Node has no global default, so this
|
||||
* patches `ChildProcess.prototype.spawn` (the one path spawn/exec/execFile/fork
|
||||
* and their promisified forms all take) and wraps the sync exports. Require it
|
||||
* FIRST in an entry point: a `const { execSync } = require("child_process")`
|
||||
* evaluated before it keeps the unwrapped function. A no-op off Windows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const DONE = Symbol.for("agenthub.windowsHide");
|
||||
|
||||
function install(cp = require("child_process"), platform = process.platform) {
|
||||
if (platform !== "win32" || cp[DONE]) return;
|
||||
cp[DONE] = true;
|
||||
|
||||
const spawn = cp.ChildProcess.prototype.spawn;
|
||||
cp.ChildProcess.prototype.spawn = function (options) {
|
||||
options.windowsHide = true;
|
||||
return spawn.call(this, options);
|
||||
};
|
||||
|
||||
// spawnSync(cmd, args?, opts?), execFileSync(file, args?, opts?), execSync(cmd, opts?)
|
||||
for (const name of ["spawnSync", "execFileSync", "execSync"]) {
|
||||
const fn = cp[name];
|
||||
cp[name] = function (...args) {
|
||||
while (args.length > 1 && args[args.length - 1] == null) args.pop();
|
||||
const last = args[args.length - 1];
|
||||
if (args.length > 1 && typeof last === "object" && !Array.isArray(last)) {
|
||||
args[args.length - 1] = { ...last, windowsHide: true };
|
||||
} else {
|
||||
args.push({ windowsHide: true });
|
||||
}
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { install };
|
||||
@@ -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",
|
||||
|
||||
@@ -406,7 +406,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
// it, a nested cwd still resolves to its owner, which is what the CLI and
|
||||
// other callers adopting "the lane I am inside" rely on.
|
||||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||
const strip = (p) => p.replace(/\/+$/, "");
|
||||
const strip = (p) => p.replace(/[\\/]+$/, "");
|
||||
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
||||
if (owner) {
|
||||
// First-write wins on the title: an ensure-with-title on an existing lane
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -67,6 +67,7 @@ function gitBranch(cwd) {
|
||||
cwd,
|
||||
timeout: 500,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
Reference in New Issue
Block a user