feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+54 -13
View File
@@ -85,11 +85,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `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 |
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 (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
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).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `PATCH /api/users/:id` (`{role?, disabled?}`), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`, `404 ENOUSER`/`ENOLANE`; an analyst gets `403 EREADONLY`, a member `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
```mermaid
sequenceDiagram
@@ -580,6 +582,7 @@ that file.
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
GET /api/lanes/:id/git/blame?path=<path>
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
@@ -596,10 +599,16 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
refs, parents}]}`, newest first in `--date-order` (no parent before its
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. `ref=` reads another branch's history instead of HEAD's
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
(where the panel's Cherry-pick finds its commits): a local or
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/blame`** → `{available, path, commits: {<sha>: {short, author,
date, summary}}, lines: [<sha>|null]}` — `git blame --porcelain` of the
working-tree file (Annotate); `null` = a line not committed yet. Same
lexical guard as `/git/file`, `403 ESECRET`, `400 ETOOBIG` over 1 MB,
`400 ENOFILE`; an untracked file answers `{available:false}`.
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
file must already exist, be text and within the 1 MB cap, and `baseSha`
@@ -689,12 +698,15 @@ POST /api/lanes/:id/git/commit-message
The Changes panel's ✨ button beside the commit box. Runs the user's own
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
— on the **staged** diff of the workspace's cwd (or `?session=` worktree) — or,
with a body `{"paths": [...]}` (the panel's ticked files), on those files'
working-tree diff against HEAD, untracked ones included — sent on
stdin together with the last ten commit subjects so the message follows the
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
and a `settings.local.json` is never part of the diff. Same-origin, because
each call spends the user's Claude quota. Answers `{"message": "..."}`;
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
`400 ENOSTAGED` with nothing staged (or no readable ticked file), `400 EBADPATH`
for a path git does not report changed, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Act on a workspace's working copy
@@ -718,7 +730,7 @@ cannot collide on `index.lock`.
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
@@ -730,12 +742,24 @@ cannot collide on `index.lock`.
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands | `{"message": "...", "sha": "<HEAD>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
```
- `commit`/`amend` also take `hunks` — `{"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}`
with `context` — to commit only those change blocks of a tracked,
non-renamed file (alone or beside whole `paths`): the commit is built in a
temporary index from git's own fresh diff (never text from the request) and
the real index entries of the committed paths are reset to the new HEAD
afterwards, the rest left as an unstaged change. A hunk no longer in the diff
is `409 EHUNKGONE`, an untracked path `400 EUNTRACKED`, a malformed entry
`400 EBADPATH`.
- `commit`/`amend` with `paths` refuse a conflicted path (`409 ECONFLICTS`) —
a merge is finished by `merge-commit` — and any path git does not report
changed (`400 EBADPATH`).
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no rebase, hard reset or stash here —
@@ -843,7 +867,7 @@ DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
this install; the **Git credentials** tab of Settings
(`/settings/git-credentials`) and the git bar's login form both write
(`/settings/git`) and the git bar's login form both write
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
@@ -1786,7 +1810,24 @@ GET /api/settings/autostart
PUT /api/settings/autostart
```
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
Settings → System's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
#### Status-area icon
```http
GET /api/settings/tray
PUT /api/settings/tray
```
Settings → System's **Show AgentHub icon in the status area** switch (`server/lib/tray.js`). `GET` answers `{ available, enabled, running }`: `available` is false in server mode and on a Linux machine with no graphical session (`DISPLAY`/`WAYLAND_DISPLAY`, read from `systemctl --user show-environment` too), `running` is true while the icon process heartbeats. `PUT { enabled: boolean }` (same-origin only) removes or writes `<data dir>/runtime/tray-off` and starts or stops the icon process at once; it never touches the server. On by default. `400 EBADVALUE` for a non-boolean.
#### Quit AgentHub
```http
POST /api/settings/quit
```
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/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.
---
@@ -2068,7 +2109,7 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's `long_*_tokens` (returned on each `breakdown` entry) and that part is priced at the rule's `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
@@ -2083,7 +2124,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"grok"|"kimi"|"opencode", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Grok Build** reads the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json` (default `~/.grok`) and sends it only to `cli-chat-proxy.grok.com`'s `GET /v1/billing?format=credits` (`wk` = `creditUsagePercent`, `mo` = `used / monthlyLimit`). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). Both are used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. **OpenCode Go** (`id: "opencode"`, `plan: "Go"`) reads opencode's own `opencode-go` API key — `auth.json`, else the `credential` table of `opencode.db`, under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — and sends it only to `opencode.ai`'s `GET /zen/go/v1/usage` (`5h`/`wk`/`mo` meters); an opencode with no Go key has no entry. These readers follow Orca's (MIT). All three cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType`, Grok's `subscriptionTier` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
@@ -2307,7 +2348,7 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
#### Push ingest (`POST /api/hooks/ingest-batch`)
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Database → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
```http
POST /api/hooks/ingest-batch
@@ -2567,7 +2608,7 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
+3 -1
View File
@@ -99,7 +99,9 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| Command | Description |
| ------- | ----------- |
| `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 `kill <pid>` stop command. 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) |
| `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 `<data dir>/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 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) |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
+5 -2
View File
@@ -398,7 +398,7 @@ CREATE TABLE notifications (
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. A Claude entry's `*_above_100k_tokens` input/output rates fill `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5); Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
@@ -411,6 +411,8 @@ CREATE TABLE model_pricing (
cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0, -- 1h-ephemeral cache-write tier
fast_input_per_mtok REAL NOT NULL DEFAULT 0, -- fast-mode premium rates
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
long_input_per_mtok REAL NOT NULL DEFAULT 0, -- prompt above 100k tokens
long_output_per_mtok REAL NOT NULL DEFAULT 0,
-- Time-limited introductory (promo) rates. When intro_until is set, usage on
-- or before that date (YYYY-MM-DD) is priced at the intro_* rates and usage
-- after it at the standard rates. All 0 / NULL = no promo.
@@ -434,6 +436,7 @@ CREATE TABLE model_pricing (
| `input_per_mtok` / `output_per_mtok` | REAL | NO | Standard input / output rate per 1M tokens |
| `cache_read_per_mtok` / `cache_write_per_mtok` / `cache_write_1h_per_mtok` | REAL | NO | Cache read + 5m/1h cache-write rates |
| `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) |
| `long_input_per_mtok` / `long_output_per_mtok` | REAL | NO | Rates for a request whose whole prompt (input + cache read + cache write) is above 100k tokens (`LONG_CONTEXT_TOKENS` in `server/lib/pricing-constants.js`); the cache rates scale by `long_input / input`. 0 = none. They price `token_usage`'s `long_*_tokens` subsets — the part of each count spent by such requests, kept at its high-water mark (`stmts.raiseLongTokens`; the exact reconcile path uses `setLongTokens`) |
| `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields |
| `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings |
| `source` | TEXT | YES | `sync` = kept at LiteLLM's rate by the automatic sync (shown **Auto** in Settings), `user` = edited in Settings and never touched by the sync, NULL = from before this column (adopted as `sync` only while it equals the list) |
@@ -509,7 +512,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
### token_usage_hourly
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier and the eight count columns, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
---
+1
View File
@@ -260,6 +260,7 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
# Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
+1 -1
View File
@@ -23,7 +23,7 @@ Comprehensive guide to integrating with Claude Code's hook system for real-time
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
> **Privacy (Settings → Data → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
> **Privacy (Settings → Database → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
+80 -24
View File
@@ -409,7 +409,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **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-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. 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/<runId>`, 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, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). 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, 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/<runId>` 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=<n>`.
@@ -560,8 +560,8 @@ to spawn a sibling worktree workspace on it, and moving this pane's HEAD under a
working agent is not. **New workspace here** goes through the normal Add-workspace flow
with the fields prefilled — one provisioning path for every workspace, not a second
one hidden in the git bar. There is no **Commit** here: committing is the
overview's staged-only box below, the one commit path, and it only ever takes
what was staged.
overview's commit box below, the one commit path, and it only ever takes the
files ticked there.
**Publish…** is the one button whose destination the user types, so the server
keeps it narrow: it only gives a remote to a repo that has none (it never
@@ -687,10 +687,16 @@ reads the workspace's git surface in five modes:
that block alone. The single-file view offers the same bar.
A new line's NUMBER is a button too: it opens a comment box under the diff,
and **Paste into pane** pastes `<path>:<line> — <text>` into this
workspace's pane focused last (`client/src/lib/panePaste.ts`, the same
workspace's pane focused last — clicked into, or focused through a tab,
Alt+1–4 or the sidebar (`client/src/lib/panePaste.ts`, the same
bracketed `term.paste()` an image drop uses) — nothing is sent until the
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 `@<path>#L<from>-<to>` (Claude Code's line-range
mention; `#L<n>` 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.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -743,27 +749,67 @@ reason — *where you are* (mode, refresh) above, *how you are looking at it*
(range, layout, context, whole file) below — because one row wrapped mid-group
in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls: the message (and its suggest button), the commit button, a **Stage all** that stages every listed file
the index lacks in one call and an **Unstage all** that unstages every staged one — one row when the
panel has room, the buttons wrapping under the message when it is narrow. The commit sends
`only: "staged"`, never `add -A`, and the box is absent with a clean index because
there is nothing it could mean. A file you never want committed (a local note, a scratch folder) can be marked **Never stage** (its row's eye-off button, or right-click): **Stage all** and a folder's stage button then skip it, the row is struck through, and the file is drawn orange in the Files tree (`--kept`, apart from the amber of a modified file). The list is kept in this browser per workspace (`agenthub.changes.neverStage.<lane id>`) and git is never told — nothing is written to the repo, and the file's own Stage still works. It is the dashboard's ONLY commit: the git bar
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. Each group is a folder tree like
**Per-file writes are the panel's, per-repo writes are the git bar's.** The
commit is JetBrains' (PhpStorm's Commit window without its staging area): in
the `work` range the overview lists **Changes** (every tracked change, staged
or not) above **Unversioned Files** (untracked), and every file, folder and
group header has a checkbox. **Commit** takes exactly the ticked files'
working-tree content — `paths`, `git commit --only` on the server — whatever
the index holds, so a staged but unticked file stays staged and uncommitted. A
tracked change arrives ticked, an unversioned one unticked, and a file the
user unticked stays unticked across polls. The commit box sits above the list
and stays pinned while it scrolls: the message (and its ✨ suggest button,
which reads the ticked files), **Amend** (folds the ticked files into the last
commit; nothing ticked rewords it), **Commit (n)** and **Commit and Push**
(the same commit, then the git bar's `push`; a failed push leaves the commit
made and says so). It is hidden while a merge, cherry-pick or revert is open —
a partial commit cannot finish one, **Complete** does. It is the dashboard's
ONLY commit: the git bar above it has none, so no click can sweep the rest of
the tree in with `add -A`.
**A commit can take part of a file**, like JetBrains' diff viewer: in Review
(and in the editor's bar under each block) every change block of a tracked
file has its own checkbox beside its Undo. Unticking one makes the file's box
partial (indeterminate) everywhere it is drawn; ticking a block of an unticked
file takes just that block; unticking every block unticks the file. A block
is remembered by its content (its -/+ lines and how many identical blocks come
before it), so a re-read or an edit elsewhere in the file keeps the choice. At
Commit the panel re-reads each partial file at the default context and sends
its ticked blocks as `hunks` (`{"<path>": [{hunk, blocks}]}`); the server builds
the commit in a temporary index from git's own fresh diff and resets the real
index entries of those paths to the new HEAD, so what was left out stays an
unstaged change in the working tree. The review header of each file carries
the same checkbox and **Rollback** (discard) instead of stage/unstage.
Files are coloured like JetBrains in the Changes list and the Files tree:
unversioned orange (`--kept`), added green, modified and renamed blue
(`--diff-mod`), deleted grey (struck through in the tree), conflicted red,
ignored dimmed.
An unversioned row offers **Add to VCS** (`stage`, and the file is ticked).
Right-click on a file adds **Commit File…** (that file alone ticked, the
message box focused), **Jump to Source** (the whole file), **Copy relative
path**, **Copy as Patch to Clipboard** (the file's diff as `git apply` takes
it), and for an unversioned file **Add to .gitignore** (`ignore`: one anchored,
escaped `/path` line appended to the workspace's own `.gitignore`), **Unstage**
for a file something else left staged in the index, **Show History** (also on
a Files-tree file: `/git/log?file=`, parents rewritten so the graph joins up;
a commit opens that file as that commit changed it, and **Whole branch** goes
back to the branch's history), and **Refresh**. In Review and in an open file
the toolbar's ▲/▼ (and **F7** / **Shift+F7**) jump to the previous / next
difference — the editor's own changed blocks, else each block's first diff
row — and in an open file ◀/▶ open the previous / next file of the list it
was opened from. **Annotate** on an open working file adds a gutter naming
each line's last commit (`GET /git/blame`; date and author, the subject on
hover, a click opens that commit; a line not committed yet says so).
**Discard** is the one irreversible action in the panel: it asks twice in the
UI, and the server refuses it without `confirm: true` and accepts only paths
`git status` is already reporting as changed. Each group is a folder tree like
JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a
chain of folders holding only one sub-folder compacted into one row
(`src/lib`), each folder row showing its file count, folding on a click and —
in Staged Changes / Changes — carrying one button that unstages / stages every
file under it; the group headers fold too. The fold state is not stored. In the tree, a changed file's
whole row takes its status tint, not only its letter.
(`src/lib`), each folder row showing its file count and folding on a click;
the group headers fold too. The fold state is not stored. In the tree, a
changed file's whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
reader's scroll position is worse than a slightly stale view, and those are the
@@ -1499,6 +1545,16 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: staged only", "only": "staged"}'
# Exactly these files (the Changes panel's ticked ones), untracked included
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "fix: two files", "paths": ["src/a.ts", "notes.md"]}'
# Add an untracked file to the workspace's .gitignore
curl -X POST http://localhost:4820/api/lanes/5/git/ignore \
-H "Content-Type: application/json" \
-d '{"paths": ["scratch.log"]}'
# Irreversible, so `confirm` is required and the path must be one git
# already reports as changed
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
@@ -1753,7 +1809,7 @@ agenthub stage <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` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, 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 <id>` 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 `<flag> <id>`), 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. 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 <id>` 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 `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
+10 -6
View File
@@ -66,10 +66,12 @@ Internal build — all rights reserved.
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
grouped General (Appearance — language, theme, pet — / Notifications /
System) / Agents (AI tools, one page per installed AI tool, AI Proxy) / Data
(Database / Pricing / Snapshots & reports / Git — logins and which untracked
worktrees a workspace's Add folder suggests), a search box (Ctrl+F) over every
section whose results deep-link a row's sub-tab and anchor. Model prices live
only on Pricing; a tool's page links there.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
@@ -82,7 +84,9 @@ Internal build — all rights reserved.
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
Vietnamese letters). Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
@@ -209,7 +213,7 @@ agenthub service uninstall # disable and remove it
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. The same is a switch in the dashboard — Settings → About →
once yourself. The same is a switch in the dashboard — Settings → System →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
+1 -1
View File
@@ -176,7 +176,7 @@ agenthub service uninstall # disable và gỡ bỏ
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
Trong dashboard cũng có công tắc tương ứng — Settings → Hệ thống → **Tự động khởi
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
+56 -2
View File
@@ -131,7 +131,7 @@ waits on an install:
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → About; OFF by default), install/refresh the
(Settings → System; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
@@ -241,9 +241,43 @@ cushion — one build deep, because a full copy of this repo is ~130 MB. Outside
a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error.
### Quit
Settings → System → **Quit AgentHub** or `agenthub stop` (issue #65,
`server/lib/quit.js`, `POST /api/settings/quit`) closes every workspace
terminal and stops the server, through the service manager when the unit runs
it. It writes `<data dir>/runtime/stopped` first: while that file exists
`fastPathOk` is true with no server and the worker starts none (`--force`, i.e.
`/agenthub-update`, overrides it; a stale unit is still repaired, not started).
Any server that starts clears it — `agenthub start`, which in a plugin install
runs this bootstrap, or a boot/login through an enabled autostart unit — so
autostart keeps working across a reboot.
### Status-area icon
Like 9router's, AgentHub puts an icon beside the clock — GNOME's top bar, the
macOS menu bar, the Windows notification area (`server/lib/tray.js`,
`scripts/tray.js`). It is its own process, drawn by the `systray2` package's
helper binary (installed with the other runtime dependencies), so a server
restart or a Quit never takes it away: after a Quit its **Start AgentHub** is
the way back. A production server starts it once it is listening and checks
every minute (a Linux server started at boot has no `DISPLAY` until someone
logs in; three quick deaths in a row and that server stops trying). It is on by
default; **Hide this icon** in its menu and Settings → System → **Show AgentHub
icon in the status area** write `<data dir>/runtime/tray-off`, and only that
switch removes it again. One icon per data dir: `<data dir>/runtime/tray.json`
holds its pid and install root and is touched every 5 s as a heartbeat — a
stale file is never trusted, so a pid reused after a reboot is never
signalled. A server replaces an icon from another install root (the previous
plugin build) once at start. Never in server mode, nor from a development
server. The icon starts at login only when the dashboard does (start at login
below); there is no separate autostart entry. Verified on Ubuntu GNOME (X11);
on macOS `systray2` ships only an x86_64 helper (Apple Silicon runs it through
Rosetta 2, else no icon appears), and Windows is untested.
### Auto-start on boot
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
Opt-in, and off by default: Settings → System → **Start AgentHub automatically**
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
the unit's enabled state — on writes a unit for the running server when none
exists and enables it, off disables it; neither starts or stops the server. A
@@ -318,6 +352,26 @@ SIGTERM from anything at all would otherwise leave the dashboard down for good.
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
the Settings panel do — still stops it.
The unit also sets **`KillMode=process`**: a stop or an update's restart ends
the server process alone. tmux forks its server from whichever process first
asks for a session — the dashboard itself after a boot with autostart on — so
it can live in the unit's cgroup, and `control-group` would kill it and every
lane pane on each update. **An update never closes a pane; only Quit does**
(it kills the `agenthub-lane-*` sessions itself). Existing units pick this up
when the next update rewrites them; a checkout's unit on `agenthub service
install`. The launchd twin is `AbandonProcessGroup`.
Belt and braces on Linux: the session that STARTS the tmux server (no server
running yet) is created through `systemd-run --user --scope`
(`server/lib/tmux.js`), so the server lives in a transient scope of its own —
not in `agenthub.service`, and not in the terminal or IDE scope a dev server
was started from. Stopping either, systemd-oomd reaping either, or an old unit
still on `control-group` leaves the panes alone. With no user manager to ask
(SSH without linger, no systemd) it falls back to a plain start. On macOS tmux
daemonizes into its own session and the plist abandons its process group. On
Windows (psmux) whether the server outlives the dashboard has not been
verified yet.
`agenthub service install` (the checkout path) keeps using `installUserService`:
it runs from your own shell, outside the unit's cgroup, so it may block and
start in one step.
+11 -2
View File
@@ -115,7 +115,8 @@ Workspaces made before server mode, or by the host account, have none.
session a member 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.
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
users: Claude Code / Codex config (except the command and skill lists the
@@ -183,7 +184,15 @@ apart, not agents.
## Accounts
Accounts are managed from the host's shell, never from a web route:
An admin manages accounts on the **Users** page (sidebar; admins and analysts
see it, an analyst only reads): add a user, change a role, disable or enable,
reset a password, give a workspace. Every change asks for the admin's OWN
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
themselves (`ESELF`), and each change is written to the audit log. In local
mode these routes refuse (`ELOCALMODE`): there is no login to check.
The same from the host's shell:
```bash
agenthub users list
+32
View File
@@ -12,3 +12,35 @@ Goal (2026-10-07): finish the server part — S2 ownership as proposed, review i
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
## 2026-10-08 — per-user isolation (S3/S4 + per-user proxy/plugins + Codex pool)
Decided: own Linux user per account; skills/plugins = admin baseline + user's own; AI Proxy = user's own + admin's shared (no key shown); tool binaries shared under `/opt/agenthub/tools`; Claude pool shared (exists); NEW Codex multi-account pool. Spike allowed on this machine with sudo (undo log: `/tmp/agh-spike/UNDO.sh`).
- [x] S0 spike (2026-10-08, this machine; test users agh-test1/2 kept for S3 dev, undo `/tmp/agh-spike/UNDO.sh`) → results below
- [ ] Per-user AI Proxy store (+ admin shared)
- [ ] S3 provisioning + spawn/attach as the account
- [ ] S4 shared tools in /opt
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline)
- [ ] Codex account pool
- [ ] Security review, docs, suites
### S0 results (claude 2.1.293, codex 0.155.1, tmux 3.7c, node 26)
| Check | Result |
| --- | --- |
| Layout: `/srv/agenthub/users` 0751 root; home `<acct>:agenthub` 0750; member's own group private | GO — a member cannot list or read another home or the host's |
| claude / codex / node run from root-owned `/opt/agenthub/tools/bin` with a foreign HOME | GO — nothing written outside the home; a root-owned binary cannot self-update (plus `DISABLE_AUTOUPDATER=1`) |
| Per-account tmux server, socket in a setgid `agenthub` dir, `server-access -a <host>` + `chmod 0770` | GO — host send-keys/capture/attach work, the pane runs as the member; tmux keeps group rw across attach; another member is refused |
| Hooks as the member: `node /opt/.../hook-handler.js` → `CLAUDE_DASHBOARD_PORT` on loopback | GO — SessionStart/UserPromptSubmit arrive with the member's transcript_path |
| Codex rollouts readable by the dashboard via group | GO — 0644 under 0775 |
| Claude transcripts readable by the dashboard via group | **NO** — `.jsonl` created 0600, `sessions/` 0700; a default ACL is masked to `---`. Needs a decision |
| agy | not run (it self-updates on any run); root-owned /opt stops that |
| Host group membership | needs a new login: a running `systemd --user` keeps its old groups → server mode wants a system unit with `SupplementaryGroups=agenthub` |
| Shared Claude pool token across Linux users | setup-token is long-lived, no refresh rotation → safe |
| Codex pool with ChatGPT logins | untested (no spare account): refresh tokens rotate, so a copied `auth.json` must be copied back under a lock; API-key accounts have no such risk |
### Open decision: how the dashboard reads every tool's files (user, 2026-10-08: "all AI tools, lightest")
Proposal: server mode runs as a system unit with `AmbientCapabilities=CAP_DAC_READ_SEARCH` (read any file, no write; zero per-read cost, no per-tool code) fenced by `InaccessiblePaths=` (`/etc/shadow`, `/etc/gshadow`, `/etc/sudoers*`, `/root`, `/home`) and `NoNewPrivileges`. Every WRITE in a member's folder (git-ops, file editor, worktrees, tmux spawn) runs as the member via `sudo -u <acct>` (sudoers: host may run commands as `%agenthub-members` only, never root). Git reads need `-c safe.directory=*` (dubious-ownership check). Verification command not yet run (blocked by the agent's permission classifier — user to run it).
Code work waits until the in-flight Quit/tray/Users change-set is committed, then a new branch.