diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cafb1029..f108cf23 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c ![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white) ![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white) -![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white) +![Express](https://img.shields.io/badge/Express-5-000000?style=flat-square&logo=express&logoColor=white) ![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white) ![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white) ![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) @@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown) | **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled | | **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module | | **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages | -| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA | +| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA | | **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly | | **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables | | **React Router 6** | Standard routing for React SPAs. Layout routes with `` give clean shell composition | diff --git a/CLAUDE.md b/CLAUDE.md index 127fb2a9..16c2189d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.25 (2026-10-26) — Today's spend is per user (`?mine=1`); a local terminal pastes images with the OS's own key (Alt+V on Windows) and pastes/drops a file manager's files by their own paths (`server/lib/file-manager.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.25+ (2026-10-26) — Express 5 / React 19 / xterm 6 (WebGL); members manage their own Claude config (`server/lib/cc-target.js`); `agenthub lanes`/`--lane` removed._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -21,7 +21,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which **Key points:** - Sessions are bound to lanes by longest path-boundary prefix match on their `cwd` (set in hook data from `claude` CLI). - A lane's stage is **declared** by the driving Claude session (via `agenthub stage [--evidence "..."]`), not inferred by the dashboard. -- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/agenthub.js`) answers in order: `--lane`/leading id → `AGENTHUB_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work. +- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/agenthub.js`) answers in order: `--workspace`/leading id → `AGENTHUB_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work. - **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **Every non-`claude` lane (`codex`, `antigravity`, any other AI Tools id) carries no pipeline at all:** `codex-ingest.js` (codex) and `antigravity-usage.js` (antigravity, idle only) touch nothing but their `status`, never `stage` or a detection, and the Workspace hides stage, pipeline/feature pickers and PipelineMap for them. Do not bring stage inference back for them. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion. - **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate ` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it. - **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life. @@ -80,7 +80,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). -- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `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` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. +- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `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` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. `/cc-config` is in neither: its router gates itself (`server/lib/cc-target.js` — a non-admin reaches only an ALLOWLIST of reads and own-config writes; `/profiles*`, `/plugins/update-all` and every unlisted endpoint stay an admin's) and every handler acts on ONE target from `resolveCcTarget` — the host's home for an admin/local mode, a member's own `~/.claude` under S3 (writes through `write-as`, `claude plugin|mcp` as their sudo argv: user scope, no `-y`, no env/headers), `403 ENOACCOUNT` otherwise, never the host's; a new endpoint there joins `MEMBER_ROUTES` only if it acts on the target. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. - **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex|cursor]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it; a Cursor API key (`server/lib/cursor-pool.js`) is `-e CURSOR_API_KEY` and nothing else. **Pool groups** (`server/lib/pool-groups.js`) narrow every balancer through `allowed(ownerId, tool)`: a group's member takes only its accounts, a user in no group only the accounts no group lists, a pin wins; a login already live in a home is never swapped for a group change. The tool list is `account-pins.js` `TOOLS` (no DB CHECK). - **A shared Oh My Pi login is reached only through omp's gateway, by a per-pane key, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_OMP_KEY`; that key is one already QUEUED in the clients file (the gateway re-reads it only every 10 s, and an omp started on a refused key hangs for good), and the clients file holds only SHA-256s — of the queue and of LIVE omp panes' keys. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. diff --git a/bin/agenthub.js b/bin/agenthub.js index 5164ac2f..6941e416 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -1281,7 +1281,7 @@ async function cmdDoctor() { // Asked of the SERVER, not of this shell: a run is a tmux session the server // spawns, so the only PATH that decides whether Start Run works is its own — // a systemd --user service never reads your shell profile. Missing tmux is a - // warning, not a failure: it costs the run console and `lanes shell`, nothing + // warning, not a failure: it costs the run console and `workspace shell`, nothing // else. try { const { available, upgrade } = await get("/api/run/tmux"); @@ -1693,7 +1693,7 @@ function cmdOpen() { } /** - * `agenthub lanes add` — adopt a working directory or provision a managed worktree. + * `agenthub workspace add` — adopt a working directory or provision a managed worktree. */ async function cmdLanesAdd(args) { const flag = (name) => { @@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) { if (base) body.base = base; if (slug) body.slug = slug; if (tool) body.tool = tool; - const { lane: provisioning } = await post("/api/lanes/worktree", body); + const { lane: provisioning } = await post("/api/workspaces/worktree", body); const deadline = Date.now() + 30_000; let lane = provisioning; while (lane.status === "provisioning" && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)); - ({ lane } = await get(`/api/lanes/${provisioning.id}`)); + ({ lane } = await get(`/api/workspaces/${provisioning.id}`)); } if (lane.status === "provisioning") { console.error( @@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) { const body = { cwd, title }; if (pipeline) body.pipeline = pipeline; if (tool) body.tool = tool; - const { lane } = await post("/api/lanes", body); + const { lane } = await post("/api/workspaces", body); console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`); } /** - * `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes + * `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes * (owning session died) and cap oversized hook logs, across every lane on * this machine. Machine-wide, not one lane's; pure local process/file * action, no HTTP round-trip. @@ -1790,7 +1790,7 @@ function cmdLanesGc(args) { } /** - * `agenthub lanes profile init ` — detect a Node.js project and scaffold + * `agenthub workspace profile init ` — detect a Node.js project and scaffold * `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not * talk to the dashboard server at all. */ @@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) { } /** - * `agenthub lanes profile check []` — validate a profile without needing a + * `agenthub workspace profile check []` — validate a profile without needing a * lane to exist for it yet. Defaults to the current directory, NOT lane-id - * resolution (unlike every other `lanes` subcommand) — this is meant to run + * resolution (unlike every other `workspace` subcommand) — this is meant to run * against a bare repo right after `profile init`. */ async function cmdLanesProfileCheck(args) { @@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) { } } -/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */ +/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */ async function cmdLanesLifecycle(action, args) { const id = args.find((arg) => !arg.startsWith("--")); const fields = LANE_PREFLIGHT_FIELDS[action]; @@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) { return; } - const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); + const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`); printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields); const keepDb = action === "reset" && args.includes("--keep-db"); if (preflight.database) { @@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) { const body = { confirm: true, expect }; if (args.includes("--force")) body.force = true; if (keepDb) body.keepDb = true; - const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); + const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true }); if (result.status) { const error = result.data?.error || {}; console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`); @@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) { } /** - * `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and + * `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and * whether the driving session is still breathing. */ /** @@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) { } async function cmdLanes() { - const { lanes, counts } = await get("/api/lanes"); + const { lanes, counts } = await get("/api/workspaces"); if (!lanes.length) { console.log( "no workspaces yet — create one with: agenthub workspace add --cwd --title " @@ -2040,7 +2040,7 @@ function toolFromEnv() { * * Only the FIRST positional counts as an id, and only when it is all digits. * Scanning the whole argv for a number would swallow flag values — - * `agenthub lanes logs web --tail 4096` would have addressed lane 4096. + * `agenthub workspace logs web --tail 4096` would have addressed lane 4096. * * @param {string[]} args - Arguments after the subcommand. * @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed. @@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) { return i > -1 ? args[i + 1] : undefined; }; - const explicit = flagValue("lane") || flagValue("workspace"); + const explicit = flagValue("workspace"); if (explicit) return { laneId: explicit, rest: args }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; const tool = flagValue("tool") || toolFromEnv(); const cwd = require("path").resolve(flagValue("cwd") || process.cwd()); - const { lanes } = await get("/api/lanes"); + const { lanes } = await get("/api/workspaces"); // A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked // against the live lane list AND the directory: a pane outlives the lane it @@ -2146,7 +2146,7 @@ function printRuntime(runtime) { } /** - * `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as + * `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as * opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one * lane id. * @@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) { const { laneId, rest: laneArgs } = resolved; if (sub === "runtime") { - printRuntime(await get(`/api/lanes/${laneId}/runtime`)); + printRuntime(await get(`/api/workspaces/${laneId}/runtime`)); return; } @@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) { const body = {}; if (laneArgs.includes("--no-build")) body.build = false; if (laneArgs.includes("--qc")) body.qc = true; - const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); + const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true }); if (result.status) { console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`); process.exitCode = 1; @@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) { const deadline = Date.now() + 15 * 60 * 1000; for (;;) { await new Promise((r) => setTimeout(r, 2000)); - const runtime = await get(`/api/lanes/${laneId}/runtime`); + const runtime = await get(`/api/workspaces/${laneId}/runtime`); if (runtime.healthy) { printRuntime(runtime); return; @@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) { } if (sub === "down") { - const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true }); + const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true }); if (result.status) { console.error( `✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}` @@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) { } const i = laneArgs.indexOf("--tail"); const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : ""; - const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`); + const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`); if (!log.available) { console.log("no logs for this workspace yet."); return; @@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) { return; } const result = await post( - `/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`, + `/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`, { args: laneArgs.slice(1) }, { allowError: true } ); @@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) { : "merge"; const branch = laneArgs.find((arg) => !arg.startsWith("--")); const result = await post( - `/api/lanes/${laneId}/sync-base`, + `/api/workspaces/${laneId}/sync-base`, { mode, branch }, { allowError: true } ); @@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) { const resolved = await resolveLaneArg(argsAfterName); if (!resolved) return null; - const { lane } = await get(`/api/lanes/${resolved.laneId}`); + const { lane } = await get(`/api/workspaces/${resolved.laneId}`); return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; } @@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) { async function cmdFeatureList(args) { const resolved = await resolveLaneArg(args); if (!resolved) return; - const { features } = await get(`/api/lanes/${resolved.laneId}/features`); + const { features } = await get(`/api/workspaces/${resolved.laneId}/features`); if (!features.length) { console.log("no features activated yet — start one with: agenthub feature activate "); return; @@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) { }; const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); if (!resolved) return; - const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, { + const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, { slug, title: flag("title"), }); @@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) { } /** `agenthub feature show [] [--cwd path]` — one feature's saved pipeline. */ -/** `agenthub lanes proof-link [] [--cwd path]` — converge the clone-root +/** `agenthub workspace proof-link [] [--cwd path]` — converge the clone-root * `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically * by anything else in this codebase; a session or hook calls it explicitly. */ async function cmdLanesProofLink(args) { const resolved = await resolveLaneArg(args); if (!resolved) return; - const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`); + const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`); console.log( linked ? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof` @@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) { ); } -/** `agenthub lanes mcp sync [] [--cwd path]` — relocate the source repo's +/** `agenthub workspace mcp sync [] [--cwd path]` — relocate the source repo's * already-configured MCP servers into /.mcp.json and seed Chromium * profiles. Never run automatically; a session calls it explicitly. */ async function cmdLanesMcpSync(args) { const resolved = await resolveLaneArg(args); if (!resolved) return; - const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true }); + const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true }); if (result.status) { console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`); process.exitCode = 1; @@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) { } } -/** `agenthub lanes integration []` — check whether a named +/** `agenthub workspace integration []` — check whether a named * integration (tracker, dev_qc, ci_wait) is turned on for this lane, per * its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */ async function cmdLanesIntegration(args) { @@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) { const resolved = await resolveLaneArg(args.filter((a) => a !== name)); if (!resolved) return; const { enabled } = await get( - `/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}` + `/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}` ); console.log(enabled ? "on" : "off"); process.exitCode = enabled ? 0 : 1; } -/** `agenthub lanes ci status []` — one-shot GitHub +/** `agenthub workspace ci status []` — one-shot GitHub * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ @@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) { const resolved = await resolveLaneArg(args.slice(2)); if (!resolved) return; const result = await get( - `/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, + `/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, undefined, { allowError: true } ); @@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) { } /** - * `agenthub lanes node [] [extra…] [] [--print]` — run ONE node of the + * `agenthub workspace node [] [extra…] [] [--print]` — run ONE node of the * lane's pipeline instead of the whole thing. * * Every node already carries the one command that drives it (`command`, added @@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) { * pasted it into a session by hand. This runs it, in the lane's own cwd: * * - a slash command starts `claude` with it as the prompt; - * - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly. + * - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly. * * With no node id it lists the nodes and their commands, which is also the * error path's answer. `--print` shows the argv and runs nothing. @@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) { const { flags, positional } = parseArgs(args); const resolved = await resolveLaneArg(args); if (!resolved) return; - const { lane } = await get(`/api/lanes/${resolved.laneId}`); + const { lane } = await get(`/api/workspaces/${resolved.laneId}`); // The lane payload's `pipeline_nodes` carries render state but not `aliases`; // the template does. Merge, so the listing can still mark the current node. - const { pipelines } = await get("/api/lanes/pipelines"); + const { pipelines } = await get("/api/workspaces/pipelines"); const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state])); const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({ ...n, @@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) { } /** - * `agenthub lanes pipeline [] []` — show or switch which pipeline - * template a lane renders against. `agenthub lanes add --pipeline` could only set + * `agenthub workspace pipeline [] []` — show or switch which pipeline + * template a lane renders against. `agenthub workspace add --pipeline` could only set * this at creation time, so every lane added from the dashboard's "+ Add lane" * was stuck on `default` with no way back to a 16-node template. */ @@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) { if (!resolved) return; if (!target) { - const { lane } = await get(`/api/lanes/${resolved.laneId}`); - const { pipelines } = await get("/api/lanes/pipelines"); + const { lane } = await get(`/api/workspaces/${resolved.laneId}`); + const { pipelines } = await get("/api/workspaces/pipelines"); console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`); if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} `); console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`); return; } - const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target }); + const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target }); console.log( `${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` + `(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)` @@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) { async function cmdLanesShell(args) { const resolved = await resolveLaneArg(args); if (!resolved) return; - const { lane } = await get(`/api/lanes/${resolved.laneId}`); + const { lane } = await get(`/api/workspaces/${resolved.laneId}`); const sessionName = `agenthub-lane-${lane.id}`; const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], { stdio: "inherit", @@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) { const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); if (!resolved) return; const result = await get( - `/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`, + `/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`, undefined, { allowError: true } ); @@ -2806,7 +2806,7 @@ async function cmdStage(args) { if (!resolved) return; const laneId = resolved.laneId; - const { lane } = await post(`/api/lanes/${laneId}/stage`, { + const { lane } = await post(`/api/workspaces/${laneId}/stage`, { stage, status: flag("status"), evidence: flag("evidence"), @@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820. ${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped. Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1. -${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same -commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}. +${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use +${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}. ${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands require it to be running (${c.bold("agenthub start")} brings one up in the background). @@ -3512,8 +3512,8 @@ function replHelp() { /** Detailed help for one command (or command prefix): every catalog entry * whose invocation starts with the token. Returns false if none matched. */ function commandHelp(token) { - // `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES), - // so `help lanes` finds the `workspace …` rows and vice versa. + // `workspace` and `workspaces` are one command (COMMAND_ALIASES), so + // `help workspaces` finds the `workspace …` rows and vice versa. const canon = (words) => { const [head, ...tail] = words.split(" "); return [COMMAND_ALIASES[head] || head, ...tail].join(" "); @@ -3774,23 +3774,22 @@ async function cmdRepl() { // prompt. Handlers that need the server throw ServerDownError; the caller // decides whether to fall back to an offline reader or report the refusal. -/** Command names that are another command under the user-facing word. - * `workspace` is the name to use; `lanes` (the handler's own name) and - * `--lane` are the OLD names, still working but deprecated. Removing them is - * step 4 of docs/plans/workspace-rename/README.md — not before the third - * release after the first one that shipped these aliases. */ -const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" }; +/** Command names that are another command under the user-facing word. */ +const COMMAND_ALIASES = { workspaces: "workspace" }; const canonicalArgv = (argv) => COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv; -/** One stderr line when an old name is typed, so people and agents move over - * before step 4 removes it. stderr only: scripts reading stdout see nothing. */ -function warnOldNames(argv) { - const old = []; - if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`"); - if (argv.includes("--lane")) old.push("`--lane` → `--workspace`"); - if (old.length) { - console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`)); +/** The old names `agenthub workspace` and `--lane` were removed in step 4 of + * docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling + * — never "Unknown command" or help, so a script written before says why. */ +function refuseOldNames(argv) { + if (argv[0] === "lanes") { + console.error(c.red("✖ renamed: use agenthub workspace …")); + process.exit(1); + } + if (argv.includes("--lane")) { + console.error(c.red("✖ renamed: use --workspace ")); + process.exit(1); } } @@ -3803,7 +3802,6 @@ const COMMANDS = (() => { for (const [name] of items) set.add(name.split(" ")[0]); } set.add("help"); - // The old names stay typeable (and completable) for scripts written before. for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical); return [...set]; })(); @@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) { * for usage/validation failures. `cmd === "repl"` is handled by the entry * points, not here, to avoid recursive REPLs. */ async function runCommand(argv) { - warnOldNames(argv); + refuseOldNames(argv); const [cmd, ...rest] = canonicalArgv(argv); const { flags, positional } = parseArgs(rest); switch (cmd) { @@ -4571,7 +4569,7 @@ async function runCommand(argv) { return cmdReinstallHooks(); case "update-check": return cmdUpdateCheck(); - case "lanes": + case "workspace": if (rest[0] === "add") { return cmdLanesAdd(rest.slice(1)); } diff --git a/client/README.md b/client/README.md index 21ec0120..90823ca3 100644 --- a/client/README.md +++ b/client/README.md @@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo The client is a single-page application (SPA) built with modern web technologies: -- **React 18.3** - Component-based UI with hooks and concurrent features -- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts +- **React 19** - Component-based UI with hooks and concurrent features +- **TypeScript 7** - Full type safety across components, utilities, and API contracts - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `