feat: start-at-login is a Settings switch, off by default (v2.5.0)
- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
for the running server when none exists and enables it, off disables it;
neither starts or stops the server. A foreign unit is never touched
(409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
installs or starts one only while the user has it enabled, and a rewrite
keeps the enabled state (writeUserService({ enable })). An install that
already had the unit enabled stays on. A stale-but-enabled unit beside a
live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js
Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
This commit is contained in:
+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`.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user