+ );
+}
+
+const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => {
me.mockReset();
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
+
+describe("usePermissions / useWorkLanes", () => {
+ const as = async (state: unknown) => {
+ cleanup();
+ me.mockResolvedValue(state);
+ render(
+
+
+
+ );
+ return (await screen.findByText(/work:\d/)).textContent;
+ };
+
+ it("lets an admin and local mode do everything", async () => {
+ for (const state of [
+ { mode: "local" },
+ { mode: "server", user: { id: 9, username: "boss", role: "admin" } },
+ ]) {
+ expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
+ }
+ });
+
+ it("lets an analyst read everything but work only in their own workspaces", async () => {
+ expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
+ "admin:false all:true mine:true theirs:false work:1"
+ );
+ });
+
+ it("lets a developer act on everything they can see, and nothing host-wide", async () => {
+ expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
+ "admin:false all:false mine:true theirs:true work:1,2"
+ );
+ });
+});
diff --git a/client/src/components/__tests__/WorkspaceTree.test.tsx b/client/src/components/__tests__/WorkspaceTree.test.tsx
index f088393f..ec407d8a 100644
--- a/client/src/components/__tests__/WorkspaceTree.test.tsx
+++ b/client/src/components/__tests__/WorkspaceTree.test.tsx
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
+ await user.click(screen.getByTestId("workspace-sessions-less-1"));
+ expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
+ expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
+ expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx
index 6ee08376..702e2d63 100644
--- a/client/src/components/conversation/ConversationView.tsx
+++ b/client/src/components/conversation/ConversationView.tsx
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
interface ConversationViewProps {
sessionId: string;
initialTranscriptId?: string | null;
+ /** Fill the parent as a chat (a workspace's session view): no toolbar, no
+ * page-height cap — the parent sizes it and puts a composer under it. */
+ fill?: boolean;
}
-export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
+export function ConversationView({
+ sessionId,
+ initialTranscriptId,
+ fill = false,
+}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState([]);
const [total, setTotal] = useState(0);
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const [error, setError] = useState(null);
const [transcripts, setTranscripts] = useState([]);
const [showNewMsg, setShowNewMsg] = useState(false);
+ // Scrolled up from the end: a chat (`fill`) offers the way back down.
+ const [awayFromEnd, setAwayFromEnd] = useState(false);
// Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0);
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom;
+ setAwayFromEnd(!atBottom);
// Hide "new messages" indicator when scrolled to bottom
if (atBottom) {
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return (
-
+
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
- {!loading && (
+ {!loading && !fill && (
+ {/* Claude Desktop's round "back to the end" button */}
+ {fill && awayFromEnd && (
+
+ )}
+
{/* New messages indicator */}
- {showNewMsg && (
+ {showNewMsg && !fill && (
diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx
index 96b727a1..44cf6761 100644
--- a/client/src/pages/Users.tsx
+++ b/client/src/pages/Users.tsx
@@ -25,7 +25,7 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"];
-const ROLES: Role[] = ["admin", "analyst", "member"];
+const ROLES: Role[] = ["admin", "analyst", "developer"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
@@ -61,11 +61,11 @@ export function Users() {
const [spinning, refresh] = useSpin(load);
useEffect(() => {
- if (me && me.role !== "member") void load();
+ if (me && me.role !== "developer") void load();
}, [me, load]);
- // Local mode, or a member: there is no Users page for them.
- if (!me || me.role === "member") return ;
+ // Local mode, or a developer: there is no Users page for them.
+ if (!me || me.role === "developer") return ;
return (
@@ -265,7 +265,7 @@ function AccountDialog({
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
- const [role, setRole] = useState(user?.role ?? "member");
+ const [role, setRole] = useState(user?.role ?? "developer");
const [laneId, setLaneId] = useState(null);
const [lanes, setLanes] = useState(null);
const [current, setCurrent] = useState("");
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx
index 8a63dd50..4349384d 100644
--- a/client/src/pages/Workspace.tsx
+++ b/client/src/pages/Workspace.tsx
@@ -69,7 +69,7 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
-import { useLanes } from "../hooks/useLanes";
+import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
ConsoleTabs,
@@ -168,7 +168,7 @@ function slotOf(runId: string | null | undefined): number {
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
* open (a tab, a sidebar row) shows the tab holding that run — a session in
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
- * into the tab on screen, to the right of the focused pane. Every live run
+ * into the tab on screen, at the 2×2 grid's next place. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
@@ -189,7 +189,7 @@ function ConsoleArea({
activeRuns: RunListResponse | null;
onHasActiveRunChange: (val: boolean) => void;
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
- * beside the focused pane instead of in it; `focusOnly` (the URL following a
+ * in a split (the grid's next place) instead; `focusOnly` (the URL following a
* focus change made here) never opens anything. */
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
/** Back to the workspace home, where a new session is started. */
@@ -519,7 +519,7 @@ export function Workspace() {
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
- const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
+ const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
const laneParam = searchParams.get("lane");
// `?run=` — a live session picked in the sidebar; the location key makes a
// second pick of the same session a new request.
@@ -529,7 +529,7 @@ export function Workspace() {
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
- // A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
+ // A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
const splitOpen = navState?.split === true;
diff --git a/client/src/pages/__tests__/SessionDetail.nestedAgents.test.tsx b/client/src/pages/__tests__/SessionDetail.nestedAgents.test.tsx
index 0f4ca6b2..cae8b409 100644
--- a/client/src/pages/__tests__/SessionDetail.nestedAgents.test.tsx
+++ b/client/src/pages/__tests__/SessionDetail.nestedAgents.test.tsx
@@ -51,10 +51,12 @@ let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, br
// The header's Resume reads lanes and runs; this file is about the agent tree.
vi.mock("../../components/lanes/SessionResumeButton", () => ({
SessionResumeButton: () => null,
+ owningLane: () => null,
}));
vi.mock("../../lib/api", () => ({
api: {
+ lanes: { list: vi.fn(() => Promise.resolve({ lanes: [] })) },
sessions: {
get: vi.fn(() =>
Promise.resolve({
@@ -104,6 +106,7 @@ vi.mock("../../lib/api", () => ({
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: vi.fn(() => () => {}),
+ onConnection: vi.fn(() => () => {}),
},
}));
diff --git a/client/src/pages/__tests__/Settings.test.tsx b/client/src/pages/__tests__/Settings.test.tsx
index a4e44d22..2da66d7e 100644
--- a/client/src/pages/__tests__/Settings.test.tsx
+++ b/client/src/pages/__tests__/Settings.test.tsx
@@ -65,6 +65,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { redact_secrets: false, ignore_cwd: false },
})),
},
+ backup: {
+ get: vi.fn(async () => ({
+ dir: "",
+ hour: 2,
+ keep: 7,
+ running: false,
+ last: null,
+ files: [],
+ dir_error: null,
+ env_owned: { dir: false, hour: false, keep: false },
+ })),
+ },
},
},
};
diff --git a/client/src/pages/__tests__/SettingsHub.test.tsx b/client/src/pages/__tests__/SettingsHub.test.tsx
index 9caea02a..08ff5795 100644
--- a/client/src/pages/__tests__/SettingsHub.test.tsx
+++ b/client/src/pages/__tests__/SettingsHub.test.tsx
@@ -3,7 +3,8 @@
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to Appearance, a disabled tool has no page while an
- * installed, enabled catalog tool gets one, the mobile
+ * installed, enabled catalog tool gets one — as a tab on AI tools, not a nav
+ * item —, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
@@ -159,13 +160,27 @@ describe("SettingsHub", () => {
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
renderAt("/settings/agents/gemini");
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
+ const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
+ .getAllByRole("tab")
+ .map((a) => a.textContent);
+ expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]);
+ });
+
+ it("shows tool pages as tabs on AI tools, not as nav items", async () => {
+ renderAt("/settings/agents/codex");
+ expect(await screen.findByTestId("codex-config")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
- expect(links).toContain("Claude Code");
expect(links).not.toContain("Codex");
- expect(links).not.toContain("Qwen");
- expect(links).not.toContain("Aider");
+ expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true");
+ fireEvent.click(screen.getByRole("tab", { name: "All tools" }));
+ expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
+ expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
});
it("navigates from the mobile select", async () => {
diff --git a/client/src/pages/__tests__/Users.test.tsx b/client/src/pages/__tests__/Users.test.tsx
index 6cedf95d..00c9fcdc 100644
--- a/client/src/pages/__tests__/Users.test.tsx
+++ b/client/src/pages/__tests__/Users.test.tsx
@@ -66,7 +66,7 @@ function renderPage() {
beforeEach(() => {
for (const fn of Object.values(users)) fn.mockReset();
users.list.mockResolvedValue({
- users: [row(1, "boss", "admin"), row(2, "mia", "member")],
+ users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
});
users.audit.mockResolvedValue({ entries: [] });
});
@@ -76,7 +76,7 @@ describe("Users page", () => {
authState = { mode: "local" };
renderPage();
expect(await screen.findByTestId("home")).toBeTruthy();
- authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
+ authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
renderPage();
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
expect(users.list).not.toHaveBeenCalled();
@@ -94,7 +94,7 @@ describe("Users page", () => {
it("adds a user only with the admin's own password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({
- user: { id: 9, username: "neo", role: "member", disabled: false },
+ user: { id: 9, username: "neo", role: "developer", disabled: false },
});
const ue = userEvent.setup();
renderPage();
@@ -111,7 +111,7 @@ describe("Users page", () => {
await ue.click(confirm);
await waitFor(() =>
expect(users.create).toHaveBeenCalledWith(
- { username: "neo", password: "neo-pass-123", role: "member" },
+ { username: "neo", password: "neo-pass-123", role: "developer" },
"boss-pass"
)
);
diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx
index a8a3fd04..bf67300a 100644
--- a/client/src/pages/__tests__/Workspace.test.tsx
+++ b/client/src/pages/__tests__/Workspace.test.tsx
@@ -732,7 +732,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
- expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
+ expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
index e2de0a72..65f3ec86 100644
--- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
+++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
@@ -7189,6 +7189,35 @@ exports[`screen snapshots > Settings — about 1`] = `
+
+
+
+ Uninstall AgentHub
+
+
+ Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
+
+
+
+
+ Uninstall
+
+
+
Settings — about 1`] = `
- UI build v2.5.12 (rebuild client to match)
+ UI build v2.5.13 (rebuild client to match)
@@ -9066,6 +9095,205 @@ exit 0
+
+
+
+
+ Daily backup
+
+
+
+ A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
+
+
+
+
+
+ An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.
+
+
+
+
+
+
+
+ 02:00
+
+
+
+
+
+
+
+
+
+
+ Save
+
+
+
+
+ Back up now
+
+
+
+ Backups are off.
+
+
+ To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start.
+
+
+
+ {
}),
save: r({ ok: true }),
},
+ backup: {
+ get: r({
+ dir: "",
+ hour: 2,
+ keep: 7,
+ running: false,
+ last: null,
+ files: [],
+ dir_error: null,
+ env_owned: { dir: false, hour: false, keep: false },
+ }),
+ },
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
diff --git a/docs/API.md b/docs/API.md
index 51cfb9eb..991e724f 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -84,6 +84,8 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` |
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
+| `GET /api/me/prefs` | The caller's OWN UI preferences: `{prefs: {key: string}, onboardedAt}` — row `u` in server mode (by the cookie, `401` without one), `local` in local mode; the request never names an account |
+| `PUT /api/me/prefs` `{prefs: {key: string \| null}}` | Merges into that row (`null` removes a key) and answers like the GET. Only the keys in `PREF_KEYS` (`server/routes/me.js`; today `agenthub.chatUi`, `"1"` = sessions open as a chat), each a string ≤ 8 KB, else `400 EBADPREFS`; same-origin |
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
@@ -341,6 +343,8 @@ Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every r
Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names a session a live pane already holds — two `--resume`s of one session would be two agents writing one transcript. `error.run_id` names that pane; open it instead. A fork (`forkSession: true`) makes a new session and is allowed. A `resumeSessionId` on a catalog tool (anything but Claude/Codex) is `400 EUNSUPPORTED`, since it would silently start fresh.
+Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `/run-prompts/.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
+
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action
@@ -1348,7 +1352,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
-- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `/workspace-context//` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
+- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `/workspace-context//` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `/projects//memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
@@ -1829,6 +1833,14 @@ POST /api/settings/quit
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.
+#### Uninstall AgentHub
+
+```http
+POST /api/settings/uninstall
+```
+
+Settings → System's **Uninstall AgentHub** (same-origin only). Body `{ "confirm": true }` — anything else is `400 ECONFIRM`; in server mode `403 ESERVERMODE` (a shared dashboard is removed with `agenthub uninstall` on the host). Spawns `scripts/uninstall.js --yes` detached and answers `202 { ok: true, log }` at once; the worker then stops this server and removes everything `agenthub uninstall --yes` does (see [CLI.md](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-.log`, the only record left afterwards.
+
---
### Tools
@@ -2661,6 +2673,18 @@ A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
+
+### Daily database backup
+
+```
+GET /api/settings/backup { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
+PUT /api/settings/backup { dir?, hour?, keep? } → the same state; dir "" turns backups off
+POST /api/settings/backup/run 202 — started (or already running); poll GET while `running`
+```
+
+Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
+
+Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
---
## WebSocket API
@@ -2841,7 +2865,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
-A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub workspace shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
+A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
diff --git a/docs/CLI.md b/docs/CLI.md
index 8d6fb9d6..9601b501 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -101,6 +101,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
+| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index ac2cc246..b2714f67 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -1004,6 +1004,10 @@ function validateSession(session) {
## Backup Strategies
+### Built-in daily backup (Settings → Data → Daily backup)
+
+Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there once a day at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
+
### Online Backup (Recommended)
```sql
@@ -1025,32 +1029,6 @@ cp /var/lib/agenthub/dashboard.db /backups/dashboard_$(date +%Y%m%d).db
systemctl start agenthub
```
-### Backup Schedule
-
-```mermaid
-graph TB
- subgraph "Automated Backups"
- Daily[Daily Backup 2 AM UTC]
- Weekly[Weekly Backup Sunday 2 AM]
- Monthly[Monthly Backup 1st of month]
- end
-
- subgraph "Retention"
- Daily --> R7[Keep 7 days]
- Weekly --> R4[Keep 4 weeks]
- Monthly --> R12[Keep 12 months]
- end
-
- subgraph "Storage"
- R7 --> Local[Local Disk]
- R4 --> S3[AWS S3]
- R12 --> Glacier[AWS Glacier]
- end
-
- style Daily fill:#3B82F6
- style S3 fill:#FF9900
-```
-
---
## Summary
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index 91334ef2..1f9b2395 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -89,26 +89,19 @@ graph TB
```mermaid
sequenceDiagram
participant Claude as Claude Code
- participant HookScript as Hook Script (Python)
participant Handler as hook-handler.js
participant Server as Dashboard Server
participant DB as SQLite
participant WS as WebSocket
-
- Claude->>HookScript: Execute (stdin: JSON)
- HookScript->>HookScript: Read stdin
- HookScript->>Handler: exec node hook-handler.js
- Handler->>Handler: Parse JSON
- Handler->>Server: HTTP POST /hooks/{type}
- Server->>DB: Insert/Update data
- DB-->>Server: Success
- Server->>WS: Broadcast event
- WS-->>Server: Sent to clients
- Server-->>Handler: 200 OK
- Handler-->>HookScript: exit 0
- HookScript-->>Claude: exit 0 (non-blocking)
-
- Note over Claude: Continues execution without waiting
+
+ Claude->>Handler: node hook-handler.js (stdin: JSON)
+ Handler->>Handler: Wrap stdin as {hook_type, data}
+ Handler->>Server: HTTP POST /api/hooks/event (one per dashboard database)
+ Handler-->>Claude: exit 0 once the body is sent (never waits for the reply)
+ Server->>DB: Insert/Update (one transaction)
+ Server->>WS: Broadcast session_updated / agent_updated / new_event …
+
+ Note over Claude: Continues as soon as the handler exits
```
> **Security:** the hook handler POSTs to the loopback dashboard (`127.0.0.1:`).
@@ -130,9 +123,9 @@ graph TB
end
subgraph "Implementation"
- Timeout[5s Timeout]
- ErrorLog[Silent Error Logging]
- Async[Async HTTP POST]
+ Timeout[2 s request timeout 2.5 s hard exit]
+ ErrorLog[Always exit 0 errors are dropped]
+ Async[Fire-and-forget POST no wait for the reply]
JSON[JSON Serialization]
end
@@ -189,79 +182,58 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
-This copies hook scripts from `scripts/hooks/` to `.githooks/`:
+For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
-```mermaid
-graph LR
- Source[scripts/hooks/*.py] -->|Copy| Target[.githooks/*.py]
- Handler[scripts/hook-handler.js] -->|Reference| Target
-
- Target -->|chmod +x| Executable[Executable Hooks]
-
- style Source fill:#3B82F6
- style Executable fill:#10B981
-```
-
-### Manual Installation
-
-```bash
-#!/bin/bash
-# scripts/install-hooks.js
-
-HOOKS_DIR=".githooks"
-SOURCE_DIR="scripts/hooks"
-
-# Create hooks directory
-mkdir -p "$HOOKS_DIR"
-
-# Copy hook scripts
-cp "$SOURCE_DIR/session-start.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/pre-tool-use.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/post-tool-use.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/stop.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/subagent-stop.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/notification.py" "$HOOKS_DIR/"
-cp "$SOURCE_DIR/session-end.py" "$HOOKS_DIR/"
-
-# Make executable
-chmod +x "$HOOKS_DIR"/*.py
-
-echo "Hooks installed in .githooks/"
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "*",
+ "hooks": [
+ { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "hooks": [
+ { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" SessionStart" }
+ ]
+ }
+ ]
+ }
+}
```
### Verification
```bash
-# Check hook files exist
-ls -la .githooks/
-
-# Expected output:
-# session-start.py
-# pre-tool-use.py
-# post-tool-use.py
-# stop.py
-# subagent-stop.py
-# notification.py
-# session-end.py
+# One line per hook type with an AgentHub entry in settings.json
+# (with the plugin installed the hooks live in its plugin.json, so this prints nothing)
+grep -o 'hook-handler.js[^ ]* [A-Za-z]*' ~/.claude/settings.json
```
+The Settings page reports the same (`source: "plugin"` when the plugin supplies them), and `/agenthub-doctor` reports missing or duplicated entries.
+
---
## Hook Types
### 1. SessionStart
-Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
+Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `fork`, and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
**Payload Example:**
```json
{
- "type": "sessionStart",
- "sessionId": "sess_abc123",
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "hook_event_name": "SessionStart",
"source": "startup",
- "model": "claude-sonnet-4",
- "timestamp": "2024-03-18T12:00:00Z"
+ "model": "claude-opus-5-5"
}
```
@@ -281,10 +253,12 @@ Triggered the moment the user hits enter on a prompt — fires *before* Claude d
```json
{
- "type": "userPromptSubmit",
- "sessionId": "sess_abc123",
- "prompt": "Refactor this function...",
- "timestamp": "2024-03-18T12:00:30Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "permission_mode": "default",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "Refactor this function..."
}
```
@@ -302,14 +276,19 @@ Triggered before a tool executes.
```json
{
- "type": "preToolUse",
- "sessionId": "sess_abc123",
- "agentId": "agent_main_001",
- "toolName": "bash",
- "timestamp": "2024-03-18T12:01:00Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "permission_mode": "default",
+ "hook_event_name": "PreToolUse",
+ "tool_name": "Bash",
+ "tool_input": { "command": "npm test", "description": "Run the tests" },
+ "tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv"
}
```
+A tool call made inside a subagent also carries `agent_id` and `agent_type`.
+
**Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; Claude can only call a tool after fresh user input)
- Set agent to `working`, set `current_tool`
@@ -326,40 +305,36 @@ Triggered after a tool completes execution.
```json
{
- "type": "postToolUse",
- "sessionId": "sess_abc123",
- "agentId": "agent_main_001",
- "toolName": "bash",
- "durationMs": 1234,
- "success": true,
- "inputTokens": 1500,
- "outputTokens": 800,
- "timestamp": "2024-03-18T12:01:01.234Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "permission_mode": "default",
+ "hook_event_name": "PostToolUse",
+ "tool_name": "Bash",
+ "tool_input": { "command": "npm test", "description": "Run the tests" },
+ "tool_response": { "stdout": "…", "stderr": "", "interrupted": false },
+ "tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv",
+ "duration_ms": 1234
}
```
+The payload carries no token counts: tokens are read from the transcript (see [Transcript-derived sync](#transcript-derived-sync)).
+
**Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; covers permission-prompt approval mid-tool)
- Clear `current_tool` on agent (agent stays `working`)
-- Update agent token counts via shared transcript cache
-- Calculate and update cost
-- Rollup cost to session
+- Sync the session's tokens from its transcript, like every event that carries a `transcript_path`
-**Cost Calculation Flow:**
+**Token and cost flow:** no cost is stored. Tokens land in `token_usage`, one row per session and model bucket, and cost is computed when it is read (`calculateCost`, e.g. `GET /api/pricing/cost`) from those rows and the editable `model_pricing` table.
```mermaid
graph TB
- PostHook[PostToolUse Hook] --> Tokens{Has Token Counts?}
- Tokens -->|Yes| Pricing[Fetch Pricing Rule]
- Tokens -->|No| Skip[Skip Cost Update]
-
- Pricing --> Calculate[Cost = input/1M * input_price + output/1M * output_price]
- Calculate --> UpdateAgent[Update agent.cost]
- UpdateAgent --> Rollup[Rollup to session.total_cost]
- Rollup --> Broadcast[Broadcast Updates]
-
- style Calculate fill:#10B981
- style Broadcast fill:#F59E0B
+ Hook[Any hook with transcript_path] --> Cache[TranscriptCache reads only the new bytes]
+ Cache --> Usage[token_usage per session + model bucket]
+ Usage --> Read[Cost computed on read token_usage × model_pricing]
+
+ style Usage fill:#10B981
+ style Read fill:#3B82F6
```
---
@@ -372,10 +347,13 @@ Triggered when Claude finishes a turn (NOT when the session is closed).
```json
{
- "type": "stop",
- "sessionId": "sess_abc123",
- "stop_reason": "end_turn",
- "timestamp": "2024-03-18T12:05:00Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "permission_mode": "default",
+ "hook_event_name": "Stop",
+ "stop_hook_active": false,
+ "last_assistant_message": "Done — the tests pass."
}
```
@@ -396,11 +374,16 @@ Triggered when a sub-agent (explore, task, etc.) completes.
```json
{
- "type": "subagentStop",
- "sessionId": "sess_abc123",
- "agentId": "agent_explore_002",
- "agentType": "explore",
- "timestamp": "2024-03-18T12:03:00Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "permission_mode": "default",
+ "hook_event_name": "SubagentStop",
+ "agent_id": "a73b0d3904535a49c",
+ "agent_type": "Explore",
+ "agent_transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01/subagents/agent-a73b0d3904535a49c.jsonl",
+ "stop_hook_active": false,
+ "last_assistant_message": "Found 3 call sites…"
}
```
@@ -422,14 +405,17 @@ Triggered when Claude Code sends a system notification.
```json
{
- "type": "notification",
- "sessionId": "sess_abc123",
- "notificationType": "backgroundTaskComplete",
- "message": "Explore agent completed successfully",
- "timestamp": "2024-03-18T12:03:00Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "hook_event_name": "Notification",
+ "message": "Claude needs your permission to use Bash",
+ "notification_type": "permission_prompt"
}
```
+Other `notification_type` values seen: `idle_prompt`, `agent_completed`, `auth_success`.
+
**Purpose:**
- Log the event for the activity feed
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
@@ -446,12 +432,16 @@ Triggered when a Claude Code session ends.
```json
{
- "type": "sessionEnd",
- "sessionId": "sess_abc123",
- "timestamp": "2024-03-18T14:30:00Z"
+ "session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
+ "transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
+ "cwd": "/home/you/repo",
+ "hook_event_name": "SessionEnd",
+ "reason": "prompt_input_exit"
}
```
+`reason` is one of `prompt_input_exit`, `clear`, `resume`, `other`.
+
**Purpose:**
- Drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together) on the session and any agents that still have it
- Mark all agents and the session as `completed` — **unless the session is in `error` AND that error is still unrecovered at the transcript tail** (`isErrorAtTail`: the latest API error has no successful turn after it), in which case `error` is preserved. A transient error the CLI retried past (successful assistant turns after the last error) finalizes as `completed` instead of freezing in a stale `error`
@@ -467,120 +457,29 @@ Triggered when a Claude Code session ends.
```mermaid
graph TB
- CLI[CLI Args] --> Parse[Parse Hook Type]
- Stdin[stdin] --> ReadJSON[Read JSON]
-
- Parse --> HookType{Hook Type?}
- ReadJSON --> Payload[Event Payload]
-
- HookType -->|session-start| Endpoint1[POST /hooks/session-start]
- HookType -->|pre-tool-use| Endpoint2[POST /hooks/pre-tool-use]
- HookType -->|post-tool-use| Endpoint3[POST /hooks/post-tool-use]
- HookType -->|stop| Endpoint4[POST /hooks/stop]
- HookType -->|subagent-stop| Endpoint5[POST /hooks/subagent-stop]
- HookType -->|notification| Endpoint6[POST /hooks/notification]
- HookType -->|session-end| Endpoint7[POST /hooks/session-end]
-
- Payload --> Endpoint1
- Payload --> Endpoint2
- Payload --> Endpoint3
- Payload --> Endpoint4
- Payload --> Endpoint5
- Payload --> Endpoint6
- Payload --> Endpoint7
-
- Endpoint1 --> HTTP[HTTP POST]
- Endpoint2 --> HTTP
- Endpoint3 --> HTTP
- Endpoint4 --> HTTP
- Endpoint5 --> HTTP
- Endpoint6 --> HTTP
- Endpoint7 --> HTTP
-
- HTTP --> Response{Success?}
- Response -->|Yes| Exit0[exit 0]
- Response -->|No| Exit1[exit 1]
-
- style HTTP fill:#10B981
- style Exit0 fill:#10B981
- style Exit1 fill:#EF4444
+ Args[argv: HookType + optional tool] --> Wrap
+ Stdin[stdin JSON] --> Wrap[Envelope hook_type, data, tool?, run_id?]
+ Wrap --> Ports[resolveHookIngestPorts one port per database]
+ Ports --> P1[POST /api/hooks/event dashboard A]
+ Ports --> P2[POST /api/hooks/event dashboard B]
+ P1 --> Flushed[Request body sent]
+ P2 --> Flushed
+ Flushed --> Exit[exit 0]
+ Guard[2.5 s safety timer] --> Exit
+
+ style Exit fill:#10B981
```
-### Implementation
+### What it does
-```javascript
-#!/usr/bin/env node
-// scripts/hook-handler.js
+`scripts/hook-handler.js` (about 120 lines) is the whole forwarder:
-const http = require('http');
-const fs = require('fs');
+- **Envelope.** It POSTs `{hook_type, data, tool?, run_id?}` to `/api/hooks/event`. `hook_type` is its first argument; `data` is the parsed stdin, or `{raw: ""}` when stdin is not JSON. `tool` is `codex` or `antigravity` when those tools' own hook scripts pass it as the second argument (Claude Code passes none). `run_id` is `AGENTHUB_RUN_ID`, set in every pane the dashboard starts.
+- **Fire-and-forget.** It exits once the request body is sent, without waiting for the dashboard's reply, so a busy or wedged dashboard never holds Claude Code at "running hooks".
+- **Never fails.** A refused connection is skipped, a request that takes over 2 s is dropped, and a 2.5 s timer exits regardless. The exit code is always 0.
-const HOOK_TYPE = process.argv[2];
-const SERVER_URL = 'http://localhost:4820';
-const TIMEOUT = 5000; // 5s timeout
-
-// Read JSON from stdin
-let inputData = '';
-process.stdin.setEncoding('utf8');
-process.stdin.on('data', (chunk) => inputData += chunk);
-
-process.stdin.on('end', () => {
- try {
- const payload = JSON.parse(inputData);
- sendToServer(HOOK_TYPE, payload);
- } catch (err) {
- console.error('[hook-handler] JSON parse error:', err);
- process.exit(1);
- }
-});
-
-function sendToServer(hookType, payload) {
- const postData = JSON.stringify(payload);
-
- const options = {
- hostname: 'localhost',
- port: 4820,
- path: `/hooks/${hookType}`,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(postData)
- },
- timeout: TIMEOUT
- };
-
- const req = http.request(options, (res) => {
- let responseData = '';
- res.on('data', (chunk) => responseData += chunk);
- res.on('end', () => {
- if (res.statusCode === 200) {
- process.exit(0);
- } else {
- console.error(`[hook-handler] Server error: ${res.statusCode}`);
- process.exit(1);
- }
- });
- });
-
- req.on('error', (err) => {
- console.error('[hook-handler] Request error:', err);
- process.exit(1);
- });
-
- req.on('timeout', () => {
- console.error('[hook-handler] Request timeout');
- req.destroy();
- process.exit(1);
- });
-
- req.write(postData);
- req.end();
-}
-```
-
-> **Port resolution & fan-out.** The snippet above shows a single fixed `4820`
-> for clarity. The real `scripts/hook-handler.js` resolves hook targets at
-> runtime via `server/lib/server-info.js`:
+> **Port resolution & fan-out.** The handler resolves its targets at runtime
+> via `server/lib/server-info.js`:
>
> 1. If `CLAUDE_DASHBOARD_PORT` is set in the environment, the handler treats
> it as an explicit operator override and POSTs to that single port —
@@ -606,38 +505,13 @@ function sendToServer(hookType, payload) {
### Server-Side Hook Processing
-```javascript
-// server/routes/hooks.js
+`POST /api/hooks/event` (`server/routes/hooks.js`, `ingestHook`):
-router.post('/session-start', (req, res) => {
- try {
- const { sessionId, model, agentId, agentType } = req.body;
-
- // Upsert session
- let session = stmts.findSession.get(sessionId);
- if (!session) {
- stmts.createSession.run(sessionId, model);
- session = stmts.findSession.get(sessionId);
- broadcast({ type: 'session.created', data: session });
- }
-
- // Create main agent
- if (!stmts.findAgent.get(agentId)) {
- stmts.createAgent.run(agentId, sessionId, agentType);
- const agent = stmts.findAgent.get(agentId);
- broadcast({ type: 'agent.created', data: agent });
- }
-
- // Touch session (update updated_at)
- stmts.touchSession.run(sessionId);
-
- res.json({ success: true });
- } catch (err) {
- console.error('session-start error:', err);
- res.json({ success: false, error: err.message });
- }
-});
-```
+1. **Checks the body** (400 when it is not a hook), maps a Codex or Antigravity payload onto Claude's shape, and answers `{ok: true, ignored: true}` for a hook from an ignored folder and `{ok: true, duplicate: true}` for the same event posted twice within 10 s.
+2. **Reads the transcript first** (incremental `TranscriptCache`), so the write transaction never waits on a large file.
+3. **Runs `processEvent`** — session and agent upserts, the status transitions described under [Hook Types](#hook-types), the event row (redacted when Redact secrets is on) and the transcript-derived sync below — then binds the session to the workspace pane that ran it (`run_id`).
+4. **Broadcasts** the changes over WebSocket: `session_created`, `session_updated`, `agent_created`, `agent_updated`, `new_event`, `workflow_upserted`.
+5. **After the reply**, evaluates event-driven alert rules, and a `SubagentStop` starts the subagent JSONL scan.
### Event Processing Pipeline
@@ -691,64 +565,17 @@ Fail-safe guards: the probe reports "no answer" (nothing changes) on Windows, in
### Error Handling Strategy
-```mermaid
-graph TB
- Error[Error Occurs] --> Type{Error Type?}
-
- Type -->|Network Error| Retry[Retry Once]
- Type -->|Timeout| Log1[Log + Exit 1]
- Type -->|Parse Error| Log2[Log + Exit 1]
- Type -->|Server Error| Log3[Log + Exit 1]
-
- Retry --> Success{Success?}
- Success -->|Yes| Exit0[Exit 0]
- Success -->|No| Exit1[Exit 1]
-
- Log1 --> Exit1
- Log2 --> Exit1
- Log3 --> Exit1
-
- style Exit0 fill:#10B981
- style Exit1 fill:#EF4444
-```
+The handler has no error path that reaches Claude Code:
-### Hook Script Error Handling
+| What goes wrong | What the handler does |
+|-----------------|-----------------------|
+| stdin is not JSON | Forwards it as `data: {raw: ""}` |
+| No dashboard listening (`ECONNREFUSED`) | Skips that target |
+| A dashboard does not take the request within 2 s | Drops it and moves on |
+| Anything hangs | The 2.5 s safety timer exits |
+| The dashboard rejects or fails the event | Not seen — the handler never reads the reply |
-```python
-#!/usr/bin/env python3
-# .githooks/session-start.py
-
-import sys
-import json
-import subprocess
-import logging
-
-logging.basicConfig(
- filename='.githooks/hooks.log',
- level=logging.ERROR,
- format='%(asctime)s - %(levelname)s - %(message)s'
-)
-
-try:
- data = json.load(sys.stdin)
-
- result = subprocess.run(
- ['node', 'scripts/hook-handler.js', 'session-start'],
- input=json.dumps(data),
- text=True,
- timeout=5,
- capture_output=True
- )
-
- if result.returncode != 0:
- logging.error(f'Hook handler failed: {result.stderr}')
-
-except Exception as e:
- logging.error(f'Hook error: {str(e)}')
-
-# Always exit 0 to avoid blocking Claude Code
-sys.exit(0)
-```
+It always exits 0, so Claude Code never shows a hook error and never waits on AgentHub. Events sent while no dashboard was running are not replayed; the transcript sync and the [liveness reap](#missed-sessionend-dashboard-down--liveness-reap) bring the session up to date later.
---
@@ -756,55 +583,14 @@ sys.exit(0)
### Hook Execution Time
-```mermaid
-graph TB
- subgraph "Hook Execution Breakdown"
- Python[Python Script ~10ms]
- Handler[Node Handler ~20ms]
- HTTP[HTTP POST ~30ms]
- DB[Database Write ~5ms]
- WS[WebSocket Broadcast ~5ms]
- end
-
- Total[Total: ~70ms]
-
- Python --> Handler
- Handler --> HTTP
- HTTP --> DB
- DB --> WS
- WS --> Total
-
- style Total fill:#10B981
-```
+Claude Code waits only for the handler process: Node start-up, reading stdin, sending the request. Measured on a 4-core desktop: **60–90 ms per hook**, almost all of it Node start-up. The dashboard's work happens after the handler has exited — ingesting a hook took ~2 ms (p50) against a 1.25 GB database at 10 hooks/s, and stayed under 40 ms (p99) at 100 hooks/s with 10 browsers open.
-**Performance Targets:**
+### What keeps it fast
-| Phase | Target | Actual |
-|-------|--------|--------|
-| Hook script | < 20ms | ~10ms |
-| Handler | < 30ms | ~20ms |
-| HTTP POST | < 50ms | ~30ms |
-| Database | < 10ms | ~5ms |
-| **Total** | **< 100ms** | **~70ms** |
-
-### Optimization Techniques
-
-```mermaid
-graph TB
- subgraph "Optimizations"
- Async[Async HTTP Don't wait for response]
- Batch[Batch Updates Transaction batching]
- Index[Database Indexes Fast lookups]
- Pool[Connection Pooling Reuse connections]
- end
-
- Async --> Faster[Faster Hook Execution]
- Batch --> Faster
- Index --> Faster
- Pool --> Faster
-
- style Faster fill:#10B981
-```
+- **Fire-and-forget POST** — no wait for the dashboard's reply.
+- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
+- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
+- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
---
@@ -812,49 +598,27 @@ graph TB
### Manual Testing
+Pipe a payload into the handler. It posts to every running dashboard, so use a session id you will recognise:
+
```bash
-# Test session-start hook
-echo '{"type":"sessionStart","sessionId":"test_001","model":"claude-sonnet-4","agentId":"agent_test","agentType":"general-purpose"}' | \
- python3 .githooks/session-start.py
+echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
+ | node scripts/hook-handler.js SessionStart
-# Test pre-tool-use hook
-echo '{"type":"preToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash"}' | \
- python3 .githooks/pre-tool-use.py
+echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"tool_use_id":"toolu_test_1"}' \
+ | node scripts/hook-handler.js PreToolUse
+```
-# Test post-tool-use hook
-echo '{"type":"postToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash","durationMs":100,"success":true,"inputTokens":1000,"outputTokens":500}' | \
- python3 .githooks/post-tool-use.py
+The handler prints nothing. To see the dashboard's answer, POST the envelope yourself:
+
+```bash
+curl -s -X POST http://localhost:4820/api/hooks/event \
+ -H 'Content-Type: application/json' \
+ -d '{"hook_type":"Stop","data":{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"Stop"}}'
```
### Integration Testing
-```javascript
-// server/__tests__/hooks.test.js
-import { test } from 'node:test';
-import assert from 'node:assert';
-
-test('session-start hook creates session', async () => {
- const payload = {
- sessionId: 'test_session',
- model: 'claude-sonnet-4',
- agentId: 'test_agent',
- agentType: 'general-purpose'
- };
-
- const response = await fetch('http://localhost:4820/hooks/session-start', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- });
-
- const data = await response.json();
- assert.strictEqual(data.success, true);
-
- // Verify session exists
- const session = await fetch('http://localhost:4820/api/sessions/test_session');
- assert.strictEqual(session.status, 200);
-});
-```
+The server suite drives `POST /api/hooks/event` end to end — for example `server/__tests__/api.test.js` and `server/__tests__/awaiting-subagent-guard.test.js`; Codex and Antigravity hooks have `codex-hook-route.test.js` and `antigravity-hook-route.test.js`. Run them with `npm run test:server`, never a bare `node --test` from a workspace pane (some suites start and kill tmux sessions).
---
@@ -864,29 +628,23 @@ test('session-start hook creates session', async () => {
| Issue | Symptoms | Solution |
|-------|----------|----------|
-| Hooks not executing | No data in dashboard | Check `.githooks/` exists and scripts are executable |
-| Timeout errors | Hooks take >5s | Check server is running, reduce timeout |
-| Parse errors | JSON parse failed | Validate hook payload format |
-| Permission denied | Hook script won't run | `chmod +x .githooks/*.py` |
-| Server connection refused | HTTP POST fails | Start dashboard server (`npm start`) |
+| Hooks not installed | No data in dashboard | `grep hook-handler.js ~/.claude/settings.json` (or check the plugin is enabled), then `npm run install-hooks` or `/agenthub-doctor` |
+| Hooks installed twice | Every event doubled | Plugin and settings.json both carry them — see [Installation Script](#installation-script) |
+| Dashboard not running | Hooks run, nothing arrives | `agenthub start` (or `npm start` from a checkout); events sent meanwhile are not replayed, the transcript sync fills the session in |
+| Dashboard on another port | Nothing arrives | The handler finds servers through `~/.claude/.agenthub.json`; set `CLAUDE_DASHBOARD_PORT` to force one |
+| Hooks installed from a container | `MODULE_NOT_FOUND` in Claude Code | Install on the host — see the container note under [Installation Script](#installation-script) |
-### Debug Mode
+### Seeing what a hook sends
-```bash
-# Enable hook logging
-export DASHBOARD_DEBUG=1
-
-# Run hook manually with verbose output
-python3 -u .githooks/session-start.py < test-payload.json
-```
+The handler logs nothing by design. POST the envelope yourself (see [Manual Testing](#manual-testing)) and read the reply.
### Health Check
```bash
# Check server is running
-curl http://localhost:4820/api/sessions
+curl http://localhost:4820/api/health
-# Expected: {"sessions": [...]}
+# Expected: {"status":"ok","version":"…","timestamp":"…"}
```
---
diff --git a/docs/LANES.md b/docs/LANES.md
index b5ed15d7..61630791 100644
--- a/docs/LANES.md
+++ b/docs/LANES.md
@@ -391,9 +391,10 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
-- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=&run=`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=&session=` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
+- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=&run=`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=&session=` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
+- **Chat view of a live pane** — with **Settings → Appearance → Chat UI** on (`ChatUiSettings.tsx`, one choice for every pane, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`, `client/src/lib/chatUi.ts`, so each user of a shared dashboard keeps their own on any browser), a Claude or Codex pane whose session is known shows a chat instead of its terminal (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls and thinking folded into one line named by what it did ("Ran 3 commands, edited a file ›", `workLabel`) that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. A permission prompt or a menu still needs the terminal — turn Chat UI off to answer it. The ended-session replay and the Analyst page's conversation use the same layout.
- **The workspace home** — `/workspace?lane=` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=`, so CLAUDE CODE makes `/.claude/worktrees/` on branch `worktree-`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=&run=` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
@@ -407,9 +408,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
-- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-` (its `run_id`), slots 1–9 are `agenthub-lane--`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
+- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-` (its `run_id`), slots 1–9 are `agenthub-lane--`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
-- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
+- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=`.
@@ -693,7 +694,7 @@ reads the workspace's git surface in five modes:
user presses Enter in the pane; with no pane of the workspace open the box
says so and keeps the text.
In the file editor, a text selection shows Cursor's **Add to Chat** chip
- above it, which pastes `@#L-` (Claude Code's line-range
+ at the end of its first line (inline, covering no code), which pastes `@#L-` (Claude Code's line-range
mention; `#L` for one line — without the `L` Claude Code attaches the
whole file) the same way. Other tools (Codex, …) get the same text as a
plain hint.
@@ -1809,7 +1810,7 @@ agenthub stage [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
-A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool ` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as ``), plus **Custom…**, and starts on the tool's default model when one is set.
+A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool ` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as ``), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md
index 1965dd97..2cd6b030 100644
--- a/docs/OVERVIEW.md
+++ b/docs/OVERVIEW.md
@@ -61,7 +61,7 @@ Internal build — all rights reserved.
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
- Instructions, Memory, Context files (picked with + or dragged in from the OS)
+ Instructions, Memory, Context files (picked with + or dragged in from the OS; each is a copy taken at upload, so edit the original and add it again — a Folder is read live)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index bd5d58bc..1967a8cb 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -432,6 +432,12 @@ page warns about it.
### Uninstalling
+Run `agenthub uninstall --yes` (or Settings → System → **Uninstall AgentHub**):
+it does everything below, and more, in the right order — see
+[CLI.md](CLI.md) for the exact list and what it keeps. Without `--yes` it only
+lists what it would remove. The rest of this section is what that command
+automates, for a machine where the CLI no longer runs.
+
`claude plugin uninstall agenthub@agenthub` only removes
the plugin from the enabled list — verified against a real install/uninstall,
not assumed. It does **not**:
diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md
index bbdaa651..186f7902 100644
--- a/docs/SERVER-MODE.md
+++ b/docs/SERVER-MODE.md
@@ -13,7 +13,7 @@ Local mode does not change at all. The login code stays dormant until
`AGENTHUB_MODE=server` is set.
> **What server mode separates, and what it does not.** In the dashboard and
-> its API, each member sees only their own workspaces and the sessions,
+> its API, each developer sees only their own workspaces and the sessions,
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
> Every terminal pane, though, still runs as the one Linux account that runs
> the dashboard. An agent in a pane can therefore read every user's folders,
@@ -36,8 +36,8 @@ agenthub server setup --hostname agenthub.example.com --admin boss
systemctl --user restart agenthub # or: agenthub start
# 3. Add the other people
-agenthub users add alice # member
-agenthub users add carol --role analyst # read-only
+agenthub users add alice # developer
+agenthub users add carol --role analyst # reads everyone, works in their own
```
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
@@ -86,15 +86,35 @@ The login cookie gets `Secure` when the request came over HTTPS
| Role | Can |
| --- | --- |
| `admin` | Everything, including the host's own configuration and every user's data |
-| `member` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
-| `analyst` | Reads everything (usage, prompts, every user's sessions); no terminal, no write |
+| `analyst` | Reads everything (usage, prompts, every user's sessions), and works in their OWN workspaces like a developer. Never changes another user's rows or the host's configuration |
+| `developer` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
+
+`developer` was called `member` before; a database with members is migrated on
+start (`server/db.js`), and `member` is no longer accepted.
"The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
-`/budgets`, `/webhooks`, `/remote-sources` and `/reports`
-(`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A member who tries one gets
-`403 EADMIN`. An analyst who tries any write gets `403 EREADONLY`.
+`/budgets`, `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
+`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A developer
+or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only an admin
+can do this". An analyst's other writes are scoped like a developer's: one
+naming another user's row gets `404 ENOTFOUND` (`memberId` in
+`server/lib/scope.js` limits every non-GET request of an analyst to their own
+rows).
+
+The UI hides what a role cannot use (`usePermissions` in
+`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
+no Data, Pricing, AI proxy or per-tool pages, no scheduled reports or git
+credentials, no start-at-login/icon/Quit/Uninstall on System, no alert
+rules or channels, no install/update/enable on AI tools, no pet upload or
+delete, and no update check. An analyst's Workspace page and sidebar list only
+their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
+Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
+hidden for a session that is not theirs. An analyst's alert feed
+(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
+sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
+only theirs.
## What each user sees
@@ -104,34 +124,34 @@ Workspaces made before server mode, or by the host account, have none.
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
snapshots and workflow runs belong to that session.
-- A **member** sees only rows of their own workspaces, everywhere: lists,
+- A **developer** sees only rows of their own workspaces, everywhere: lists,
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
WebSocket updates and terminals. A request naming someone else's row (in the
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
that does not exist (`scopeGuard`, `server/lib/scope.js`). Sessions with no
workspace, such as ones started in a terminal outside AgentHub, are not shown
- to members.
+ to developers.
- An **admin** or **analyst** sees everything. Each time one of them opens a
- session a member owns (its page, transcript, events or cost), the read is
+ session a developer owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
Users page in the sidebar shows it, with the audit log under it.
-A member also cannot open the host's own configuration, or any view across all
+A developer also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
imports, data export, alert rules and `/api/users`. Those return
-`403 EADMIN`, and Settings hides them for members.
+`403 EADMIN`, and Settings hides them.
-### Keep members' folders apart
+### Keep developers' folders apart
```bash
# in ~/.config/agenthub/settings.env
AGENTHUB_USERS_ROOT=/srv/agenthub/users
```
-With this set, a member can create a workspace only inside
+With this set, a developer or analyst can create a workspace only inside
`/srv/agenthub/users/`; any other folder gets `403 EFOLDER`. A folder
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
@@ -151,10 +171,10 @@ apart, not agents.
`403 EBADORIGIN`. This is on top of the `SameSite=Strict` cookie, which a
sibling subdomain would still satisfy.
- WebSockets need a login, and an `Origin` that is the request's own allowed
- Host. A terminal (`/ws-pty/*`) is refused to an analyst and to anyone but the
- owner of the pane's workspace. Open sockets are re-checked: `/ws` every 30 s,
+ Host. A terminal (`/ws-pty/*`) is refused to anyone but an admin and the
+ owner of the pane's workspace (an analyst included). Open sockets are re-checked: `/ws` every 30 s,
terminals every 10 s (`AGENTHUB_PTY_RECHECK_MS`). A logout, a disable, a
- password change, or a demotion to analyst closes them.
+ password change, or losing the pane's workspace closes them.
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
change and disabling the user each end it.
@@ -170,12 +190,12 @@ apart, not agents.
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
A workspace with no owner has no such user. Give old workspaces an owner with
`agenthub users assign `.
-- Named locks (`agenthub lock …`) are per user: a member's lock names live in
+- Named locks (`agenthub lock …`) are per user: a developer's lock names live in
their own namespace, so one user's agents never see or take another's.
- Browser push subscriptions belong to the user who made them. A
notification goes only to that user's own browsers, never to the host's
desktop.
-- A member cannot create a session by hand (`POST /api/sessions` → `403`), and
+- A developer or analyst cannot create a session by hand (`POST /api/sessions` → `403`), and
cannot change a workspace's pane or session columns: `PATCH` accepts only
`title`, `pipeline`, `notes`, `links` and `branch` (`403 EFIELD`).
Pet uploads are admin-only.
@@ -196,13 +216,13 @@ The same from the host's shell:
```bash
agenthub users list
-agenthub users add [--role admin|analyst|member]
+agenthub users add [--role admin|analyst|developer]
agenthub users passwd # ends that user's logins
agenthub users role
agenthub users disable # ends that user's logins; users are never deleted
agenthub users enable
agenthub users assign # give a pre-server-mode workspace an owner
-agenthub users audit [--limit N] # who read which member's session
+agenthub users audit [--limit N] # who read which developer's session
agenthub server status
```
diff --git a/docs/plans/roles-developer/CHECKLIST.md b/docs/plans/roles-developer/CHECKLIST.md
new file mode 100644
index 00000000..18e234d6
--- /dev/null
+++ b/docs/plans/roles-developer/CHECKLIST.md
@@ -0,0 +1,36 @@
+# Roles: admin / analyst / developer — checklist
+
+Decisions (user, 2026-10-08):
+- `member` is renamed `developer` everywhere, stored value included (migration).
+- developer: full control of OWN workspaces/sessions/git; nothing host-wide.
+- analyst: sees every user's rows (as today) AND works on OWN rows like a
+ developer (was: read-only); nothing host-wide, never another user's rows.
+- UI hides every control the role cannot use; refusals show a clear toast.
+
+## Server
+- [x] db.js: rebuild `users` with CHECK(admin, analyst, developer), member→developer
+- [x] auth.js: ROLES, default role, STAFF_ONLY gate, analyst no longer EREADONLY
+- [x] auth.js: `/alerts/rules` joins ADMIN_WRITE_PREFIXES (host-wide rules)
+- [x] scope.js: `memberId` = developer always, analyst on writes only
+- [x] websocket.js: analyst terminal only on own runs; broadcast member→developer
+- [x] audit.js, users.js, workflow-stats.js, openapi-extra/auth.js, CLI users
+- [x] tests: update member→developer, add analyst write-scope tests
+
+## Client
+- [x] one role helper (isAdmin / canSeeAll) from useAuth
+- [x] hide admin-only controls (inventory from Explore)
+- [x] analyst: hide write controls on other users' rows
+- [x] errors: EADMIN / EREADONLY / ENOTFOUND toasts translated (en + vi)
+- [x] role label "Developer" en/vi, Users page role select
+
+## Finish
+- [x] npm run test:server, npm run test:client
+- [x] docs: SERVER-MODE.md, CLAUDE.md lines naming `member`
+- [x] headers check
+
+## Follow-ups (2026-10-08)
+- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
+- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
+- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
+- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
+- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)
diff --git a/openapi.yaml b/openapi.yaml
index c86b346e..240c3b7b 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
- version: 2.5.12
+ version: 2.5.13
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ĩ
@@ -4776,7 +4776,7 @@ components:
enum:
- admin
- analyst
- - member
+ - developer
disabled:
type: boolean
AuthMe:
@@ -12834,6 +12834,173 @@ paths:
description: 'EBADVALUE: `enabled` is not a boolean'
'403':
description: 'EBADORIGIN: another site asked'
+ /api/settings/backup:
+ get:
+ tags:
+ - Settings
+ summary: Daily database backup settings and backups
+ operationId: getBackup
+ responses:
+ '200':
+ description: Daily database backup settings, the folder's backups and the last run
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - dir
+ - hour
+ - keep
+ - running
+ - files
+ - env_owned
+ properties:
+ dir:
+ type: string
+ description: Backup folder; empty = backups off
+ hour:
+ type: integer
+ minimum: 0
+ maximum: 23
+ description: Server local hour
+ keep:
+ type: integer
+ minimum: 1
+ maximum: 365
+ running:
+ type: boolean
+ last:
+ type: object
+ nullable: true
+ description: 'This process''s last run: {at, ok, file?, size?, ms?, removed?, error?}'
+ files:
+ type: array
+ description: AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` files, newest first
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: integer
+ mtime:
+ type: string
+ format: date-time
+ dir_error:
+ type: string
+ nullable: true
+ env_owned:
+ type: object
+ properties:
+ dir:
+ type: boolean
+ hour:
+ type: boolean
+ keep:
+ type: boolean
+ put:
+ tags:
+ - Settings
+ summary: Set the backup folder, hour and number kept
+ description: Any subset of the three fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP. An empty `dir` turns backups off. Same-origin only.
+ operationId: setBackup
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ dir:
+ type: string
+ hour:
+ type: integer
+ minimum: 0
+ maximum: 23
+ keep:
+ type: integer
+ minimum: 1
+ maximum: 365
+ responses:
+ '200':
+ description: The new state
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - dir
+ - hour
+ - keep
+ - running
+ - files
+ - env_owned
+ properties:
+ dir:
+ type: string
+ description: Backup folder; empty = backups off
+ hour:
+ type: integer
+ minimum: 0
+ maximum: 23
+ description: Server local hour
+ keep:
+ type: integer
+ minimum: 1
+ maximum: 365
+ running:
+ type: boolean
+ last:
+ type: object
+ nullable: true
+ description: 'This process''s last run: {at, ok, file?, size?, ms?, removed?, error?}'
+ files:
+ type: array
+ description: AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` files, newest first
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: integer
+ mtime:
+ type: string
+ format: date-time
+ dir_error:
+ type: string
+ nullable: true
+ env_owned:
+ type: object
+ properties:
+ dir:
+ type: boolean
+ hour:
+ type: boolean
+ keep:
+ type: boolean
+ '400':
+ description: 'INVALID_PATH: the folder is relative, missing, not a directory or not writable; INVALID_INPUT: hour/keep out of range'
+ '403':
+ description: 'EBADORIGIN: another site asked'
+ '409':
+ description: 'EENVOWNED: the environment set that field'
+ '500':
+ description: 'ESAVE: the settings file could not be written'
+ /api/settings/backup/run:
+ post:
+ tags:
+ - Settings
+ summary: Back up the database now
+ description: Starts a backup and answers at once; poll GET /api/settings/backup while `running`. Same-origin only.
+ operationId: runBackup
+ responses:
+ '202':
+ description: Started (or already running); the backup state
+ '400':
+ description: 'ENOBACKUPDIR: no backup folder is set'
+ '403':
+ description: 'EBADORIGIN: another site asked'
/api/settings/codex-hooks:
get:
tags:
@@ -13514,7 +13681,7 @@ paths:
tags:
- Auth
operationId: listServerUsers
- summary: Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)
+ summary: Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a developer)
responses:
'200':
description: Accounts
@@ -13549,7 +13716,7 @@ paths:
tokens:
type: integer
'403':
- description: EADMIN — a member
+ description: EADMIN — a developer
content:
application/json:
schema:
@@ -13580,8 +13747,8 @@ paths:
enum:
- admin
- analyst
- - member
- default: member
+ - developer
+ default: developer
currentPassword:
type: string
responses:
@@ -13644,7 +13811,7 @@ paths:
enum:
- admin
- analyst
- - member
+ - developer
disabled:
type: boolean
currentPassword:
@@ -13827,7 +13994,7 @@ paths:
tags:
- Auth
operationId: listAuditLog
- summary: Admin/analyst reads of sessions members own, newest first
+ summary: Admin/analyst reads of sessions developers own, newest first
parameters:
- name: limit
in: query
@@ -13865,7 +14032,7 @@ paths:
type: string
nullable: true
'403':
- description: EADMIN — a member
+ description: EADMIN — a developer
content:
application/json:
schema:
diff --git a/package-lock.json b/package-lock.json
index 064661c3..c12a2daf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,18 @@
{
"name": "agenthub",
- "version": "2.5.12",
+ "version": "2.5.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
- "version": "2.5.12",
+ "version": "2.5.13",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
- "adm-zip": "^0.5.16",
+ "adm-zip": "^0.6.1",
"cors": "^2.8.5",
- "cross-spawn": "^7.0.6",
"express": "^4.21.2",
"multer": "^2.0.0",
"redoc": "^2.5.3",
@@ -40,9 +39,9 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
- "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -176,9 +175,9 @@
]
},
"node_modules/@nodable/entities": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
- "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
+ "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
@@ -210,9 +209,9 @@
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
- "version": "1.34.15",
- "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
- "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
+ "version": "1.34.20",
+ "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz",
+ "integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==",
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
@@ -220,7 +219,7 @@
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
- "js-yaml": "4.1.1",
+ "js-yaml": "4.3.2",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
@@ -264,12 +263,12 @@
}
},
"node_modules/adm-zip": {
- "version": "0.5.17",
- "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
- "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
+ "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
"license": "MIT",
"engines": {
- "node": ">=12.0"
+ "node": ">=14.0"
}
},
"node_modules/agent-base": {
@@ -412,15 +411,15 @@
}
},
"node_modules/bn.js": {
- "version": "4.12.3",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
- "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
+ "version": "4.12.5",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
+ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": {
- "version": "1.20.4",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
- "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -431,7 +430,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
- "qs": "~6.14.0",
+ "qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -442,9 +441,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -481,12 +480,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "license": "MIT"
- },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -583,11 +576,13 @@
}
},
"node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC",
- "optional": true
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/classnames": {
"version": "2.5.1",
@@ -642,31 +637,16 @@
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"license": "MIT"
},
- "node_modules/concat-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
- "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
- "engines": [
- "node >= 6.0"
- ],
- "license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.0.2",
- "typedarray": "^0.0.6"
- }
- },
"node_modules/concurrently": {
- "version": "9.2.1",
- "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
- "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
+ "version": "9.2.4",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
+ "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
- "shell-quote": "1.8.3",
+ "shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
@@ -719,12 +699,15 @@
"license": "MIT"
},
"node_modules/core-js": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
- "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "version": "3.50.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
+ "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
+ "engines": {
+ "node": "*"
+ },
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -747,20 +730,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -860,9 +829,9 @@
}
},
"node_modules/dompurify": {
- "version": "3.4.11",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
- "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+ "version": "3.4.16",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
+ "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -941,9 +910,9 @@
}
},
"node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -999,14 +968,14 @@
}
},
"node_modules/express": {
- "version": "4.22.1",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
- "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
- "body-parser": "~1.20.3",
+ "body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -1023,9 +992,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "~0.1.12",
+ "path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
- "qs": "~6.14.0",
+ "qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -1057,9 +1026,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
- "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
+ "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
@@ -1068,14 +1037,14 @@
],
"license": "MIT",
"dependencies": {
- "path-expression-matcher": "^1.5.0",
- "xml-naming": "^0.1.0"
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz",
- "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==",
+ "version": "5.11.2",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
+ "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
"funding": [
{
"type": "github",
@@ -1084,12 +1053,12 @@
],
"license": "MIT",
"dependencies": {
- "@nodable/entities": "^2.2.0",
+ "@nodable/entities": "^3.0.1",
"fast-xml-builder": "^1.2.0",
- "is-unsafe": "^1.0.1",
- "path-expression-matcher": "^1.5.0",
- "strnum": "^2.4.1",
- "xml-naming": "^0.1.0"
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.2",
+ "xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -1268,9 +1237,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1415,9 +1384,9 @@
}
},
"node_modules/is-unsafe": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
- "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
+ "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
@@ -1426,12 +1395,6 @@
],
"license": "MIT"
},
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "license": "ISC"
- },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -1448,9 +1411,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -1748,14 +1721,13 @@
"license": "MIT"
},
"node_modules/multer": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
- "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
+ "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
- "concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
@@ -1783,9 +1755,9 @@
}
},
"node_modules/node-abi": {
- "version": "3.87.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
- "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
+ "version": "3.98.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz",
+ "integrity": "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1950,9 +1922,9 @@
}
},
"node_modules/openapi-sampler": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.7.4.tgz",
- "integrity": "sha512-CKS/rd5ucPCuEDbJnjGDXZTsuGWcmv53aCmQx7soZlPEONUGN4af0/dY5+THRFZraSEjeA78nlfzdFswC/N5SA==",
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.7.6.tgz",
+ "integrity": "sha512-MvWOm3vdgX7CZ6CNfu2wOFMGhC/pq03BxYTL/DKCkyPWfR6XIwUJnOV32mQGgCcUm1tY/y1jj7F2yqx1DieIWw==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.7",
@@ -1976,9 +1948,9 @@
"license": "MIT"
},
"node_modules/path-expression-matcher": {
- "version": "1.6.4",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
- "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
@@ -1990,19 +1962,10 @@
"node": ">=14.0.0"
}
},
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/path-to-regexp": {
- "version": "0.1.12",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
- "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/perfect-scrollbar": {
@@ -2068,9 +2031,9 @@
}
},
"node_modules/prettier": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
- "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "version": "3.9.9",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
+ "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2104,9 +2067,9 @@
}
},
"node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -2114,6 +2077,10 @@
},
"engines": {
"node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/pump": {
@@ -2128,12 +2095,13 @@
}
},
"node_modules/qs": {
- "version": "6.14.2",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
- "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
- "side-channel": "^1.1.0"
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -2183,9 +2151,9 @@
}
},
"node_modules/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
- "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2193,16 +2161,16 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
- "scheduler": "^0.27.0"
+ "scheduler": "^0.28.0"
},
"peerDependencies": {
- "react": "^19.2.7"
+ "react": "^19.3.0"
}
},
"node_modules/react-is": {
@@ -2229,6 +2197,7 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -2239,9 +2208,9 @@
}
},
"node_modules/redoc": {
- "version": "2.5.3",
- "resolved": "https://registry.npmjs.org/redoc/-/redoc-2.5.3.tgz",
- "integrity": "sha512-bBbat+Sx6xKWdyoCGTtA0BWeTEW9Vs4VnEja7q7ZLOk4IM7cHQLrf+kDxWF6dKeKxT8kOBnoy/OsNXCeLttpyQ==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/redoc/-/redoc-2.5.4.tgz",
+ "integrity": "sha512-M6jWhG1qoBnH6TFmzJnstyCZ87HmOY/UzDm78mHiYihEdlV/YcS9ogOo1NlElnJMeLsyxHFe2yFc4sNjHTrABQ==",
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.15",
@@ -2342,16 +2311,16 @@
"license": "MIT"
},
"node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT",
"peer": true
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
@@ -2412,31 +2381,10 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2501,14 +2449,14 @@
"license": "MIT"
},
"node_modules/side-channel": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3",
- "side-channel-list": "^1.0.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -2520,13 +2468,13 @@
}
},
"node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
+ "object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -2655,6 +2603,7 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -2696,9 +2645,9 @@
}
},
"node_modules/strnum": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
- "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
+ "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
@@ -2711,9 +2660,9 @@
}
},
"node_modules/styled-components": {
- "version": "6.4.3",
- "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.3.tgz",
- "integrity": "sha512-wYXrhu+JmDjZ1Tv7O0OopGTfztbzun43Pjjhh2H+xc0h5A09dwpZ5FJbrifJDcL8g5TA9btpWOX2+iSRuJTExw==",
+ "version": "6.5.3",
+ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.5.3.tgz",
+ "integrity": "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2771,9 +2720,9 @@
}
},
"node_modules/swagger-ui-dist": {
- "version": "5.32.2",
- "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.2.tgz",
- "integrity": "sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==",
+ "version": "5.33.1",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz",
+ "integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -2858,9 +2807,9 @@
"license": "MIT"
},
"node_modules/tar": {
- "version": "7.5.13",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
- "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
+ "version": "7.5.22",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
+ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -2874,9 +2823,9 @@
}
},
"node_modules/tar-fs": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
- "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2886,6 +2835,13 @@
"tar-stream": "^2.1.4"
}
},
+ "node_modules/tar-fs/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -2903,15 +2859,6 @@
"node": ">=6"
}
},
- "node_modules/tar/node_modules/chownr": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
- "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2970,12 +2917,6 @@
"node": ">= 0.6"
}
},
- "node_modules/typedarray": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
- "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
- "license": "MIT"
- },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -3007,9 +2948,9 @@
"license": "BSD"
},
"node_modules/use-sync-external-store": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
- "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -3019,7 +2960,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/utils-merge": {
"version": "1.0.1",
@@ -3031,9 +2973,9 @@
}
},
"node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
+ "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -3087,21 +3029,6 @@
"webidl-conversions": "^3.0.0"
}
},
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -3127,9 +3054,9 @@
"optional": true
},
"node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -3148,9 +3075,9 @@
}
},
"node_modules/xml-naming": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
- "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
diff --git a/package.json b/package.json
index 7d5236be..5cdb8c90 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agenthub",
- "version": "2.5.12",
+ "version": "2.5.13",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -72,9 +72,8 @@
},
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
- "adm-zip": "^0.5.16",
+ "adm-zip": "^0.6.1",
"cors": "^2.8.5",
- "cross-spawn": "^7.0.6",
"express": "^4.21.2",
"multer": "^2.0.0",
"redoc": "^2.5.3",
diff --git a/scripts/darken-status-colors.mjs b/scripts/darken-status-colors.mjs
deleted file mode 100644
index a01a6e53..00000000
--- a/scripts/darken-status-colors.mjs
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env node
-/**
- * @file One-off codemod: shifts every raw `blue-*`/`amber-*` Tailwind utility
- * one shade darker (100→200 ... 700→800), mirroring the 1-step darkening
- * already applied to the accent token (`#2563eb` blue-600 → `#1d4ed8`
- * blue-700). `blue` here is the "current stage" / info color (e.g.
- * PipelineMap's `current` state, unrelated to the `accent` CSS-variable
- * token); `amber` is the warning/pending/auto-detected color used across
- * badges, banners, and the pipeline's dashed "auto: " chip. `950` is
- * left alone — already the darkest step available.
- * @author Nguyễn Ngọc Trí Vĩ
- */
-import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
-import { join } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const SHIFT = {
- 50: 100,
- 100: 200,
- 200: 300,
- 300: 400,
- 400: 500,
- 500: 600,
- 600: 700,
- 700: 800,
- 800: 900,
-};
-const COLORS = ["blue", "amber"];
-const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill"];
-
-// Descending by `from` — JS enumerates integer-like object keys ascending
-// regardless of source order, and shifting low-to-high would let an already
-// -shifted "200" (from 100) get caught and shifted AGAIN by the 200 rule.
-// Highest-first guarantees each original shade is only ever matched once.
-const SHIFTS_DESC = Object.entries(SHIFT)
- .map(([from, to]) => [Number(from), to])
- .sort((a, b) => b[0] - a[0]);
-
-function rewrite(text) {
- let out = text;
- for (const prop of PROPS) {
- for (const color of COLORS) {
- for (const [from, to] of SHIFTS_DESC) {
- const re = new RegExp(`\\b${prop}-${color}-${from}(\\/[0-9]+)?\\b`, "g");
- out = out.replace(re, (_m, opacity) => `${prop}-${color}-${to}${opacity || ""}`);
- }
- }
- }
- return out;
-}
-
-function walk(dir, files = []) {
- for (const entry of readdirSync(dir)) {
- const full = join(dir, entry);
- const st = statSync(full);
- if (st.isDirectory()) {
- if (entry === "node_modules") continue;
- walk(full, files);
- } else if (entry.endsWith(".tsx")) {
- files.push(full);
- }
- }
- return files;
-}
-
-const scriptDir = fileURLToPath(new URL(".", import.meta.url));
-const root = join(scriptDir, "..", "client", "src");
-const files = walk(root);
-let changed = 0;
-for (const file of files) {
- const before = readFileSync(file, "utf8");
- const after = rewrite(before);
- if (after !== before) {
- writeFileSync(file, after);
- changed++;
- }
-}
-console.log(`rewrote ${changed} file(s)`);
diff --git a/scripts/migrate-color-tokens.mjs b/scripts/migrate-color-tokens.mjs
deleted file mode 100644
index 797da6e9..00000000
--- a/scripts/migrate-color-tokens.mjs
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env node
-/**
- * @file One-off codemod: rewrites raw Tailwind gray-scale utility classes
- * (neutral, gray, slate, zinc, stone shades) across client/src to the
- * semantic surface/border/fg tokens introduced for dark/light mode.
- * Status colors (emerald/red/amber) and the categorical hue palette
- * (violet/indigo/cyan/teal/sky/rose/pink/orange/yellow) are deliberately
- * out of scope — see docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md.
- * @author Nguyễn Ngọc Trí Vĩ
- */
-import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
-import { join } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const MAP = {
- text: {
- "gray-50": "fg-primary",
- "gray-100": "fg-primary",
- "gray-200": "fg-primary",
- "neutral-50": "fg-primary",
- "neutral-100": "fg-primary",
- "gray-300": "fg-secondary",
- "gray-400": "fg-secondary",
- "neutral-200": "fg-secondary",
- "neutral-300": "fg-secondary",
- "neutral-400": "fg-secondary",
- "slate-300": "fg-secondary",
- "gray-500": "fg-muted",
- "gray-600": "fg-muted",
- "gray-700": "fg-muted",
- "neutral-500": "fg-muted",
- "neutral-600": "fg-muted",
- },
- placeholder: {
- "gray-500": "fg-muted",
- "gray-600": "fg-muted",
- },
- fill: {
- "gray-100": "fg-primary",
- "gray-300": "fg-secondary",
- "gray-600": "fg-muted",
- },
- bg: {
- "neutral-900": "surface-0",
- "gray-900": "surface-0",
- "neutral-800": "surface-2",
- "gray-800": "surface-2",
- "neutral-700": "surface-3",
- "gray-700": "surface-3",
- "neutral-500": "surface-4",
- "gray-500": "surface-4",
- "gray-400": "surface-4",
- "gray-600": "surface-4",
- "slate-500": "surface-4",
- },
- border: {
- "neutral-800": "border",
- "gray-800": "border",
- "neutral-700": "border-light",
- "gray-700": "border-light",
- "neutral-500": "border-light",
- "gray-500": "border-light",
- "gray-600": "border-light",
- },
- ring: {
- "slate-500": "border-light",
- "slate-400": "border-light",
- },
-};
-
-function rewrite(text) {
- let out = text;
- for (const [prop, shadeMap] of Object.entries(MAP)) {
- for (const [rawShade, token] of Object.entries(shadeMap)) {
- // Matches `bg-gray-500`, `bg-gray-500/10`, `bg-gray-500/50` etc. — the
- // opacity suffix is preserved verbatim since the token's CSS var
- // already supports ``.
- const re = new RegExp(`\\b${prop}-${rawShade}(\\/[0-9]+)?\\b`, "g");
- out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
- }
- }
- return out;
-}
-
-function walk(dir, files = []) {
- for (const entry of readdirSync(dir)) {
- const full = join(dir, entry);
- const st = statSync(full);
- if (st.isDirectory()) {
- if (entry === "node_modules") continue;
- walk(full, files);
- } else if (entry.endsWith(".tsx")) {
- files.push(full);
- }
- }
- return files;
-}
-
-const scriptDir = fileURLToPath(new URL(".", import.meta.url));
-const root = join(scriptDir, "..", "client", "src");
-const files = walk(root);
-let changed = 0;
-for (const file of files) {
- const before = readFileSync(file, "utf8");
- const after = rewrite(before);
- if (after !== before) {
- writeFileSync(file, after);
- changed++;
- }
-}
-console.log(`rewrote ${changed} file(s)`);
diff --git a/scripts/plugin-update-helper.js b/scripts/plugin-update-helper.js
index 2550612d..4b2f093d 100644
--- a/scripts/plugin-update-helper.js
+++ b/scripts/plugin-update-helper.js
@@ -247,7 +247,7 @@ function main() {
record({ ok: true, step: "done", plugin_root: newRoot });
}
-module.exports = { failureText };
+module.exports = { failureText, resolveClaude };
if (require.main === module) {
try {
diff --git a/scripts/tokenize-status-colors.mjs b/scripts/tokenize-status-colors.mjs
deleted file mode 100644
index edf4763a..00000000
--- a/scripts/tokenize-status-colors.mjs
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env node
-/**
- * @file One-off codemod: rewrites raw `emerald-*`/`red-*`/`amber-*` Tailwind
- * utilities across client/src to the shared `status-success`/`status-danger`/
- * `status-warning` CSS-variable tokens (see `src/index.css`), so every
- * badge/button/component that means "success"/"danger"/"warning" uses the
- * SAME shade per theme instead of each usage picking its own. The
- * categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose,
- * pink, orange, yellow, and `blue` — which doubles as both "info" and a
- * categorical role color in places like message bubbles) is deliberately
- * left alone: those distinguish between different *kinds* of thing, not a
- * status, and collapsing them would erase that distinction.
- * @author Nguyễn Ngọc Trí Vĩ
- */
-import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
-import { join } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const COLOR_TO_STATUS = {
- emerald: "status-success",
- red: "status-danger",
- amber: "status-warning",
-};
-const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill", "placeholder"];
-
-function rewrite(text) {
- let out = text;
- for (const prop of PROPS) {
- for (const [color, token] of Object.entries(COLOR_TO_STATUS)) {
- // Shade number is dropped entirely — the token carries its own
- // per-theme value, so `emerald-400`, `emerald-500`, `emerald-600` all
- // collapse onto the one `status-success` (that collapse IS the fix:
- // no more per-usage shade picking). Opacity suffix (`/10`, `/60`) is
- // preserved verbatim.
- const re = new RegExp(`\\b${prop}-${color}-[0-9]+(\\/\\[[0-9.]+\\]|\\/[0-9]+)?`, "g");
- out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
- }
- }
- return out;
-}
-
-function walk(dir, files = []) {
- for (const entry of readdirSync(dir)) {
- const full = join(dir, entry);
- const st = statSync(full);
- if (st.isDirectory()) {
- if (entry === "node_modules") continue;
- walk(full, files);
- } else if (entry.endsWith(".tsx")) {
- files.push(full);
- }
- }
- return files;
-}
-
-const scriptDir = fileURLToPath(new URL(".", import.meta.url));
-const root = join(scriptDir, "..", "client", "src");
-const files = walk(root);
-let changed = 0;
-for (const file of files) {
- const before = readFileSync(file, "utf8");
- const after = rewrite(before);
- if (after !== before) {
- writeFileSync(file, after);
- changed++;
- }
-}
-console.log(`rewrote ${changed} file(s)`);
diff --git a/scripts/uninstall.js b/scripts/uninstall.js
new file mode 100644
index 00000000..8d6a42a8
--- /dev/null
+++ b/scripts/uninstall.js
@@ -0,0 +1,463 @@
+#!/usr/bin/env node
+/**
+ * @file `agenthub uninstall` and Settings → System's **Uninstall AgentHub**:
+ * removes everything AgentHub put on this machine, in an order that keeps a
+ * running piece from putting anything back — terminals, then the service unit
+ * (else `Restart=always` revives the server), servers, tray icon, lane stacks,
+ * the hooks and statusline it wrote into Claude Code / Codex / Antigravity
+ * config (only its own entries), the plugin itself, then its files and data
+ * dir LAST (pure fs, so this script's own cache dir may already be gone).
+ *
+ * Never touched: the AI tools' own conversations (`~/.claude/projects`,
+ * `~/.codex/sessions`, …), lane worktrees and branches, anything written into
+ * a user's repo, config backups, a marketplace other than `agenthub`.
+ *
+ * Every module is required up front: the plugin cache and runtime this file
+ * runs from are deleted mid-run. Each step's outcome goes to
+ * `$TMPDIR/agenthub-uninstall-.log`, outside everything removed. Run as a
+ * script (`--yes`) it is the detached worker `POST /api/settings/uninstall`
+ * spawns; `bin/agenthub.js` calls `plan()`/`run()` in-process.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+require("../server/lib/windows-hide").install(); // before child_process is read
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { spawnSync } = require("child_process");
+const { getClaudeHome, getDataDir, getSettingsPath } = require("../server/lib/claude-home");
+const { getCodexHome } = require("../server/lib/codex-home");
+const { settingsFilePath, loadEnvFiles } = require("../server/lib/settings-env");
+const { getServerInfoPath } = require("../server/lib/server-info");
+const { isOurEntry } = require("./install-hooks");
+const statusline = require("../statusline/install");
+const codexHooks = require("../server/lib/codex-hooks");
+const agyHooks = require("../server/lib/antigravity-hooks");
+const { resolveClaude } = require("./plugin-update-helper");
+
+const REPO_ROOT = path.resolve(__dirname, "..");
+const MARKETPLACE = "agenthub"; // .claude-plugin/marketplace.json — never a shared one
+const uid = () => (process.getuid ? process.getuid() : "user");
+const logPath = () => path.join(os.tmpdir(), `agenthub-uninstall-${uid()}.log`);
+const lanesRoot = () =>
+ process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
+
+function defaultIo() {
+ const bootstrap = require("./plugin-bootstrap");
+ const tray = require("../server/lib/tray");
+ return {
+ svc: require("./service-manager"),
+ tmux: require("../server/lib/tmux"),
+ livePids: bootstrap.livePids,
+ stopDashboard: bootstrap.stopDashboard,
+ liveTray: tray.liveTray,
+ stopTray: tray.stopTray,
+ claude: resolveClaude,
+ run: (cmd, args, opts) => spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, ...opts }),
+ kill: (pid, sig) => process.kill(pid, sig),
+ sleep: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
+ };
+}
+
+const exists = (p) => {
+ try {
+ fs.lstatSync(p);
+ return true;
+ } catch {
+ return false;
+ }
+};
+const rm = (p) => fs.rmSync(p, { recursive: true, force: true });
+const readJson = (p) => {
+ try {
+ return JSON.parse(fs.readFileSync(p, "utf8"));
+ } catch {
+ return null;
+ }
+};
+const alive = (io, pid) => {
+ try {
+ io.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+/** `dir` is ancestor-or-equal of `p`. */
+function covers(dir, p) {
+ const r = path.relative(path.resolve(dir), path.resolve(p));
+ return r === "" || (!r.startsWith("..") && !path.isAbsolute(r));
+}
+
+/**
+ * A data dir is deleted whole only when it cannot be the user's own folder:
+ * never `/`, the home, CLAUDE_HOME or this checkout (or any of their parents —
+ * `DASHBOARD_DATA_DIR` is free text), and it must look like ours.
+ */
+function isSafeDataDir(dir) {
+ if ([os.homedir(), getClaudeHome(), REPO_ROOT].some((p) => covers(dir, p))) return false;
+ return (
+ ["agenthub", "agent-dashboard"].includes(path.basename(path.resolve(dir))) ||
+ exists(path.join(dir, "dashboard.db"))
+ );
+}
+
+/** Claude Code hook entries of ours: a checkout's hook-handler.js, a plugin's bootstrap. */
+const isOurHook = (e) => isOurEntry(e) || JSON.stringify(e).includes("plugin-bootstrap.js");
+
+function ourHookCount() {
+ const hooks = readJson(getSettingsPath())?.hooks || {};
+ return Object.values(hooks)
+ .filter(Array.isArray)
+ .reduce((n, list) => n + list.filter(isOurHook).length, 0);
+}
+
+function removeOurHooks() {
+ const file = getSettingsPath();
+ const settings = readJson(file);
+ for (const [type, list] of Object.entries(settings.hooks || {})) {
+ if (!Array.isArray(list)) continue;
+ const kept = list.filter((e) => !isOurHook(e));
+ if (kept.length) settings.hooks[type] = kept;
+ else delete settings.hooks[type];
+ }
+ if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
+ const backup = `${file}.agenthub-uninstall.bak`;
+ fs.copyFileSync(file, backup);
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", "utf8");
+ return `backup: ${backup}`;
+}
+
+/** `agenthub@` plus every plugin of AgentHub's own marketplace. */
+function ourPlugins() {
+ const data = readJson(path.join(getClaudeHome(), "plugins", "installed_plugins.json"));
+ const out = [];
+ for (const [id, entries] of Object.entries(data?.plugins || {})) {
+ if (!id.startsWith("agenthub@") && !id.endsWith(`@${MARKETPLACE}`)) continue;
+ for (const e of entries || []) out.push({ id, scope: e.scope || "user", cwd: e.projectPath });
+ }
+ return out;
+}
+
+function marketplaceKnown() {
+ const known = readJson(path.join(getClaudeHome(), "plugins", "known_marketplaces.json"));
+ return !!known && MARKETPLACE in known;
+}
+
+function cacheDirs() {
+ const base = path.join(getClaudeHome(), "plugins", "cache");
+ let mkts = [];
+ try {
+ mkts = fs.readdirSync(base);
+ } catch {
+ return [];
+ }
+ return mkts.map((m) => path.join(base, m, "agenthub")).filter(exists);
+}
+
+function launchers() {
+ const dir = path.join(os.homedir(), ".local", "bin");
+ return ["agenthub", "agenthub.cmd"]
+ .map((n) => path.join(dir, n))
+ .filter((p) => {
+ try {
+ return fs.readFileSync(p, "utf8").includes("agenthub-plugin-launcher");
+ } catch {
+ return false;
+ }
+ });
+}
+
+function stackPids(io) {
+ const state = path.join(lanesRoot(), ".state");
+ const out = [];
+ let lanes = [];
+ try {
+ lanes = fs.readdirSync(state);
+ } catch {
+ return out;
+ }
+ for (const lane of lanes) {
+ const run = path.join(state, lane, "run");
+ let files = [];
+ try {
+ files = fs.readdirSync(run).filter((f) => f.endsWith(".pid"));
+ } catch {
+ continue;
+ }
+ for (const f of files) {
+ const pid = Number(fs.readFileSync(path.join(run, f), "utf8").trim());
+ if (Number.isInteger(pid) && pid > 1 && alive(io, pid)) out.push(pid);
+ }
+ }
+ return out;
+}
+
+function codexProxyLayers() {
+ const home = getCodexHome();
+ let names = [];
+ try {
+ names = fs.readdirSync(home).filter((n) => n.endsWith(".config.toml"));
+ } catch {
+ return [];
+ }
+ return names
+ .map((n) => path.join(home, n))
+ .filter((p) => {
+ try {
+ return fs.readFileSync(p, "utf8").startsWith("# Written by AgentHub");
+ } catch {
+ return false;
+ }
+ });
+}
+
+function hookFiles(mod) {
+ const s = mod.status();
+ const any = Object.values(s.events || {}).some((e) => e.installed);
+ return [any && s.path, exists(s.script) && s.script].filter(Boolean);
+}
+
+/** Files and directories that are AgentHub's alone, deleted whole. */
+function ownFiles() {
+ const ch = getClaudeHome();
+ const lanes = lanesRoot();
+ let backups = [];
+ try {
+ const dir = path.dirname(getSettingsPath());
+ backups = fs
+ .readdirSync(dir)
+ .filter((n) => /^settings\.json\.agenthub-(bak\d*|backup-.+)$/.test(n))
+ .map((n) => path.join(dir, n));
+ } catch {
+ /* no CLAUDE_HOME */
+ }
+ return [
+ ...launchers(),
+ settingsFilePath(),
+ getServerInfoPath(),
+ path.join(ch, ".agent-dashboard.json"),
+ ...["proxies", "routers", "git-credentials", "accounts", "profiles"].map((n) =>
+ path.join(ch, `agenthub-${n}`)
+ ),
+ ...backups,
+ path.join(lanes, ".state"),
+ path.join(lanes, ".locks"),
+ path.join(os.homedir(), "Library", "Logs", "agenthub.log"),
+ path.join(os.tmpdir(), `agenthub-statusline-${uid()}.json`),
+ path.join(os.tmpdir(), `agenthub-drops-${uid()}`),
+ ].filter(exists);
+}
+
+function dataDirs() {
+ const ch = getClaudeHome();
+ const dirs = new Set([getDataDir(), path.join(ch, "agenthub"), path.join(ch, "agent-dashboard")]);
+ const out = [...dirs].filter(exists);
+ const db = process.env.DASHBOARD_DB_PATH;
+ if (db && /\.db$/.test(db) && !out.some((d) => covers(d, db))) {
+ out.push(...[db, `${db}-wal`, `${db}-shm`].filter(exists));
+ }
+ return out;
+}
+
+/**
+ * The steps, in order. `find` lists what is there (empty = nothing to do, the
+ * step is skipped); `remove` takes it away and may return a note for the log.
+ */
+function steps(io) {
+ const self = process.env.AGENTHUB_RUN_ID || null; // the pane this runs in, if any
+ return [
+ {
+ key: "panes",
+ label: "Close every workspace terminal",
+ find: () => io.tmux.listSessions("agenthub-lane-").filter((n) => n !== self),
+ remove: (names) => names.forEach((n) => io.tmux.killSession(n)),
+ },
+ {
+ key: "service",
+ label: `Remove the ${io.svc.label}`,
+ find: () =>
+ io.svc.systemctlAvailable() && io.svc.unitIsOurs("agenthub")
+ ? [io.svc.unitPath("agenthub")]
+ : [],
+ remove: () => io.svc.uninstallUserService("agenthub"),
+ },
+ {
+ key: "servers",
+ label: "Stop the dashboard server",
+ find: () => io.livePids().map((p) => `pid ${p}`),
+ remove: () => {
+ io.stopDashboard();
+ for (let i = 0; i < 50 && io.livePids().length; i++) io.sleep(200);
+ if (io.livePids().length) throw new Error(`still running: ${io.livePids().join(", ")}`);
+ },
+ },
+ {
+ key: "tray",
+ label: "Close the status-area icon",
+ find: () => (io.liveTray() ? [`pid ${io.liveTray().pid}`] : []),
+ remove: () => io.stopTray(),
+ },
+ {
+ key: "stacks",
+ label: "Stop workspace stacks",
+ find: () => stackPids(io).map((p) => `pid ${p}`),
+ remove: () => stackPids(io).forEach((p) => io.kill(p, "SIGTERM")),
+ },
+ {
+ key: "claude-hooks",
+ label: "Remove AgentHub's hooks from Claude Code settings",
+ find: () => (ourHookCount() ? [`${getSettingsPath()} (${ourHookCount()} entries)`] : []),
+ remove: removeOurHooks,
+ },
+ {
+ key: "statusline",
+ label: "Remove AgentHub's statusline",
+ find: () =>
+ statusline.isOurs(readJson(getSettingsPath())?.statusLine) ? [getSettingsPath()] : [],
+ remove: () => statusline.uninstall(getSettingsPath()).status,
+ },
+ {
+ key: "codex-hooks",
+ label: "Remove AgentHub's Codex hooks",
+ find: () => hookFiles(codexHooks),
+ remove: () => void codexHooks.remove(),
+ },
+ {
+ key: "antigravity-hooks",
+ label: "Remove AgentHub's Antigravity hook",
+ find: () => hookFiles(agyHooks),
+ remove: () => void agyHooks.remove(),
+ },
+ {
+ key: "codex-proxies",
+ label: "Remove AI Proxy profiles from Codex",
+ find: codexProxyLayers,
+ remove: (files) => files.forEach(rm),
+ },
+ {
+ key: "plugin",
+ label: "Uninstall the AgentHub plugin(s) from Claude Code",
+ find: () => [
+ ...ourPlugins().map((p) => `${p.id} (${p.scope})`),
+ ...(marketplaceKnown() ? [`marketplace ${MARKETPLACE}`] : []),
+ ],
+ remove: () => {
+ const claude = io.claude();
+ if (!claude)
+ throw new Error("`claude` CLI not found — run `claude plugin uninstall` yourself");
+ const failed = [];
+ const call = (args, cwd) => {
+ const r = io.run(claude, args, cwd && exists(cwd) ? { cwd } : {});
+ if (r.status !== 0)
+ failed.push(`${args.join(" ")}: ${(r.stderr || r.stdout || "").trim()}`);
+ };
+ for (const p of ourPlugins())
+ call(["plugin", "uninstall", p.id, "-s", p.scope, "-y"], p.cwd);
+ if (marketplaceKnown()) call(["plugin", "marketplace", "remove", MARKETPLACE]);
+ if (failed.length) throw new Error(failed.join("; "));
+ },
+ },
+ {
+ key: "files",
+ label: "Delete AgentHub's files, settings and stored logins",
+ find: ownFiles,
+ remove: (paths) => {
+ paths.forEach(rm);
+ for (const dir of [path.dirname(settingsFilePath()), lanesRoot()]) {
+ try {
+ fs.rmdirSync(dir); // only when nothing else is left in it
+ } catch {
+ /* not empty, or gone */
+ }
+ }
+ },
+ },
+ {
+ key: "cache",
+ label: "Delete the plugin's cached copies",
+ // Claude Code still recording an install means its uninstall failed:
+ // deleting the files then would leave it pointing at nothing.
+ find: () => (ourPlugins().some((p) => p.id.startsWith("agenthub@")) ? [] : cacheDirs()),
+ remove: (dirs) => dirs.forEach(rm),
+ },
+ {
+ key: "data",
+ label: "Delete AgentHub's database and data directory",
+ find: dataDirs,
+ remove: (paths) => {
+ const refused = paths.filter((p) => fs.statSync(p).isDirectory() && !isSafeDataDir(p));
+ paths.filter((p) => !refused.includes(p)).forEach(rm);
+ if (refused.length)
+ throw new Error(`not deleted (not clearly AgentHub's): ${refused.join(", ")}`);
+ },
+ },
+ {
+ key: "own-pane",
+ label: "Close the terminal this runs in",
+ find: () => (self && io.tmux.listSessions(self).includes(self) ? [self] : []),
+ remove: () => io.tmux.killSession(self),
+ },
+ ];
+}
+
+/** What would be removed: `[{key, label, items}]`, only steps with something to do. */
+function plan(io = defaultIo()) {
+ return steps(io)
+ .map((s) => {
+ try {
+ return { key: s.key, label: s.label, items: s.find() };
+ } catch (err) {
+ return { key: s.key, label: s.label, items: [], error: err.message };
+ }
+ })
+ .filter((s) => s.items.length || s.error);
+}
+
+/**
+ * Run every step. A failing step is logged and the rest still run — half an
+ * uninstall that stops at the first error is worse than one that reports it.
+ * @returns {{ok: boolean, log: string, steps: object[]}}
+ */
+function run({ io = defaultIo(), onLine = () => {} } = {}) {
+ const file = logPath();
+ const log = (line) => {
+ onLine(line);
+ try {
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
+ } catch {
+ /* best effort */
+ }
+ };
+ log(`AgentHub uninstall started (from ${REPO_ROOT})`);
+ const results = [];
+ for (const s of steps(io)) {
+ let items;
+ try {
+ items = s.find();
+ if (!items.length) continue;
+ const note = s.remove(items);
+ results.push({ key: s.key, status: "done" });
+ log(`✔ ${s.label}${typeof note === "string" ? ` — ${note}` : ""}`);
+ for (const i of items) log(` ${i}`);
+ } catch (err) {
+ results.push({ key: s.key, status: "failed", error: err.message });
+ log(`✖ ${s.label} — ${err.message}`);
+ }
+ }
+ const ok = results.every((r) => r.status === "done");
+ log(ok ? "AgentHub uninstalled." : "AgentHub uninstalled with errors (see above).");
+ return { ok, log: file, steps: results };
+}
+
+module.exports = { plan, run, logPath, isSafeDataDir, isOurHook, steps };
+
+if (require.main === module) {
+ if (!process.argv.includes("--yes")) {
+ console.error("usage: node scripts/uninstall.js --yes");
+ process.exit(2);
+ }
+ loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
+ process.exit(run({ onLine: (l) => console.log(l) }).ok ? 0 : 1);
+}
diff --git a/server/README.md b/server/README.md
index fe2ee380..c16de763 100644
--- a/server/README.md
+++ b/server/README.md
@@ -713,6 +713,9 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
| `GET` | `/api/settings/privacy` | `{redact_secrets, ignore_cwd[], env_owned: {redact_secrets, ignore_cwd}}` (`server/lib/privacy.js`) |
| `PUT` | `/api/settings/privacy` | `{redact_secrets?: boolean, ignore_cwd?: string[]}` → persisted to the settings file as `AGENTHUB_REDACT_SECRETS` / `AGENTHUB_IGNORE_CWD` (`sameOriginGuard`). 400 `EBADPATH` (relative, a root, a control char or the list separator), 409 `EENVOWNED`, 500 `ESAVE` |
+| `GET` | `/api/settings/backup` | `{dir, hour, keep, running, last, files[], dir_error, env_owned: {dir, hour, keep}}` — the daily database backup (`server/lib/db-backup.js`); `files` are AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` in `dir`, newest first; `last` is this process's last run |
+| `PUT` | `/api/settings/backup` | `{dir?: string, hour?: 0–23, keep?: 1–365}` → persisted as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP` (`sameOriginGuard`); an empty `dir` turns backups off. 400 `INVALID_PATH` (relative, missing, not a directory, not writable) / `INVALID_INPUT`, 409 `EENVOWNED`, 500 `ESAVE` |
+| `POST` | `/api/settings/backup/run` | Start a backup now and answer `202` at once (poll `GET` while `running`); 400 `ENOBACKUPDIR` (`sameOriginGuard`) |
| `GET` | `/api/settings/push-token` | `{enabled, env_owned}` — never the token |
| `POST` | `/api/settings/push-token` | Make a new push-ingest token (the old one stops working) and return it this ONCE as `token`; only its SHA-256 is stored (`AGENTHUB_PUSH_TOKEN_SHA256`). `sameOriginGuard`; 409 `EENVOWNED` |
| `DELETE` | `/api/settings/push-token` | Revoke it: push ingest off (`sameOriginGuard`) |
@@ -1599,6 +1602,10 @@ DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms);
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
+DASHBOARD_BACKUP_TICK_MS=60000 # Daily-backup scheduler beat (ms); 0 disables it. The folder/hour/count are set in Settings → Data → Backup:
+AGENTHUB_BACKUP_DIR= # folder for agenthub-YYYYMMDD-HHMMSS.db (empty = off); only those files are ever rotated out
+AGENTHUB_BACKUP_HOUR=2 # server local hour (0–23); a day missed while the server was down runs at the next start
+AGENTHUB_BACKUP_KEEP=7 # backups kept (1–365)
# Remote Data Sources (SSH pull; see the Remote Data Sources section)
DASHBOARD_REMOTE_SYNC_MS=15000 # Remote-source sync poll interval (ms); 0 disables the poller
diff --git a/server/__tests__/db-backup.test.js b/server/__tests__/db-backup.test.js
new file mode 100644
index 00000000..5a46c3df
--- /dev/null
+++ b/server/__tests__/db-backup.test.js
@@ -0,0 +1,198 @@
+/**
+ * @file Tests server/lib/db-backup.js and its Settings routes — a backup is a
+ * complete, openable copy of the live database under a server-made name, no
+ * `.partial` is left behind, rotation deletes only AgentHub's own
+ * `agenthub-YYYYMMDD-HHMMSS.db` files (never a look-alike, a symlink or anything
+ * else in the folder), the once-a-day schedule decision, and the folder check.
+ * The settings file and the folders are temp paths.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const os = require("node:os");
+const fs = require("node:fs");
+const path = require("node:path");
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+
+const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-backup-"));
+process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
+process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
+process.env.DASHBOARD_LIVENESS_PROBE = "0";
+for (const k of ["AGENTHUB_BACKUP_DIR", "AGENTHUB_BACKUP_HOUR", "AGENTHUB_BACKUP_KEEP"]) {
+ delete process.env[k];
+}
+
+const { createApp, startServer } = require("../index");
+const { db, Database } = require("../db");
+const backup = require("../lib/db-backup");
+
+const folder = (name) => {
+ const dir = path.join(ROOT, name);
+ fs.mkdirSync(dir, { recursive: true });
+ return dir;
+};
+
+let server;
+let BASE;
+before(async () => {
+ server = await startServer(createApp(), 0);
+ BASE = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => {
+ server?.close();
+ fs.rmSync(ROOT, { recursive: true, force: true });
+});
+
+async function call(method, urlPath, body) {
+ const res = await fetch(BASE + urlPath, {
+ method,
+ headers: body ? { "content-type": "application/json" } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ return { status: res.status, body: await res.json() };
+}
+
+describe("validateBackupDir", () => {
+ it("accepts an existing writable folder and resolves it", () => {
+ const dir = folder("ok");
+ assert.equal(backup.validateBackupDir(`${dir}/./`), dir);
+ });
+ for (const [label, input] of [
+ ["a relative path", "backups"],
+ ["a missing folder", path.join(ROOT, "nope")],
+ ["a control character", `${ROOT}/a\nb`],
+ ]) {
+ it(`refuses ${label}`, () => {
+ assert.throws(() => backup.validateBackupDir(input), { code: "INVALID_PATH" });
+ });
+ }
+ it("refuses a file", () => {
+ const file = path.join(ROOT, "a-file");
+ fs.writeFileSync(file, "x");
+ assert.throws(() => backup.validateBackupDir(file), { code: "INVALID_PATH" });
+ });
+});
+
+describe("runBackup", () => {
+ it("writes a complete copy under a server-made name and leaves no .partial", async () => {
+ const dir = folder("run");
+ process.env.AGENTHUB_BACKUP_DIR = dir;
+ db.prepare(
+ "INSERT INTO sessions (id, name, status, started_at) VALUES ('backup-marker', 'm', 'completed', '2026-01-01T00:00:00Z')"
+ ).run();
+ const out = await backup.runBackup();
+ assert.match(out.file, /^agenthub-\d{8}-\d{6}\.db$/);
+ assert.deepEqual(fs.readdirSync(dir), [out.file]);
+ const copy = new Database(path.join(dir, out.file), { readonly: true });
+ try {
+ assert.equal(copy.pragma("quick_check", { simple: true }), "ok");
+ assert.equal(
+ copy.prepare("SELECT name FROM sessions WHERE id = 'backup-marker'").get().name,
+ "m"
+ );
+ } finally {
+ copy.close();
+ }
+ assert.equal(backup.lastResult().ok, true);
+ });
+
+ it("refuses with no folder set", async () => {
+ delete process.env.AGENTHUB_BACKUP_DIR;
+ await assert.rejects(backup.runBackup(), { code: "ENOBACKUPDIR" });
+ });
+});
+
+describe("rotate", () => {
+ it("keeps the newest N of its own files and touches nothing else", () => {
+ const dir = folder("rotate");
+ const own = [
+ "agenthub-20261001-020000.db",
+ "agenthub-20261002-020000.db",
+ "agenthub-20261003-020000.db",
+ "agenthub-20261004-020000.db",
+ ];
+ const others = ["notes.txt", "agenthub-latest.db", "agenthub-20261001-020000.db.bak", "my.db"];
+ for (const f of [...own, ...others]) fs.writeFileSync(path.join(dir, f), "x");
+ const outside = path.join(ROOT, "outside.db");
+ fs.writeFileSync(outside, "keep me");
+ fs.symlinkSync(outside, path.join(dir, "agenthub-20250101-000000.db"));
+
+ assert.deepEqual(backup.rotate(dir, 2).sort(), own.slice(0, 2));
+ assert.deepEqual(
+ fs.readdirSync(dir).sort(),
+ [...own.slice(2), ...others, "agenthub-20250101-000000.db"].sort()
+ );
+ assert.equal(fs.readFileSync(outside, "utf8"), "keep me");
+ });
+});
+
+describe("dueNow", () => {
+ const cfg = { dir: "/backups", hour: 2, keep: 7 };
+ const at = (h) => new Date(2026, 9, 8, h, 30);
+ const today = [{ name: "agenthub-20261008-020000.db" }];
+ const yesterday = [{ name: "agenthub-20261007-020000.db" }];
+ it("waits for the hour", () => assert.equal(backup.dueNow(at(1), cfg, [], null), false));
+ it("runs once the hour passed with no backup today", () =>
+ assert.equal(backup.dueNow(at(9), cfg, yesterday, null), true));
+ it("skips a day that already has one", () =>
+ assert.equal(backup.dueNow(at(9), cfg, today, null), false));
+ it("tries once a day, not every minute after a failure", () =>
+ assert.equal(backup.dueNow(at(9), cfg, [], "20261008"), false));
+ it("does nothing with no folder", () =>
+ assert.equal(backup.dueNow(at(9), { ...cfg, dir: "" }, [], null), false));
+});
+
+describe("Settings routes", () => {
+ it("GET answers the defaults with no folder", async () => {
+ delete process.env.AGENTHUB_BACKUP_DIR;
+ const { status, body } = await call("GET", "/api/settings/backup");
+ assert.equal(status, 200);
+ assert.equal(body.dir, "");
+ assert.equal(body.hour, 2);
+ assert.equal(body.keep, 7);
+ assert.deepEqual(body.files, []);
+ });
+
+ it("PUT validates every field", async () => {
+ for (const bad of [
+ { dir: "relative" },
+ { hour: 24 },
+ { hour: 1.5 },
+ { keep: 0 },
+ { keep: "7" },
+ ]) {
+ const { status } = await call("PUT", "/api/settings/backup", bad);
+ assert.equal(status, 400, JSON.stringify(bad));
+ }
+ });
+
+ it("PUT persists to the settings file; POST /run starts a backup", async () => {
+ const dir = folder("route");
+ const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5 });
+ assert.equal(put.status, 200);
+ assert.equal(put.body.dir, dir);
+ const saved = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
+ assert.match(saved, new RegExp(`^AGENTHUB_BACKUP_DIR=${dir}$`, "m"));
+ assert.match(saved, /^AGENTHUB_BACKUP_HOUR=3$/m);
+ assert.match(saved, /^AGENTHUB_BACKUP_KEEP=5$/m);
+
+ const run = await call("POST", "/api/settings/backup/run");
+ assert.equal(run.status, 202);
+ for (let i = 0; i < 100 && (await call("GET", "/api/settings/backup")).body.running; i++) {
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ const { body } = await call("GET", "/api/settings/backup");
+ assert.equal(body.last.ok, true);
+ assert.equal(body.files.length, 1);
+ assert.match(body.files[0].name, /^agenthub-\d{8}-\d{6}\.db$/);
+ });
+
+ it("PUT with an empty folder turns backups off", async () => {
+ const { status, body } = await call("PUT", "/api/settings/backup", { dir: "" });
+ assert.equal(status, 200);
+ assert.equal(body.dir, "");
+ const run = await call("POST", "/api/settings/backup/run");
+ assert.equal(run.status, 400);
+ assert.equal(run.body.error.code, "ENOBACKUPDIR");
+ });
+});
diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js
index 65e9081e..21001e2d 100644
--- a/server/__tests__/pty-attach.test.js
+++ b/server/__tests__/pty-attach.test.js
@@ -42,6 +42,7 @@ describe("pty-attach", () => {
beforeEach(() => {
fakePty = makeFakePty();
ptyAttach.__setSpawnImpl(() => fakePty);
+ ptyAttach.__resetGroups();
tmux.__setExecImpl(() => "");
});
afterEach(() => {
@@ -164,6 +165,44 @@ describe("pty-attach", () => {
assert.equal(closed, true);
});
+ it("holds a second viewer at the first one's size until it takes the lead", () => {
+ // Two browsers of different sizes on one pane: tmux's `window-size latest`
+ // resized the window under whichever was not typing. One drives; the other
+ // renders the driver's grid.
+ const ptys = [];
+ ptyAttach.__setSpawnImpl((_f, _a, opts) => {
+ const p = makeFakePty();
+ p.opts = opts;
+ ptys.push(p);
+ return p;
+ });
+ const resize = (ws, cols, rows, drive) =>
+ ws.__emitter.emit(
+ "message",
+ Buffer.from(JSON.stringify({ type: "resize", cols, rows, drive })),
+ {
+ binary: false,
+ }
+ );
+ const a = makeFakeWs();
+ const b = makeFakeWs();
+ ptyAttach.attach(a, "agenthub-lane-1", { cols: 200, rows: 50 });
+ ptyAttach.attach(b, "agenthub-lane-1", { cols: 100, rows: 30 });
+ assert.deepEqual([ptys[1].opts.cols, ptys[1].opts.rows], [200, 50]);
+ assert.deepEqual(JSON.parse(b.__sent[0].data), { type: "size", cols: 200, rows: 50 });
+
+ resize(b, 100, 30); // its own fit, not a takeover: nothing moves
+ assert.equal(ptys[1].__writes.length, 0);
+
+ resize(b, 100, 30, true); // a keystroke in b
+ assert.deepEqual(ptys[1].__writes.at(-1), { resize: [100, 30] });
+ assert.deepEqual(ptys[0].__writes.at(-1), { resize: [100, 30] });
+ assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "size", cols: 100, rows: 30 });
+
+ b.__emitter.emit("close"); // the driver leaves: the rest size themselves
+ assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
+ });
+
it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js
index a5ad088d..0eb4b7ca 100644
--- a/server/__tests__/pty-run.test.js
+++ b/server/__tests__/pty-run.test.js
@@ -524,6 +524,46 @@ describe("pty-run", () => {
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
});
+ it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
+ process.env.DASHBOARD_DATA_DIR = TMP;
+ t.after(() => delete process.env.DASHBOARD_DATA_DIR);
+ // tmux refuses a message past 16 KB (`command too long`).
+ const prompt = `-${"x".repeat(20_000)}\nend`;
+ const call = captureNewSession(() =>
+ pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", initialPrompt: prompt })
+ );
+ assert.ok(Buffer.byteLength(call.join("")) < 8 * 1024);
+ const argv = paneArgv(call);
+ assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
+ const file = argv[4];
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
+ assert.equal(argv[5], "claude");
+ // Run the reader with a stand-in CLI: the prompt arrives as ONE last
+ // argument, leading-dash guard included, and the file is gone after.
+ const { execFileSync } = require("node:child_process");
+ const out = execFileSync(argv[0], [
+ ...argv.slice(1, 5),
+ process.execPath,
+ "-e",
+ "process.stdout.write(process.argv.at(-1))",
+ ]).toString();
+ assert.equal(out, ` ${prompt}`);
+ assert.ok(!fs.existsSync(file));
+ });
+
+ it("refuses a prompt no CLI can take as one argument, before starting anything", () => {
+ const calls = [];
+ tmux.__setExecImpl((args) => {
+ calls.push(args);
+ return "";
+ });
+ assert.throws(
+ () => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", initialPrompt: "é".repeat(70_000) }),
+ { code: "EBADPROMPT" }
+ );
+ assert.equal(calls.length, 0);
+ });
+
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
diff --git a/server/__tests__/quit.test.js b/server/__tests__/quit.test.js
index 3e85d2a2..edc2d218 100644
--- a/server/__tests__/quit.test.js
+++ b/server/__tests__/quit.test.js
@@ -2,7 +2,8 @@
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
* POST /api/settings/quit): the stopped flag, stopping through the service
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
- * and the route killing every lane pane before it stops. tmux runs only in the
+ * and the route killing every lane pane before it stops; the uninstall route's
+ * confirm gate. tmux runs only in the
* isolated server `npm run test:server` sets up.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -127,5 +128,16 @@ describe(
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
}
});
+
+ // Never with `confirm: true` here: that runs scripts/uninstall.js for real.
+ it("refuses an uninstall without confirm: true", async () => {
+ const res = await fetch(`${base}/api/settings/uninstall`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(res.status, 400);
+ assert.equal((await res.json()).error.code, "ECONFIRM");
+ });
}
);
diff --git a/server/__tests__/scope.test.js b/server/__tests__/scope.test.js
index cb2c8024..a79130c6 100644
--- a/server/__tests__/scope.test.js
+++ b/server/__tests__/scope.test.js
@@ -20,9 +20,9 @@ process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const scope = require("../lib/scope");
-const member = (id, username = `u${id}`) => ({ user: { id, username, role: "member" } });
+const member = (id, username = `u${id}`) => ({ user: { id, username, role: "developer" } });
const admin = { user: { id: 99, username: "boss", role: "admin" } };
-const analyst = { user: { id: 98, username: "ana", role: "analyst" } };
+const analyst = { user: { id: 98, username: "ana", role: "analyst" }, method: "GET" };
const local = {};
before(() => {
@@ -30,6 +30,7 @@ before(() => {
lane.run("/w/one", 1); // id 1, user 1
lane.run("/w/two", 2); // id 2, user 2
lane.run("/w/host", null); // id 3, nobody's
+ lane.run("/w/ana", 98); // id 4, the analyst's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
@@ -63,6 +64,16 @@ describe("scope", () => {
assert.deepEqual(visibleSessions(member(3)), []);
});
+ it("limits an analyst's writes to their own workspaces", () => {
+ const write = { ...analyst, method: "POST" };
+ assert.equal(scope.memberId(analyst), null);
+ assert.equal(scope.memberId(write), 98);
+ assert.equal(scope.canSeeLane(write, 2), false);
+ assert.equal(scope.canSeeLane(write, 4), true);
+ assert.equal(scope.canSeeRun(write, "agenthub-lane-1-2"), false);
+ assert.deepEqual(visibleSessions(write), []);
+ });
+
it("shows admin, analyst and local mode everything", () => {
for (const req of [admin, analyst, local]) {
assert.equal(visibleSessions(req).length, 5);
@@ -183,12 +194,22 @@ describe("scopeGuard", () => {
);
});
- it("lets admin, analyst and local requests through", () => {
- for (const user of [admin.user, analyst.user, undefined]) {
+ it("lets admin and local requests through", () => {
+ for (const user of [admin.user, undefined]) {
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
}
});
+
+ it("lets an analyst read every row but write only their own", () => {
+ const user = analyst.user;
+ assert.equal(run({ user, path: "/sessions/s2" }), 200);
+ assert.equal(run({ user, path: "/lanes/2", method: "HEAD" }), 200);
+ assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
+ assert.equal(run({ user, path: "/sessions/s2", method: "PATCH" }), 404);
+ assert.equal(run({ user, path: "/lanes/2", method: "DELETE" }), 404);
+ assert.equal(run({ user, path: "/lanes/4", method: "DELETE" }), 200);
+ });
});
describe("broadcastAudience", () => {
diff --git a/server/__tests__/server-mode-aggregates.test.js b/server/__tests__/server-mode-aggregates.test.js
index 38c930b7..39534925 100644
--- a/server/__tests__/server-mode-aggregates.test.js
+++ b/server/__tests__/server-mode-aggregates.test.js
@@ -58,8 +58,8 @@ const SESSIONS = [
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
- const mia = await auth.createUser("mia", "member-pass-1", "member");
- const max = await auth.createUser("max", "member-pass-2", "member");
+ const mia = await auth.createUser("mia", "member-pass-1", "developer");
+ const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
diff --git a/server/__tests__/server-mode-auth.test.js b/server/__tests__/server-mode-auth.test.js
index 035691dc..6ff0e0f6 100644
--- a/server/__tests__/server-mode-auth.test.js
+++ b/server/__tests__/server-mode-auth.test.js
@@ -55,7 +55,7 @@ before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
- await auth.createUser("mia", "member-pass-1", "member");
+ await auth.createUser("mia", "member-pass-1", "developer");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
@@ -73,6 +73,10 @@ describe("local mode (the default)", () => {
assert.equal((await call("GET", "/api/sessions")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
+ // The one local viewer keeps their prefs too.
+ const chat = { prefs: { "agenthub.chatUi": "1" } };
+ assert.equal((await call("PUT", "/api/me/prefs", { body: chat })).status, 200);
+ assert.equal((await call("GET", "/api/me/prefs")).body.prefs["agenthub.chatUi"], "1");
});
});
@@ -101,15 +105,25 @@ describe("server mode", () => {
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
const cookie = ok.setCookie.split(";")[0];
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
- assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "member");
+ assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "developer");
});
- it("keeps an analyst read-only and the host's config writes an admin's", async () => {
+ it("lets an analyst work but keeps the host's config writes an admin's", async () => {
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
+ // Past the role gate: the route itself answers the empty body.
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
- assert.equal(w.status, 403);
- assert.equal(w.body.error.code, "EREADONLY");
+ assert.ok(![401, 403].includes(w.status), `got ${w.status}`);
+ for (const [method, url] of [
+ ["PUT", "/api/settings/no-such-route"],
+ ["POST", "/api/settings/quit"],
+ ["POST", "/api/alerts/rules"],
+ ["DELETE", "/api/alerts/rules/x"],
+ ]) {
+ const r = await call(method, url, { cookie: analyst, body: {} });
+ assert.equal(r.status, 403, `${method} ${url}`);
+ assert.equal(r.body.error.code, "EADMIN");
+ }
const member = await loginAs("mia", "member-pass-1");
// A path no router answers: the guard decides before any handler could
@@ -149,6 +163,30 @@ describe("server mode", () => {
auth.setDisabled("mia", false);
});
+ it("keeps each user's UI prefs apart, and only for a logged-in caller", async () => {
+ assert.equal((await call("GET", "/api/me/prefs")).status, 401);
+ const boss = await loginAs("boss", "admin-pass-1");
+ const ana = await loginAs("ana", "analyst-pass-1");
+ const on = { prefs: { "agenthub.chatUi": "1" } };
+ const put = await call("PUT", "/api/me/prefs", { cookie: boss, body: on });
+ assert.deepEqual(put.body.prefs, { "agenthub.chatUi": "1" });
+ assert.equal(
+ (await call("GET", "/api/me/prefs", { cookie: ana })).body.prefs["agenthub.chatUi"],
+ undefined
+ );
+ for (const prefs of [{ "agenthub.chatUi": true }, { userKey: "u1" }]) {
+ const bad = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs } });
+ assert.equal(bad.status, 400);
+ assert.equal(bad.body.error.code, "EBADPREFS");
+ }
+ // null removes the key: back to the default.
+ await call("PUT", "/api/me/prefs", {
+ cookie: boss,
+ body: { prefs: { "agenthub.chatUi": null } },
+ });
+ assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
+ });
+
it("checks a WebSocket upgrade's login and Origin", async () => {
const cookie = await loginAs("boss", "admin-pass-1");
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
@@ -157,9 +195,10 @@ describe("server mode", () => {
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
+ // Which runs an analyst may type into is the run check's (canSeeRun), not this one's.
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
- false
+ true
);
});
diff --git a/server/__tests__/server-mode-review-fixes.test.js b/server/__tests__/server-mode-review-fixes.test.js
index d0f3f68f..6c7e7f96 100644
--- a/server/__tests__/server-mode-review-fixes.test.js
+++ b/server/__tests__/server-mode-review-fixes.test.js
@@ -52,8 +52,8 @@ async function login(username, password) {
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
- const mia = await auth.createUser("mia", "member-pass-1", "member");
- const max = await auth.createUser("max", "member-pass-2", "member");
+ const mia = await auth.createUser("mia", "member-pass-1", "developer");
+ const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
@@ -288,7 +288,7 @@ describe("server mode review fixes, second round", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
- assert.equal(scope.audienceIncludes(a, { id: 1, role: "member" }), true);
+ assert.equal(scope.audienceIncludes(a, { id: 1, role: "developer" }), true);
});
});
diff --git a/server/__tests__/server-mode-rows.test.js b/server/__tests__/server-mode-rows.test.js
index 1197fd88..6ed11433 100644
--- a/server/__tests__/server-mode-rows.test.js
+++ b/server/__tests__/server-mode-rows.test.js
@@ -50,8 +50,8 @@ const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
- const alice = await auth.createUser("alice", "alice-pass-1", "member");
- const bob = await auth.createUser("bob", "bob-pass-1", "member");
+ const alice = await auth.createUser("alice", "alice-pass-1", "developer");
+ const bob = await auth.createUser("bob", "bob-pass-1", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
@@ -228,6 +228,17 @@ describe("server mode: list and aggregate routes", () => {
assert.equal(snap.status, 404);
});
+ it("tells an analyst which alerts they may acknowledge, and refuses the rest", async () => {
+ const feed = (await get("/api/alerts", "ana")).alerts;
+ assert.equal(feed.length, 5);
+ // ana owns no workspace here, so no alert is hers to acknowledge.
+ assert.ok(feed.every((a) => a.can_ack === false));
+ const bobs = feed.find((a) => a.session_id === "sB");
+ assert.equal((await call("POST", `/api/alerts/${bobs.id}/ack`, { as: "ana" })).status, 404);
+ // An admin acknowledges anything: no per-alert answer is needed.
+ assert.ok((await get("/api/alerts", "boss")).alerts.every((a) => !("can_ack" in a)));
+ });
+
it("lets a member acknowledge only their own alerts", async () => {
const r = await call("POST", "/api/alerts/ack-all", { as: "alice" });
assert.equal(r.status, 200);
diff --git a/server/__tests__/server-mode-user-admin.test.js b/server/__tests__/server-mode-user-admin.test.js
index 10f595ec..0b04fcbd 100644
--- a/server/__tests__/server-mode-user-admin.test.js
+++ b/server/__tests__/server-mode-user-admin.test.js
@@ -56,7 +56,7 @@ before(async () => {
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
- ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
+ ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.lane = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
.run().lastInsertRowid;
@@ -76,14 +76,14 @@ describe("managing accounts from the dashboard", () => {
body: {
username: "neo",
password: "neo-pass-123",
- role: "member",
+ role: "developer",
currentPassword: BOSS_PASS,
},
});
assert.equal(r.status, 201);
assert.deepEqual(
{ username: r.body.user.username, role: r.body.user.role },
- { username: "neo", role: "member" }
+ { username: "neo", role: "developer" }
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
@@ -179,14 +179,14 @@ describe("managing accounts from the dashboard", () => {
assert.equal(bad.status, 400);
const none = await call("PATCH", "/api/users/9999", {
cookie: boss,
- body: { role: "member", currentPassword: BOSS_PASS },
+ body: { role: "developer", currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("never lets an admin demote or disable themselves", async () => {
const boss = await login("boss", BOSS_PASS);
- for (const body of [{ role: "member" }, { disabled: true }]) {
+ for (const body of [{ role: "developer" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
@@ -239,7 +239,7 @@ describe("managing accounts from the dashboard", () => {
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
- assert.equal(auth.findUser("mia").role, "member");
+ assert.equal(auth.findUser("mia").role, "developer");
});
});
diff --git a/server/__tests__/server-mode-users.test.js b/server/__tests__/server-mode-users.test.js
index 231a2660..ec0f5a38 100644
--- a/server/__tests__/server-mode-users.test.js
+++ b/server/__tests__/server-mode-users.test.js
@@ -47,7 +47,7 @@ before(async () => {
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
- ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
+ ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
diff --git a/server/__tests__/server-mode-workflows.test.js b/server/__tests__/server-mode-workflows.test.js
index 4831b672..42292b9f 100644
--- a/server/__tests__/server-mode-workflows.test.js
+++ b/server/__tests__/server-mode-workflows.test.js
@@ -64,8 +64,8 @@ function seedSession(id, laneId, skill, wfStatus) {
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
- const mia = await auth.createUser("mia", "member-pass-1", "member");
- const max = await auth.createUser("max", "member-pass-2", "member");
+ const mia = await auth.createUser("mia", "member-pass-1", "developer");
+ const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
diff --git a/server/__tests__/server-mode-workspaces.test.js b/server/__tests__/server-mode-workspaces.test.js
index 3634c51d..6e868512 100644
--- a/server/__tests__/server-mode-workspaces.test.js
+++ b/server/__tests__/server-mode-workspaces.test.js
@@ -63,8 +63,8 @@ before(async () => {
}
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
- const alice = await auth.createUser("alice", "alice-pass-1", "member");
- const bob = await auth.createUser("bob", "bob-pass-1", "member");
+ const alice = await auth.createUser("alice", "alice-pass-1", "developer");
+ const bob = await auth.createUser("bob", "bob-pass-1", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
diff --git a/server/__tests__/uninstall.test.js b/server/__tests__/uninstall.test.js
new file mode 100644
index 00000000..e4487b2d
--- /dev/null
+++ b/server/__tests__/uninstall.test.js
@@ -0,0 +1,249 @@
+/**
+ * @file Tests Uninstall AgentHub (scripts/uninstall.js, `agenthub uninstall`,
+ * POST /api/settings/uninstall): on a throwaway HOME with every footprint in
+ * place, everything of AgentHub's goes and everything else stays — foreign
+ * hooks, other plugins and marketplaces, conversations, worktrees. Service
+ * manager, tmux, servers and the `claude` CLI are fakes; nothing real is touched.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it, before, 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 HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-uninstall-"));
+const CH = path.join(HOME, ".claude");
+const saved = { ...process.env };
+Object.assign(process.env, {
+ HOME,
+ CLAUDE_HOME: CH,
+ CODEX_HOME: path.join(HOME, ".codex"),
+ AGENTHUB_GEMINI_DIR: path.join(HOME, ".gemini"),
+ LANES_ROOT: path.join(CH, "agenthub-lanes"),
+ AGENTHUB_SETTINGS_FILE: path.join(HOME, ".config", "agenthub", "settings.env"),
+ TMPDIR: path.join(HOME, "tmp"),
+});
+delete process.env.DASHBOARD_DATA_DIR;
+delete process.env.DASHBOARD_DB_PATH;
+delete process.env.AGENTHUB_RUN_ID;
+
+const uninstall = require("../../scripts/uninstall");
+const codexHooks = require("../lib/codex-hooks");
+const agyHooks = require("../lib/antigravity-hooks");
+
+const p = (...parts) => path.join(HOME, ...parts);
+const write = (file, body) => {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body, null, 2));
+};
+const json = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
+const cmd = (c) => ({ hooks: [{ type: "command", command: c }] });
+
+function fakeIo() {
+ const calls = [];
+ let sessions = ["agenthub-lane-1", "agenthub-lane-1-2", "work"];
+ let pids = [111];
+ const installed = path.join(CH, "plugins", "installed_plugins.json");
+ const known = path.join(CH, "plugins", "known_marketplaces.json");
+ return {
+ calls,
+ svc: {
+ label: "systemd --user service",
+ systemctlAvailable: () => true,
+ unitIsOurs: () => true,
+ unitPath: () => "/fake/agenthub.service",
+ uninstallUserService: () => (calls.push("service"), "removed"),
+ },
+ tmux: {
+ listSessions: (prefix) => sessions.filter((s) => s.startsWith(prefix)),
+ killSession: (n) => {
+ calls.push(`kill ${n}`);
+ sessions = sessions.filter((s) => s !== n);
+ },
+ },
+ livePids: () => pids,
+ stopDashboard: () => (calls.push("stop"), (pids = [])),
+ liveTray: () => null,
+ stopTray: () => {},
+ claude: () => "claude",
+ // Plays Claude Code: drops the record it was asked to drop.
+ run: (_c, args) => {
+ calls.push(args.join(" "));
+ if (args[1] === "uninstall") {
+ const d = json(installed);
+ delete d.plugins[args[2]];
+ write(installed, d);
+ } else {
+ const d = json(known);
+ delete d[args[3]];
+ write(known, d);
+ }
+ return { status: 0 };
+ },
+ kill: (pid) => {
+ if (pid !== 4242) throw new Error("ESRCH");
+ },
+ sleep: () => {},
+ };
+}
+
+before(() => {
+ fs.mkdirSync(p("tmp"), { recursive: true });
+ write(p(".claude", "settings.json"), {
+ hooks: {
+ PreToolUse: [cmd('node "/x/agenthub/scripts/hook-handler.js" PreToolUse'), cmd("their-hook")],
+ SessionStart: [cmd('node "/c/agenthub/v1/scripts/plugin-bootstrap.js"')],
+ },
+ statusLine: { type: "command", command: 'node "/x/agenthub/statusline/statusline.js"' },
+ theme: "dark",
+ });
+ write(p(".claude", "settings.json.agenthub-bak"), "{}");
+ write(p(".claude", "plugins", "installed_plugins.json"), {
+ plugins: {
+ "agenthub@sg": [{ scope: "user", installPath: p(".claude/plugins/cache/sg/agenthub/v1") }],
+ "agenthub-analytics@agenthub": [{ scope: "user" }],
+ "sg-all@sg": [{ scope: "user" }],
+ },
+ });
+ write(p(".claude", "plugins", "known_marketplaces.json"), { sg: {}, agenthub: {} });
+ write(p(".claude", "plugins", "cache", "sg", "agenthub", "v1", "package.json"), "{}");
+ write(p(".claude", "plugins", "cache", "sg", "sg-all", "v1", "package.json"), "{}");
+ write(p(".claude", "projects", "repo", "session.jsonl"), "{}\n");
+ write(p(".claude", ".agenthub.json"), "{}");
+ write(p(".claude", "agenthub-proxies", "a.json"), "{}");
+ write(p(".claude", "agenthub-git-credentials", "credentials.json"), "{}");
+ write(p(".claude", "agenthub", "dashboard.db"), "db");
+ write(p(".claude", "agenthub", "runtime", "state.json"), "{}");
+ write(p(".claude", "agenthub-lanes", ".state", "lane1", "run", "web.pid"), "4242");
+ write(p(".claude", "agenthub-lanes", "repo__feature", "README.md"), "user code");
+ write(p(".codex", "hooks.json"), { hooks: { Stop: [cmd("their-codex-hook")] } });
+ codexHooks.install();
+ write(p(".codex", "work.config.toml"), "# Written by AgentHub — AI Proxy profile work.\n");
+ write(p(".codex", "mine.config.toml"), "model = 'x'\n");
+ write(p(".codex", "sessions", "rollout-1.jsonl"), "{}\n");
+ agyHooks.install();
+ write(p(".local", "bin", "agenthub"), "#!/bin/sh\n# agenthub-plugin-launcher\n");
+ write(p(".config", "agenthub", "settings.env"), "CODEX_HOME=x\n");
+});
+
+after(() => {
+ process.env = saved;
+ fs.rmSync(HOME, { recursive: true, force: true });
+});
+
+describe("uninstall", () => {
+ it("plans every footprint and leaves the machine untouched", () => {
+ const io = fakeIo();
+ const keys = uninstall.plan(io).map((s) => s.key);
+ assert.deepEqual(keys, [
+ "panes",
+ "service",
+ "servers",
+ "stacks",
+ "claude-hooks",
+ "statusline",
+ "codex-hooks",
+ "antigravity-hooks",
+ "codex-proxies",
+ "plugin",
+ "files",
+ "data",
+ ]);
+ assert.deepEqual(io.calls, []);
+ assert.ok(fs.existsSync(p(".claude", "agenthub", "dashboard.db")));
+ });
+
+ it("removes AgentHub's and keeps everything else", () => {
+ const io = fakeIo();
+ const r = uninstall.run({ io });
+ assert.equal(r.ok, true, JSON.stringify(r.steps));
+
+ // Order: panes before the unit before the server; plugin via the CLI.
+ assert.deepEqual(io.calls, [
+ "kill agenthub-lane-1",
+ "kill agenthub-lane-1-2",
+ "service",
+ "stop",
+ "plugin uninstall agenthub@sg -s user -y",
+ "plugin uninstall agenthub-analytics@agenthub -s user -y",
+ "plugin marketplace remove agenthub",
+ ]);
+
+ const settings = json(p(".claude", "settings.json"));
+ assert.deepEqual(settings.hooks, { PreToolUse: [cmd("their-hook")] });
+ assert.equal(settings.statusLine, undefined);
+ assert.equal(settings.theme, "dark");
+
+ const codex = json(p(".codex", "hooks.json"));
+ assert.deepEqual(Object.keys(codex.hooks), ["Stop"]);
+ assert.ok(!fs.existsSync(p(".codex", "agenthub-hook.sh")));
+ assert.ok(!fs.existsSync(p(".codex", "work.config.toml")));
+ assert.ok(!fs.existsSync(p(".gemini", "config", "agenthub-hook.sh")));
+
+ for (const gone of [
+ ".claude/agenthub",
+ ".claude/agenthub-proxies",
+ ".claude/agenthub-git-credentials",
+ ".claude/.agenthub.json",
+ ".claude/settings.json.agenthub-bak",
+ ".claude/agenthub-lanes/.state",
+ ".claude/plugins/cache/sg/agenthub",
+ ".local/bin/agenthub",
+ ".config/agenthub",
+ ]) {
+ assert.ok(!fs.existsSync(p(gone)), `${gone} should be gone`);
+ }
+ for (const kept of [
+ ".claude/projects/repo/session.jsonl",
+ ".claude/agenthub-lanes/repo__feature/README.md",
+ ".claude/plugins/cache/sg/sg-all",
+ ".codex/mine.config.toml",
+ ".codex/sessions/rollout-1.jsonl",
+ ]) {
+ assert.ok(fs.existsSync(p(kept)), `${kept} should be kept`);
+ }
+ assert.deepEqual(Object.keys(json(p(".claude/plugins/known_marketplaces.json"))), ["sg"]);
+ assert.ok(fs.readFileSync(r.log, "utf8").includes("AgentHub uninstalled."));
+ });
+
+ it("finds nothing left on disk on a second run", () => {
+ const live = ["panes", "service", "servers"]; // the fakes' own processes
+ assert.deepEqual(
+ uninstall.plan(fakeIo()).filter((s) => !live.includes(s.key)),
+ []
+ );
+ });
+
+ it("keeps the cache when Claude Code still records the plugin", () => {
+ write(p(".claude", "plugins", "installed_plugins.json"), {
+ plugins: { "agenthub@sg": [{ scope: "user" }] },
+ });
+ write(p(".claude", "plugins", "cache", "sg", "agenthub", "v2", "package.json"), "{}");
+ const io = { ...fakeIo(), run: () => ({ status: 1, stderr: "boom" }) };
+ const r = uninstall.run({ io });
+ assert.equal(r.ok, false);
+ assert.equal(r.steps.find((s) => s.key === "plugin").status, "failed");
+ assert.ok(fs.existsSync(p(".claude", "plugins", "cache", "sg", "agenthub", "v2")));
+ });
+});
+
+describe("isSafeDataDir", () => {
+ it("never deletes the home, CLAUDE_HOME, their parents or a stranger's folder", () => {
+ assert.equal(uninstall.isSafeDataDir(HOME), false);
+ assert.equal(uninstall.isSafeDataDir(CH), false);
+ assert.equal(uninstall.isSafeDataDir(path.dirname(HOME)), false);
+ assert.equal(uninstall.isSafeDataDir("/"), false);
+ assert.equal(uninstall.isSafeDataDir(p("Documents")), false);
+ assert.equal(uninstall.isSafeDataDir(p(".claude", "agenthub")), true);
+ });
+});
+
+describe("Claude hook matching", () => {
+ it("is AgentHub's handler or bootstrap only", () => {
+ assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/hook-handler.js" Stop')), true);
+ assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/plugin-bootstrap.js"')), true);
+ assert.equal(uninstall.isOurHook(cmd("their-hook")), false);
+ });
+});
diff --git a/server/__tests__/users-role-migration.test.js b/server/__tests__/users-role-migration.test.js
new file mode 100644
index 00000000..b024cc5c
--- /dev/null
+++ b/server/__tests__/users-role-migration.test.js
@@ -0,0 +1,63 @@
+/**
+ * @file users-role-migration.test.js
+ * @description The `users` table migration that renamed the `member` role to
+ * `developer`: an existing database's members become developers, keep their
+ * logins, and the old value is refused afterwards.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { it, after } = require("node:test");
+const assert = require("node:assert/strict");
+const path = require("node:path");
+const fs = require("node:fs");
+const os = require("node:os");
+const { DatabaseSync } = require("node:sqlite");
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "role-migration-"));
+const DB = path.join(TMP, "dashboard.db");
+
+after(() => fs.rmSync(TMP, { recursive: true, force: true }));
+
+it("turns every member into a developer and keeps their logins", () => {
+ const old = new DatabaseSync(DB);
+ old.exec(`
+ CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
+ password_hash TEXT NOT NULL,
+ disabled INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ );
+ CREATE TABLE auth_sessions (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ expires_at TEXT NOT NULL
+ );
+ INSERT INTO users (username, role, password_hash) VALUES
+ ('boss', 'admin', 'x'), ('mia', 'member', 'x'), ('ana', 'analyst', 'x');
+ INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES ('t', 2, '2999-01-01');
+ `);
+ old.close();
+
+ process.env.DASHBOARD_DB_PATH = DB;
+ const { db } = require("../db");
+ const roles = db.prepare("SELECT username, role FROM users ORDER BY id").all();
+ assert.deepEqual(
+ roles.map((r) => [r.username, r.role]),
+ [
+ ["boss", "admin"],
+ ["mia", "developer"],
+ ["ana", "analyst"],
+ ]
+ );
+ assert.equal(db.prepare("SELECT user_id FROM auth_sessions").get().user_id, 2);
+ assert.throws(() =>
+ db
+ .prepare("INSERT INTO users (username, role, password_hash) VALUES ('x', 'member', 'x')")
+ .run()
+ );
+ db.prepare("INSERT INTO users (username, password_hash) VALUES ('neo', 'x')").run();
+ assert.equal(db.prepare("SELECT role FROM users WHERE username = 'neo'").get().role, "developer");
+});
diff --git a/server/db.js b/server/db.js
index f8f41638..04f8370d 100644
--- a/server/db.js
+++ b/server/db.js
@@ -291,8 +291,8 @@ db.exec(`
cache_write_per_mtok REAL NOT NULL DEFAULT 0,
cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0,
-- Fast mode (research preview) premium input/output rates; 0 = no fast pricing.
- -- Cache rates in fast mode are derived from fast_input via the standard
- -- caching multipliers (see server/lib/pricing-constants.js).
+ -- Cache rates in fast mode are the stored cache rates scaled by
+ -- fast_input / input (server/routes/pricing.js).
fast_input_per_mtok REAL NOT NULL DEFAULT 0,
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
-- Rates for a request whose prompt is above LONG_CONTEXT_TOKENS (Haiku 5.5);
@@ -1746,7 +1746,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
- role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
+ role TEXT NOT NULL DEFAULT 'developer' CHECK(role IN ('admin','analyst','developer')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -1768,8 +1768,47 @@ db.exec(`
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
+ -- One account's own UI preferences (server/routes/me.js). \`local\` in local
+ -- mode, \`u\` per server-mode account. The same table as the server-s3
+ -- work's (which adds theme, language… to PREF_KEYS) — keep the two identical.
+ CREATE TABLE IF NOT EXISTS user_prefs (
+ user_key TEXT PRIMARY KEY,
+ prefs TEXT NOT NULL DEFAULT '{}',
+ onboarded_at TEXT,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ );
`);
+// The `member` role was renamed `developer`: rebuild `users` for the new
+// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
+// login through the DROP.
+{
+ const meta = db
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'")
+ .get();
+ if (meta?.sql?.includes("'member'")) {
+ db.exec(`
+ PRAGMA foreign_keys = OFF;
+ BEGIN;
+ CREATE TABLE users_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ role TEXT NOT NULL DEFAULT 'developer' CHECK(role IN ('admin','analyst','developer')),
+ password_hash TEXT NOT NULL,
+ disabled INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ );
+ INSERT INTO users_new (id, username, role, password_hash, disabled, created_at)
+ SELECT id, username, CASE role WHEN 'member' THEN 'developer' ELSE role END,
+ password_hash, disabled, created_at FROM users;
+ DROP TABLE users;
+ ALTER TABLE users_new RENAME TO users;
+ COMMIT;
+ PRAGMA foreign_keys = ON;
+ `);
+ }
+}
+
migrationLock.exec("COMMIT");
migrationLock.close();
diff --git a/server/index.js b/server/index.js
index d6afd6f5..ee7f21ba 100644
--- a/server/index.js
+++ b/server/index.js
@@ -101,6 +101,7 @@ const locksRouter = require("./routes/locks");
const petsRouter = require("./routes/pets");
const snapshotsRouter = require("./routes/snapshots");
const authRouter = require("./routes/auth");
+const meRouter = require("./routes/me");
const accountsRouter = require("./routes/accounts");
const usersRouter = require("./routes/users");
const { auditGuard } = require("./lib/audit");
@@ -131,6 +132,7 @@ function createApp() {
app.use("/api", scopeGuard);
app.use("/api", auditGuard);
app.use("/api/auth", authRouter);
+ app.use("/api/me", meRouter);
app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter);
@@ -448,6 +450,8 @@ function startBackgroundServices() {
// Scheduled reports (Settings → Reports). Off with DASHBOARD_REPORTS_MS=0.
require("./lib/reports").startReportScheduler();
+ // Daily database backup (Settings → Data → Backup). Off until a folder is set.
+ require("./lib/db-backup").startBackupScheduler();
// Rates for models seen in token_usage with no pricing row of their own.
require("./lib/pricing-sync").startPricingSync(require("./db").db);
diff --git a/server/lib/audit.js b/server/lib/audit.js
index 02548a30..2dcdab4a 100644
--- a/server/lib/audit.js
+++ b/server/lib/audit.js
@@ -21,7 +21,8 @@ const SESSION_PATHS = [
function auditGuard(req, res, next) {
const u = req.user;
- if (!u || u.role === "member" || (req.method !== "GET" && req.method !== "HEAD")) return next();
+ if (!u || u.role === "developer" || (req.method !== "GET" && req.method !== "HEAD"))
+ return next();
const p = require("./auth").apiPath(req);
for (const re of SESSION_PATHS) {
const m = re.exec(p);
@@ -32,7 +33,7 @@ function auditGuard(req, res, next) {
for (const owner of sessionOwners(id)) {
if (owner === u.id) continue;
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
- if (ownerRole !== "member") continue;
+ if (ownerRole !== "developer") continue;
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
diff --git a/server/lib/auth.js b/server/lib/auth.js
index 059b506f..fecb94b3 100644
--- a/server/lib/auth.js
+++ b/server/lib/auth.js
@@ -11,9 +11,10 @@
* disabled user's sessions stop working at once (the lookup joins `users`).
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
- * its own bearer token), and everything else only with a valid login. An
- * `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
- * account's own configuration — are an `admin`'s.
+ * its own bearer token), and everything else only with a valid login.
+ * Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
+ * configuration — are an `admin`'s; an `analyst` and a `developer` write
+ * only their own rows (`scope.js`).
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
@@ -36,7 +37,7 @@ const scrypt = promisify(crypto.scrypt);
const COOKIE = "agenthub_sid";
const SESSION_DAYS = 30;
-const ROLES = ["admin", "analyst", "member"];
+const ROLES = ["admin", "analyst", "developer"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
@@ -58,13 +59,15 @@ const ADMIN_WRITE_PREFIXES = [
"/reports",
"/pets",
"/users",
+ // Alert rules and budgets fire for every user's sessions.
+ "/alerts/rules",
];
/**
- * API prefixes a member may not reach at all, reads included: the host's own
+ * API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
- * pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
+ * pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
@@ -131,7 +134,7 @@ function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
-async function createUser(username, password, role = "member") {
+async function createUser(username, password, role = "developer") {
const name = validateUsername(username);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
@@ -392,7 +395,7 @@ function authGuard(req, res, next) {
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
- user.role === "member" &&
+ user.role === "developer" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
) {
@@ -407,8 +410,7 @@ function authGuard(req, res, next) {
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
- if (user.role === "analyst")
- return deny(res, 403, "EREADONLY", "an analyst account is read-only");
+ // An analyst writes like a developer: only their own rows (scope.js).
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
@@ -432,15 +434,8 @@ function isWebSocketLoggedIn(req) {
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
- const user = userFromRequest(req);
- if (
- user &&
- user.role === "analyst" &&
- /^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
- ) {
- return false; // a terminal is a write
- }
- return Boolean(user);
+ // Which terminals a login may open is the run check's (websocket.js).
+ return Boolean(userFromRequest(req));
}
module.exports = {
diff --git a/server/lib/data-transfer.js b/server/lib/data-transfer.js
index 397f0ca8..0ef431f6 100644
--- a/server/lib/data-transfer.js
+++ b/server/lib/data-transfer.js
@@ -36,10 +36,6 @@
const EXPORT_FORMAT = "agenthub-export";
const EXPORT_VERSION = 1;
-// Tables serialized into the bundle. Order matters for restore (parents before
-// children); FK checks are deferred to COMMIT anyway (see importExportBundle).
-const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"];
-
/**
* Build the full export bundle from the live database.
*
@@ -263,7 +259,6 @@ module.exports = {
exportableRun,
EXPORT_FORMAT,
EXPORT_VERSION,
- SESSION_CHILD_TABLES,
buildExportBundle,
importExportBundle,
ImportFormatError,
diff --git a/server/lib/db-backup.js b/server/lib/db-backup.js
new file mode 100644
index 00000000..6822ec93
--- /dev/null
+++ b/server/lib/db-backup.js
@@ -0,0 +1,221 @@
+/**
+ * @file Scheduled database backups (Settings → Data → Backup). Once a day, at
+ * a chosen local hour of the server, the dashboard's SQLite database is copied
+ * into a folder the admin picked: `VACUUM INTO` on a READ-ONLY connection in a
+ * worker thread — a consistent snapshot that writes from other connections
+ * never restart, compacted (1.25 GB → ~0.7 GB), while the event loop keeps
+ * serving (≈30 s at that size). The file name is server-made,
+ * `agenthub-YYYYMMDD-HHMMSS.db`, written as `.partial` and renamed once
+ * complete. Rotation deletes ONLY regular files matching that exact name, oldest
+ * first beyond `keep` — nothing else in the folder, never through a symlink.
+ * Settings live in the settings file: AGENTHUB_BACKUP_DIR (empty = off),
+ * AGENTHUB_BACKUP_HOUR (0–23, default 2), AGENTHUB_BACKUP_KEEP (1–365, default
+ * 7). A missed day (server down at the hour) runs at the next start; a failed
+ * one is retried the next day or by "Back up now", never every minute. The
+ * scheduler ticks every DASHBOARD_BACKUP_TICK_MS (default 60 s, 0 = off).
+ * Restoring is manual: stop AgentHub, copy a backup over the database, start.
+ * This file is also the worker's entry point.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+"use strict";
+
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
+
+const KEYS = {
+ dir: "AGENTHUB_BACKUP_DIR",
+ hour: "AGENTHUB_BACKUP_HOUR",
+ keep: "AGENTHUB_BACKUP_KEEP",
+};
+const NAME_RE = /^agenthub-(\d{8})-(\d{6})\.db$/;
+
+const coded = (message, code) => Object.assign(new Error(message), { code });
+
+function int(value, lo, hi, fallback) {
+ const n = Number(value);
+ return value !== undefined && value !== "" && Number.isInteger(n) && n >= lo && n <= hi
+ ? n
+ : fallback;
+}
+
+function backupConfig(env = process.env) {
+ return {
+ dir: env[KEYS.dir] || "",
+ hour: int(env[KEYS.hour], 0, 23, 2),
+ keep: int(env[KEYS.keep], 1, 365, 7),
+ };
+}
+
+/** Absolute, existing, writable directory → its resolved path; else INVALID_PATH. */
+function validateBackupDir(input) {
+ const invalid = (message) => coded(message, "INVALID_PATH");
+ const trimmed = String(input).trim();
+ if (/[\x00-\x1f]/.test(trimmed)) throw invalid("the folder may not contain control characters");
+ const expanded = trimmed.replace(/^~(?=$|\/)/, os.homedir());
+ if (!path.isAbsolute(expanded)) throw invalid("the backup folder must be an absolute path");
+ const resolved = path.resolve(expanded);
+ let st;
+ try {
+ st = fs.statSync(resolved);
+ } catch {
+ throw invalid(`Directory does not exist: ${resolved}`);
+ }
+ if (!st.isDirectory()) throw invalid(`Not a directory: ${resolved}`);
+ try {
+ fs.accessSync(resolved, fs.constants.W_OK);
+ } catch {
+ throw invalid(`AgentHub cannot write to ${resolved}`);
+ }
+ return resolved;
+}
+
+/** Local `YYYYMMDD-HHMMSS`. */
+function stamp(d) {
+ const p = (n) => String(n).padStart(2, "0");
+ return (
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
+ );
+}
+
+/** AgentHub's own backups in `dir`, newest first. Symlinks and look-alikes are not ours. */
+function listBackups(dir) {
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isFile() && NAME_RE.test(e.name))
+ .map((e) => {
+ const st = fs.statSync(path.join(dir, e.name));
+ return { name: e.name, size: st.size, mtime: st.mtime.toISOString() };
+ })
+ .sort((a, b) => (a.name < b.name ? 1 : -1));
+}
+
+/** Delete our backups beyond the newest `keep`; returns the names removed. */
+function rotate(dir, keep) {
+ const old = listBackups(dir).slice(keep);
+ for (const f of old) fs.unlinkSync(path.join(dir, f.name));
+ return old.map((f) => f.name);
+}
+
+/** Whether the scheduler should back up now. `attemptedDay` = YYYYMMDD already tried. */
+function dueNow(now, cfg, files, attemptedDay) {
+ if (!cfg.dir || now.getHours() < cfg.hour) return false;
+ const today = stamp(now).slice(0, 8);
+ if (attemptedDay === today) return false;
+ return !files.some((f) => NAME_RE.exec(f.name)?.[1] === today);
+}
+
+function vacuumInto(dbPath, out) {
+ return new Promise((resolve, reject) => {
+ const w = new Worker(__filename, { workerData: { agenthubBackupWorker: true, dbPath, out } });
+ w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve()));
+ w.once("error", reject);
+ w.once(
+ "exit",
+ (code) => code !== 0 && reject(coded(`backup worker exited (${code})`, "EBACKUP"))
+ );
+ });
+}
+
+let running = null;
+let last = null;
+
+function runBackup() {
+ if (running) return running;
+ running = (async () => {
+ const { dir: configured, keep } = backupConfig();
+ if (!configured) throw coded("no backup folder is set", "ENOBACKUPDIR");
+ const started = new Date();
+ let partial = null;
+ try {
+ const dir = validateBackupDir(configured); // it may have been unmounted since
+ const name = `agenthub-${stamp(started)}.db`;
+ partial = path.join(dir, `${name}.partial`);
+ await vacuumInto(require("../db").DB_PATH, partial);
+ fs.renameSync(partial, path.join(dir, name));
+ partial = null;
+ const removed = rotate(dir, keep);
+ const size = fs.statSync(path.join(dir, name)).size;
+ last = {
+ at: started.toISOString(),
+ ok: true,
+ file: name,
+ size,
+ ms: Date.now() - started,
+ removed,
+ };
+ return last;
+ } catch (err) {
+ if (partial) fs.rmSync(partial, { force: true });
+ last = { at: started.toISOString(), ok: false, error: err.message };
+ throw err;
+ }
+ })().finally(() => {
+ running = null;
+ });
+ return running;
+}
+
+const isRunning = () => running !== null;
+const lastResult = () => last;
+
+function startBackupScheduler() {
+ const every = Number(process.env.DASHBOARD_BACKUP_TICK_MS ?? 60_000);
+ if (!Number.isFinite(every) || every <= 0) return;
+ let attemptedDay = null;
+ const t = setInterval(() => {
+ const cfg = backupConfig();
+ if (!cfg.dir || running) return;
+ const now = new Date();
+ let files = [];
+ try {
+ files = listBackups(cfg.dir);
+ } catch {
+ /* folder gone: runBackup reports it */
+ }
+ if (!dueNow(now, cfg, files, attemptedDay)) return;
+ attemptedDay = stamp(now).slice(0, 8);
+ runBackup().then(
+ (r) => console.log(`[backup] ${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.ms} ms)`),
+ (err) => console.warn(`[backup] failed: ${err.message}`)
+ );
+ }, every);
+ if (t.unref) t.unref();
+}
+
+if (!isMainThread && workerData?.agenthubBackupWorker) {
+ // Same driver order as server/db.js: better-sqlite3, else node:sqlite.
+ let conn;
+ try {
+ try {
+ const Database = require("better-sqlite3");
+ conn = new Database(workerData.dbPath, { readonly: true });
+ } catch {
+ const Database = require("../compat-sqlite");
+ conn = new Database(workerData.dbPath, { readonly: true });
+ }
+ conn.pragma("busy_timeout = 5000");
+ conn.prepare("VACUUM INTO ?").run(workerData.out);
+ parentPort.postMessage({ ok: true });
+ } catch (err) {
+ parentPort.postMessage({ error: { message: err.message, code: err.code || "EBACKUP" } });
+ } finally {
+ conn?.close();
+ }
+}
+
+module.exports = {
+ KEYS,
+ backupConfig,
+ validateBackupDir,
+ listBackups,
+ rotate,
+ dueNow,
+ runBackup,
+ isRunning,
+ lastResult,
+ startBackupScheduler,
+};
diff --git a/server/lib/pricing-constants.js b/server/lib/pricing-constants.js
index 30408c7d..a7df54b8 100644
--- a/server/lib/pricing-constants.js
+++ b/server/lib/pricing-constants.js
@@ -7,14 +7,6 @@
* @author Nguyễn Ngọc Trí Vĩ
*/
-// ── Prompt-caching multipliers (relative to base input price) ───────────────
-// Stored model rates already encode these for the standard tier, but fast-mode
-// cache rates are derived from the fast input base using the same ratios, so we
-// keep the multipliers here for that derivation and for documentation.
-const CACHE_READ_MULTIPLIER = 0.1; // cache hit / refresh
-const CACHE_WRITE_5M_MULTIPLIER = 1.25; // 5-minute ephemeral write
-const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
-
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
@@ -26,7 +18,7 @@ const LONG_CONTEXT_TOKENS = 100_000;
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
-const WEB_FETCH_PER_REQUEST = 0.0; // web fetch has no surcharge — tokens only
+// Web fetch has no surcharge — tokens only.
// Code execution: billed by container-time, not request count. Transcripts only
// expose request counts, so we estimate at the documented 5-minute minimum per
@@ -48,14 +40,10 @@ function estimateCodeExecHours(codeExecRequests, webSearchRequests, webFetchRequ
}
module.exports = {
- CACHE_READ_MULTIPLIER,
- CACHE_WRITE_5M_MULTIPLIER,
- CACHE_WRITE_1H_MULTIPLIER,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
LONG_CONTEXT_TOKENS,
WEB_SEARCH_PER_1K_SEARCHES,
- WEB_FETCH_PER_REQUEST,
CODE_EXEC_PER_HOUR,
CODE_EXEC_MIN_MINUTES,
CODE_EXEC_FREE_HOURS,
diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js
index 0a2865e0..1d0b805c 100644
--- a/server/lib/pty-attach.js
+++ b/server/lib/pty-attach.js
@@ -6,8 +6,12 @@
* both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach
- * process — tmux itself is what keeps them all in sync, this module does no
- * cross-connection coordination.
+ * process, and ONE of them sizes the window: the DRIVER — the first to attach,
+ * then whichever sent `{type:"resize", drive:true}` last (the client does on a
+ * click or a keystroke). Every other attach is a follower: its PTY is held at
+ * the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
+ * renders the same grid, scaled to fit, instead of each browser resizing tmux
+ * under the others. A driver that leaves sends `{type:"lead"}` to the rest.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -61,6 +65,28 @@ function attachBinary(platform = process.platform) {
}
}
+// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
+const groups = new Map();
+
+const tell = (v, msg) => {
+ try {
+ v.ws.send(JSON.stringify(msg), { binary: false });
+ } catch {
+ /* client gone — its close handler cleans up */
+ }
+};
+
+/** `v` sizes the window now: every other attach is held at its size and told. */
+function drive(group, v) {
+ group.driver = v;
+ v.pty.resize(v.cols, v.rows);
+ for (const o of group.viewers) {
+ if (o === v) continue;
+ o.pty.resize(v.cols, v.rows);
+ tell(o, { type: "size", cols: v.cols, rows: v.rows });
+ }
+}
+
/**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t ` per call.
@@ -75,11 +101,20 @@ function attach(ws, runId, { cols, rows }) {
} catch {
/* session gone or tmux unavailable — attach below will surface that */
}
+ let group = groups.get(runId);
+ if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
+ const lead = group.driver;
+ // A follower attaches at the driver's size: tmux sizes the window to the
+ // newest client, so its own size would resize the driver's pane first.
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
name: "xterm-256color",
- cols: cols || 80,
- rows: rows || 24,
+ cols: lead ? lead.cols : cols || 80,
+ rows: lead ? lead.rows : rows || 24,
});
+ const me = { ws, pty, cols: cols || 80, rows: rows || 24 };
+ group.viewers.add(me);
+ if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
+ else group.driver = me;
// Backpressure: a slow viewer (a remote browser on a thin link) must not
// queue a chatty pane's output in this process without bound. Past 1 MB
@@ -143,7 +178,11 @@ function attach(ws, runId, { cols, rows }) {
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
- pty.resize(msg.cols, msg.rows);
+ me.cols = msg.cols;
+ me.rows = msg.rows;
+ // A follower's own size is remembered, not applied: its PTY stays at
+ // the driver's until it takes the lead.
+ if (msg.drive || group.driver === me || !group.driver) drive(group, me);
return;
}
}
@@ -151,6 +190,12 @@ function attach(ws, runId, { cols, rows }) {
});
ws.on("close", () => {
+ group.viewers.delete(me);
+ if (!group.viewers.size) groups.delete(runId);
+ else if (group.driver === me) {
+ group.driver = null;
+ for (const o of group.viewers) tell(o, { type: "lead" });
+ }
try {
pty.kill();
} catch {
@@ -173,4 +218,6 @@ function attach(ws, runId, { cols, rows }) {
// resolves a working native binding with no compiler needed.
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
-module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl };
+const __resetGroups = () => groups.clear();
+
+module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups };
diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js
index 2bfad8ae..9b99c998 100644
--- a/server/lib/pty-run.js
+++ b/server/lib/pty-run.js
@@ -30,6 +30,8 @@
* @author Nguyễn Ngọc Trí Vĩ
*/
+const fs = require("node:fs");
+const path = require("node:path");
const tmux = require("./tmux");
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
const { desktopEnv } = require("./desktop-env");
@@ -137,6 +139,23 @@ function promptArg(p) {
return p.startsWith("-") ? ` ${p}` : p;
}
+// A prompt past INLINE_PROMPT_BYTES reaches the pane in a 0600 file, not in
+// tmux's argv: one tmux message holds 16 KB with the env and the rest of the
+// argv beside it (`command too long`, issue #68). The pane's own sh reads it
+// back as the CLI's last argument and deletes the file. No CLI can take one
+// argument past Linux's MAX_ARG_STRLEN (128 KB, E2BIG), so that is refused.
+const INLINE_PROMPT_BYTES = 4 * 1024;
+const MAX_PROMPT_BYTES = 128 * 1024 - 2; // the NUL, and promptArg's space
+const READ_PROMPT = 'p=$(cat "$1") && rm -f "$1" && shift && exec "$@" "$p"';
+
+function promptFileArgv(id, argv) {
+ const dir = path.join(require("./claude-home").getDataDir(), "run-prompts");
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ const file = path.join(dir, `${id}.txt`);
+ fs.writeFileSync(file, argv[argv.length - 1], { mode: 0o600 });
+ return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
+}
+
function buildClaudeArgv({
model,
permissionMode,
@@ -316,6 +335,12 @@ function spawnRun(args) {
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
+ if (typeof initialPrompt === "string" && Buffer.byteLength(initialPrompt) > MAX_PROMPT_BYTES) {
+ throw makeErr(
+ "EBADPROMPT",
+ `the prompt is ${Math.ceil(Buffer.byteLength(initialPrompt) / 1024)} KB; an AI CLI takes at most 128 KB as its first message — attach the text as a file instead`
+ );
+ }
// A lane whose tool has since left the catalog keeps its row; it just
// cannot start until the catalog knows the tool again.
const entry = aiTools.get(tool);
@@ -434,6 +459,14 @@ function spawnRun(args) {
entry
);
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
+ // Every launcher puts the prompt LAST; Windows has no sh to read it back.
+ const paneCmd =
+ initialPrompt &&
+ !pasteLater &&
+ process.platform !== "win32" &&
+ Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
+ ? promptFileArgv(id, argv)
+ : argv;
const record = () => {
if (!dashboardRuns) return;
@@ -524,7 +557,7 @@ function spawnRun(args) {
};
if (!tmux.hasSession(id)) {
- tmux.newSession({ name: id, cwd, argv: paneArgv(argv), env });
+ tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -534,7 +567,7 @@ function spawnRun(args) {
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
- tmux.sendCommand(id, argv, env, cwd);
+ tmux.sendCommand(id, paneCmd, env, cwd);
record();
schedulePaste();
}
diff --git a/server/lib/scope.js b/server/lib/scope.js
index ac435e0c..edaddb12 100644
--- a/server/lib/scope.js
+++ b/server/lib/scope.js
@@ -6,9 +6,11 @@
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and
- * token rows belong to their session. A member sees only what their own
- * workspaces own; an admin and an analyst see everything, and so does every
- * request in local mode (`req.user` is never set there).
+ * token rows belong to their session. A developer sees and changes only what
+ * their own workspaces own; an analyst SEES everything but changes only their
+ * own (every non-GET request is scoped like a developer's); an admin sees and
+ * changes everything, and so does every request in local mode (`req.user` is
+ * never set there).
*
* Every helper takes `req`, so no caller can pick whose rows it reads.
* @author Nguyễn Ngọc Trí Vĩ
@@ -16,10 +18,14 @@
const sql = (q) => require("../db").db.prepare(q);
-/** The member whose rows this request is limited to, or null for "all". */
+/** The user whose rows this request is limited to, or null for "all". */
function memberId(req) {
const u = req?.user;
- return u && u.role === "member" ? u.id : null;
+ if (!u) return null;
+ if (u.role === "developer") return u.id;
+ // An analyst reads every user's rows but writes only their own.
+ if (u.role === "analyst" && req.method !== "GET" && req.method !== "HEAD") return u.id;
+ return null;
}
/** The owner a workspace created by this request gets (null outside server mode). */
@@ -191,7 +197,8 @@ function namedIds(req) {
/**
* Express middleware (on `/api`, after authGuard): a member naming a row that
* is not theirs — in the path, the query or the body — gets 404, the same
- * answer as a row that does not exist. A no-op for admin, analyst and local.
+ * answer as a row that does not exist. A no-op for an admin, an analyst's
+ * reads, and local mode.
*/
function scopeGuard(req, res, next) {
if (memberId(req) == null) return next();
@@ -273,7 +280,7 @@ function broadcastAudience(type, data) {
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
- if (!user || user.role !== "member") return true;
+ if (!user || user.role !== "developer") return true;
if (audience === null || audience.strip) return true;
return Boolean(audience.owners && audience.owners.has(user.id));
}
diff --git a/server/lib/workflow-stats.js b/server/lib/workflow-stats.js
index 19e769ab..d528240b 100644
--- a/server/lib/workflow-stats.js
+++ b/server/lib/workflow-stats.js
@@ -37,7 +37,7 @@ function statusClause(filter, alias = "s") {
// A pure SQL builder: scope.js opens the db only for its canSee* checks,
// so this also runs in the read worker on its own connection.
const w = require("./scope").sessionWhere(
- { user: { id: filter.member, role: "member" } },
+ { user: { id: filter.member, role: "developer" } },
`${alias}.id`,
`${alias}.lane_id`
);
diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js
index 65c3a002..0291ed0c 100644
--- a/server/openapi-extra/auth.js
+++ b/server/openapi-extra/auth.js
@@ -5,7 +5,7 @@
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude account pool, no tokens
* - GET /api/users (Auth) — every account's usage (admin/analyst)
- * - GET /api/users/audit (Auth) — staff reads of members' sessions
+ * - GET /api/users/audit (Auth) — staff reads of developers' sessions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ
@@ -26,7 +26,7 @@ const schemas = {
properties: {
id: { type: "integer" },
username: { type: "string" },
- role: { type: "string", enum: ["admin", "analyst", "member"] },
+ role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
},
},
@@ -84,7 +84,7 @@ const paths = {
tags: ["Auth"],
operationId: "listServerUsers",
summary:
- "Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)",
+ "Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a developer)",
responses: {
200: {
description: "Accounts",
@@ -121,7 +121,7 @@ const paths = {
},
},
},
- 403: err("EADMIN — a member"),
+ 403: err("EADMIN — a developer"),
},
},
post: {
@@ -139,7 +139,11 @@ const paths = {
properties: {
username: { type: "string" },
password: { type: "string", minLength: 8 },
- role: { type: "string", enum: ["admin", "analyst", "member"], default: "member" },
+ role: {
+ type: "string",
+ enum: ["admin", "analyst", "developer"],
+ default: "developer",
+ },
currentPassword: { type: "string" },
},
},
@@ -163,7 +167,7 @@ const paths = {
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
parameters: [idParam],
requestBody: changeBody({
- role: { type: "string", enum: ["admin", "analyst", "member"] },
+ role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
}),
responses: changeResponses,
@@ -193,7 +197,7 @@ const paths = {
get: {
tags: ["Auth"],
operationId: "listAuditLog",
- summary: "Admin/analyst reads of sessions members own, newest first",
+ summary: "Admin/analyst reads of sessions developers own, newest first",
parameters: [{ name: "limit", in: "query", schema: { type: "integer", maximum: 1000 } }],
responses: {
200: {
@@ -223,7 +227,7 @@ const paths = {
},
},
},
- 403: err("EADMIN — a member"),
+ 403: err("EADMIN — a developer"),
},
},
},
diff --git a/server/openapi-extra/misc.js b/server/openapi-extra/misc.js
index 9b79f709..d22bdd99 100644
--- a/server/openapi-extra/misc.js
+++ b/server/openapi-extra/misc.js
@@ -8,6 +8,9 @@
* - PUT /api/settings/autostart (Settings)
* - GET /api/settings/tray (Settings)
* - PUT /api/settings/tray (Settings)
+ * - GET /api/settings/backup (Settings)
+ * - PUT /api/settings/backup (Settings)
+ * - POST /api/settings/backup/run (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1120,6 +1123,108 @@ const paths = {
};
})(),
+ "/api/settings/backup": (() => {
+ const state = {
+ description: "Daily database backup settings, the folder's backups and the last run",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ required: ["dir", "hour", "keep", "running", "files", "env_owned"],
+ properties: {
+ dir: { type: "string", description: "Backup folder; empty = backups off" },
+ hour: { type: "integer", minimum: 0, maximum: 23, description: "Server local hour" },
+ keep: { type: "integer", minimum: 1, maximum: 365 },
+ running: { type: "boolean" },
+ last: {
+ type: "object",
+ nullable: true,
+ description:
+ "This process's last run: {at, ok, file?, size?, ms?, removed?, error?}",
+ },
+ files: {
+ type: "array",
+ description: "AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` files, newest first",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ size: { type: "integer" },
+ mtime: { type: "string", format: "date-time" },
+ },
+ },
+ },
+ dir_error: { type: "string", nullable: true },
+ env_owned: {
+ type: "object",
+ properties: {
+ dir: { type: "boolean" },
+ hour: { type: "boolean" },
+ keep: { type: "boolean" },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ return {
+ get: {
+ tags: ["Settings"],
+ summary: "Daily database backup settings and backups",
+ operationId: "getBackup",
+ responses: { 200: state },
+ },
+ put: {
+ tags: ["Settings"],
+ summary: "Set the backup folder, hour and number kept",
+ description:
+ "Any subset of the three fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP. An empty `dir` turns backups off. Same-origin only.",
+ operationId: "setBackup",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ dir: { type: "string" },
+ hour: { type: "integer", minimum: 0, maximum: 23 },
+ keep: { type: "integer", minimum: 1, maximum: 365 },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ 200: { ...state, description: "The new state" },
+ 400: {
+ description:
+ "INVALID_PATH: the folder is relative, missing, not a directory or not writable; INVALID_INPUT: hour/keep out of range",
+ },
+ 403: { description: "EBADORIGIN: another site asked" },
+ 409: { description: "EENVOWNED: the environment set that field" },
+ 500: { description: "ESAVE: the settings file could not be written" },
+ },
+ },
+ };
+ })(),
+
+ "/api/settings/backup/run": {
+ post: {
+ tags: ["Settings"],
+ summary: "Back up the database now",
+ description:
+ "Starts a backup and answers at once; poll GET /api/settings/backup while `running`. Same-origin only.",
+ operationId: "runBackup",
+ responses: {
+ 202: { description: "Started (or already running); the backup state" },
+ 400: { description: "ENOBACKUPDIR: no backup folder is set" },
+ 403: { description: "EBADORIGIN: another site asked" },
+ },
+ },
+ },
+
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
diff --git a/server/routes/alerts.js b/server/routes/alerts.js
index a5fb5d91..bf488aa9 100644
--- a/server/routes/alerts.js
+++ b/server/routes/alerts.js
@@ -184,6 +184,24 @@ router.get("/", (req, res) => {
: stmts.countAlertEvents.get().count;
const unacked = stmts.countUnackedAlertEvents.get().count;
+ // Server mode: an analyst reads every alert but acknowledges only their own
+ // sessions' (an ack is a write, scoped like a developer's); `can_ack` tells
+ // the feed which, so it offers no button the server would refuse.
+ const writer = { user: req.user, method: "POST" };
+ if (memberId(writer) != null && alerts.length) {
+ const own = bySessionWhere(writer);
+ const ids = alerts.map((a) => a.id);
+ const ackable = new Set(
+ db
+ .prepare(
+ `SELECT id FROM alert_events WHERE id IN (${ids.map(() => "?").join(",")}) AND ${own.where}`
+ )
+ .all(...ids, ...own.params)
+ .map((r) => r.id)
+ );
+ for (const a of alerts) a.can_ack = ackable.has(a.id);
+ }
+
res.json({ alerts, total, unacked, limit, offset });
});
diff --git a/server/routes/me.js b/server/routes/me.js
new file mode 100644
index 00000000..44f119eb
--- /dev/null
+++ b/server/routes/me.js
@@ -0,0 +1,84 @@
+/**
+ * @file me.js
+ * @description `/api/me` — the signed-in account's own UI preferences
+ * (`user_prefs`), so they follow it to any browser and differ between the
+ * accounts of one dashboard. Local mode is one account, `local`; server mode
+ * keys a row `u` by the login cookie — the request never names an account.
+ * Only the keys in `PREF_KEYS` are kept, each a string of at most 8 KB, and a
+ * PUT merges into the row (`null` removes a key). Writes are same-origin.
+ * Today the one key is `agenthub.chatUi` ("1" = Claude/Codex sessions open as
+ * a chat, `PaneChat`). Same table, routes and semantics as the server-s3 work,
+ * which adds its own keys to this list.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { Router } = require("express");
+const { db } = require("../db");
+const auth = require("../lib/auth");
+const { sameOriginGuard } = require("../lib/same-origin");
+
+const router = Router();
+
+/** The keys an account's preferences are made of. */
+const PREF_KEYS = ["agenthub.chatUi"];
+const MAX_VALUE = 8 * 1024;
+
+/** `u` for a server-mode account, `local` in local mode, null when nobody is signed in. */
+function userKey(req) {
+ if (!auth.isServerMode()) return "local";
+ const user = req.user || auth.userFromRequest(req);
+ return user ? `u${user.id}` : null;
+}
+
+const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?");
+const upsert = db.prepare(
+ `INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?)
+ ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
+);
+
+function read(key) {
+ const row = getRow.get(key);
+ let prefs = {};
+ try {
+ prefs = row ? JSON.parse(row.prefs) : {};
+ } catch {
+ /* a damaged row reads as empty */
+ }
+ return { prefs, onboardedAt: row?.onboarded_at || null };
+}
+
+const unauth = (res) =>
+ res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } });
+
+router.get("/prefs", (req, res) => {
+ const key = userKey(req);
+ if (!key) return unauth(res);
+ res.json(read(key));
+});
+
+router.put("/prefs", sameOriginGuard, (req, res) => {
+ const key = userKey(req);
+ if (!key) return unauth(res);
+ const patch = req.body?.prefs;
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
+ return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } });
+ }
+ const { prefs } = read(key);
+ for (const [k, v] of Object.entries(patch)) {
+ if (!PREF_KEYS.includes(k)) {
+ return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
+ }
+ if (v === null) delete prefs[k];
+ else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
+ else
+ return res
+ .status(400)
+ .json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
+ }
+ upsert.run(key, JSON.stringify(prefs));
+ res.json(read(key));
+});
+
+module.exports = router;
+module.exports.PREF_KEYS = PREF_KEYS;
diff --git a/server/routes/run.js b/server/routes/run.js
index d7c6dd93..71840f8e 100644
--- a/server/routes/run.js
+++ b/server/routes/run.js
@@ -476,8 +476,6 @@ router.delete("/:id", (req, res) => {
});
module.exports = router;
-module.exports.__sameOriginGuard = sameOriginGuard;
-module.exports.__sanitiseCwd = sanitiseCwd;
// Shared with routes/lanes.js: lane actions spawn processes through the same
// run-spawner, so they must sit behind the same loopback/same-origin check.
module.exports.sameOriginGuard = sameOriginGuard;
diff --git a/server/routes/settings.js b/server/routes/settings.js
index d398c266..a4457c3d 100644
--- a/server/routes/settings.js
+++ b/server/routes/settings.js
@@ -1,5 +1,5 @@
/**
- * @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
+ * @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
+const dbBackup = require("../lib/db-backup");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const {
@@ -301,6 +302,41 @@ router.post("/quit", sameOriginGuard, (_req, res) => {
res.json({ ok: true, killed });
});
+// POST /api/settings/uninstall {confirm: true} — Uninstall AgentHub
+// (scripts/uninstall.js) in a detached worker, because it stops this server.
+// Under systemd it starts in a scope of its own, outside the unit's cgroup —
+// an older unit still on KillMode=control-group would reap it mid-run; launchd
+// leaves it alone (AbandonProcessGroup).
+// Never in server mode — a shared dashboard is removed from the host's shell.
+router.post("/uninstall", sameOriginGuard, (req, res) => {
+ if (require("../lib/auth").isServerMode()) {
+ return res.status(403).json({
+ error: { code: "ESERVERMODE", message: "Run `agenthub uninstall` on the host instead" },
+ });
+ }
+ if (req.body?.confirm !== true) {
+ return res
+ .status(400)
+ .json({ error: { code: "ECONFIRM", message: "`confirm: true` is required" } });
+ }
+ const script = path.join(__dirname, "..", "..", "scripts", "uninstall.js");
+ const svc = require("../../scripts/service-manager");
+ const argv = [process.execPath, script, "--yes"];
+ const [cmd, ...args] =
+ svc.kind === "systemd" && svc.systemctlAvailable()
+ ? ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--", ...argv]
+ : argv;
+ require("child_process")
+ .spawn(cmd, args, {
+ cwd: os.tmpdir(), // never the plugin root, which it deletes
+ detached: true,
+ stdio: "ignore",
+ env: process.env,
+ })
+ .unref();
+ res.status(202).json({ ok: true, log: require("../../scripts/uninstall").logPath() });
+});
+
// 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.
@@ -537,6 +573,84 @@ router.put("/privacy", sameOriginGuard, (req, res) => {
res.json({ ok: true, ...privacyState() });
});
+// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily
+// database backup (server/lib/db-backup.js). The folder, hour and count persist
+// to the settings file; one the environment set is `env_owned` and refused. A
+// run answers 202 at once (a large database takes tens of seconds) and the
+// page polls GET while `running`.
+function backupState() {
+ const cfg = dbBackup.backupConfig();
+ let files = [];
+ let dir_error = null;
+ if (cfg.dir) {
+ try {
+ files = dbBackup.listBackups(cfg.dir);
+ } catch (err) {
+ dir_error = err.message;
+ }
+ }
+ return {
+ ...cfg,
+ running: dbBackup.isRunning(),
+ last: dbBackup.lastResult(),
+ files,
+ dir_error,
+ env_owned: Object.fromEntries(
+ Object.entries(dbBackup.KEYS).map(([field, key]) => [field, envOwned.has(key)])
+ ),
+ };
+}
+router.get("/backup", (_req, res) => res.json(backupState()));
+router.put("/backup", sameOriginGuard, (req, res) => {
+ const body = req.body || {};
+ const fail = (code, message) => res.status(400).json({ error: { code, message } });
+ const writes = [];
+ if (body.dir !== undefined) {
+ if (typeof body.dir !== "string") return fail("INVALID_PATH", "dir must be a string");
+ let dir = "";
+ if (body.dir.trim()) {
+ try {
+ dir = dbBackup.validateBackupDir(body.dir);
+ } catch (err) {
+ return fail(err.code || "INVALID_PATH", err.message);
+ }
+ }
+ writes.push([dbBackup.KEYS.dir, dir]);
+ }
+ for (const [field, lo, hi] of [
+ ["hour", 0, 23],
+ ["keep", 1, 365],
+ ]) {
+ if (body[field] === undefined) continue;
+ const n = body[field];
+ if (!Number.isInteger(n) || n < lo || n > hi) {
+ return fail("INVALID_INPUT", `${field} must be a whole number from ${lo} to ${hi}`);
+ }
+ writes.push([dbBackup.KEYS[field], String(n)]);
+ }
+ const owned = writes.find(([k]) => envOwned.has(k));
+ if (owned) {
+ return res
+ .status(409)
+ .json({ error: { code: "EENVOWNED", message: `${owned[0]} is set by the environment` } });
+ }
+ try {
+ for (const [k, v] of writes) applySetting(k, v);
+ } catch (err) {
+ return res.status(500).json({ error: { code: "ESAVE", message: err.message } });
+ }
+ res.json({ ok: true, ...backupState() });
+});
+router.post("/backup/run", sameOriginGuard, (_req, res) => {
+ if (!dbBackup.backupConfig().dir) {
+ return res
+ .status(400)
+ .json({ error: { code: "ENOBACKUPDIR", message: "choose a backup folder first" } });
+ }
+ dbBackup.runBackup().catch(() => {}); // the outcome is `last` in GET
+ res.status(202).json({ ok: true, ...backupState() });
+});
+
// GET/POST/DELETE /api/settings/push-token — the token for POST
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
// POST makes a new one (the old one stops working) and returns it this once.
diff --git a/server/routes/users.js b/server/routes/users.js
index b996e714..856764fb 100644
--- a/server/routes/users.js
+++ b/server/routes/users.js
@@ -44,7 +44,7 @@ const USAGE_COLS = [
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
function usageSince(userId, sinceHour, rules) {
const { calculateCost } = require("./pricing");
- const w = sessionWhere({ user: { id: userId, role: "member" } }, "s.id", "s.lane_id");
+ const w = sessionWhere({ user: { id: userId, role: "developer" } }, "s.id", "s.lane_id");
const rows = db
.prepare(
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
@@ -66,7 +66,7 @@ const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slic
router.get("/", (_req, res) => {
const rules = stmts.listPricing.all();
const users = listUsers().map((u) => {
- const w = sessionWhere({ user: { id: u.id, role: "member" } });
+ const w = sessionWhere({ user: { id: u.id, role: "developer" } });
return {
...u,
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
@@ -132,7 +132,7 @@ function target(req) {
router.post(
"/",
accountChange(async (_req, body) => {
- const role = body.role === undefined ? "member" : body.role;
+ const role = body.role === undefined ? "developer" : body.role;
const user = await auth.createUser(body.username, body.password, role);
return { status: 201, user, action: `create ${user.username} (${user.role})` };
})
diff --git a/server/websocket.js b/server/websocket.js
index 86d428c6..469f6582 100644
--- a/server/websocket.js
+++ b/server/websocket.js
@@ -169,8 +169,9 @@ function initPtyWebSocket(server) {
socket.destroy();
return;
}
- // Server mode: a member attaches only to a pane of their own workspace.
- if (isServerMode() && !canSeeRun({ user: userFromRequest(req) }, match[1])) {
+ // Server mode: a terminal is a write, so a developer or an analyst
+ // attaches only to a pane of their own workspace.
+ if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
socket.destroy();
return;
}
@@ -192,7 +193,7 @@ function initPtyWebSocket(server) {
});
// Server mode: a terminal stays open only while its user may still use it —
- // logged in, not disabled, not an analyst, still owning the pane's workspace.
+ // logged in, not disabled, still owning the pane's workspace.
const recheck = setInterval(() => {
if (!ptyWss) {
clearInterval(recheck);
@@ -201,7 +202,7 @@ function initPtyWebSocket(server) {
ptyWss.clients.forEach((ws) => {
if (ws.cookie === undefined) return;
const user = userFromRequest({ headers: { cookie: ws.cookie } });
- if (!user || user.role === "analyst" || !canSeeRun({ user }, ws.runId)) {
+ if (!user || !canSeeRun({ user, method: "POST" }, ws.runId)) {
try {
ws.close(1008, "log in again");
} catch {
@@ -222,7 +223,7 @@ function broadcast(type, data) {
// Server mode: worked out once, and only when a member is connected.
let audience;
const reaches = (client) => {
- if (!client.user || client.user.role !== "member")
+ if (!client.user || client.user.role !== "developer")
return !isServerMode() || Boolean(client.user);
if (audience === undefined) {
try {
@@ -237,7 +238,7 @@ function broadcast(type, data) {
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
- const member = client.user && client.user.role === "member";
+ const member = client.user && client.user.role === "developer";
if (member && audience && audience.strip) {
stripped ??= JSON.stringify({ type, data: {}, timestamp: new Date().toISOString() });
client.send(stripped);