Compare commits

...

25 Commits

Author SHA1 Message Date
nntrivi2001 3237ce4774 chore: v2.8.9 — /tui works in panes with workspace instructions; clearer Startup buttons 2026-10-16 13:39:31 +07:00
nntrivi2001 7c5f30df7d fix(panes): /tui works in panes with workspace instructions
Claude refused `/tui` ("Cannot switch renderers in this session — it has
restrictions a restart can't carry over") in every pane started with
--append-system-prompt-file, i.e. any workspace with instructions (member
panes usually have the owner's). The instructions are now written as the only
CLAUDE.md of a folder of their own (<data dir>/workspace-prompts/<id>/, a
member's prompt-<id>/ in the handoff dir via write-as), passed as one more
--add-dir with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Verified with
Claude Code 2.1.301: the file is listed in /memory and /tui switches. Codex
is unchanged (its pointer line names the same file).
2026-10-16 13:38:09 +07:00
nntrivi2001 5e0a9b526b ui(settings): Quit/Uninstall as real buttons with icons and a busy spinner; code in row descriptions; dimmed unavailable rows; smoother switch 2026-10-16 10:58:14 +07:00
nntrivi2001 538aec3ed9 chore: v2.8.8 — faster updates and lighter local runs, images attach from the chat on Windows, pet chat window 2026-10-16 10:28:58 +07:00
nntrivi2001 692f532972 Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest 2026-10-16 10:27:26 +07:00
nntrivi2001 f460fff406 feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server
- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo
  status strip, Needs you, Mute / Clear alerts); SagiPanel removed.
- Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu).
- pty-attach: resize/write after a Windows attach exited, and node-pty's pipe
  errors, no longer throw out of the WebSocket handler.
- restart-helper: the server started by an update logs to
  data/agenthub-server.log instead of discarding its output.
2026-10-16 10:27:22 +07:00
nntrivi2001 2c712b06d1 test: vendored files out of the link lint, whole-second mtime in codex settle test; regenerate openapi.yaml 2026-10-16 10:25:32 +07:00
nntrivi2001 1b09371042 Merge branch 'perf/local-dbupd' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 14595651fe Merge branch 'perf/local-ingest' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 8739294a72 Merge branch 'perf/local-install' into perf/local-opt 2026-10-16 10:20:59 +07:00
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00
nntrivi2001 5cb70d702b perf: cheaper ingest — no re-parse of live hooked transcripts, delta hook writes, lighter pollers
- Session sync leaves a transcript alone while hooks feed its session live
  (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS,
  default 5 min) and catches it up once quiet; a hook-created session (which
  importSession skips anyway) is only snapshotted, never parsed.
- Hook writer keeps a per-transcript mark (applied after commit): an
  unchanged TranscriptCache result skips token/compaction/API-error/turn
  writes, a grown one walks only the entries after the last one handled.
- Codex usage: one stamp query per pass instead of one row per journal;
  between full passes (first + every 5 min) only the newest day folders and
  journals written in the last hour are statted; fork parents resolved from
  the table or a full walk.
- Cursor/Antigravity polls back off to 60 s after idle passes
  (server/lib/backoff-poll.js); lane sync runs only while another dashboard
  shares the data dir (checked every 30 s).
2026-10-16 10:20:09 +07:00
nntrivi2001 9a4ed54cf7 perf: lighter, louder local install — vendored ReDoc, bootstrap failures shown in the session
- Plugin bootstrap: a failed npm install, a Node older than 22.5 or a failed UI
  build is recorded in runtime/last-failure.json; the next SessionStart prints
  it once as a systemMessage naming the log and /agenthub-doctor (a different
  failure is reported again; a successful bootstrap clears it). The foreground
  pass checks the Node version itself and spawns no worker on an old Node.
- ReDoc: the standalone bundle (redoc 2.5.4, MIT, licence files kept) is
  vendored under server/vendor/redoc/ and the `redoc` dependency dropped —
  a runtime install (npm ci --omit=dev --ignore-scripts) goes 126 MB -> 45 MB.
  vendor/ is excluded from the header audit and prettier.
- git missing from PATH: lane git actions answer 503 EGITMISSING with the
  install command instead of a bare ENOENT.
- package.json engines: node >=22.5.0 (node:sqlite), matching MIN_NODE.
- Docs: INSTALL.md is the one install/setup guide (SETUP.md merged in and now a
  pointer); no VS Code extension claim, one client-install hint, no
  windows-build-tools, better-sqlite3 notes scoped to checkouts, new
  "Plugin bootstrap never comes up" troubleshooting entry.
2026-10-16 10:16:54 +07:00
nntrivi2001 57c423cdb1 perf(client): cut needless polling for local users
- ConversationView: an ended session (SessionDetail, SessionReplay pass
  `ended`) polls its transcript every 60 s instead of 3 s and stops
  rescanning sub-agents; new_event, reconnect and show still fetch at once.
- Workspace: GET /api/run follows run-moving WS messages (lane run
  columns, session created/status change, runs_interrupted) with a 30 s
  fallback, instead of a blind 5 s poll.
- StatusBar: session counts re-read only when a session's
  status/waiting/archived state changes (60 s fallback); costs re-read at
  most every 30 s while work is happening and every 2 min otherwise.
- Dashboard recent activity: 60 s safety poll (was 10 s) plus remote
  import messages; WS still prepends new events.
- New lib/sessionChange.ts matcher with tests.
2026-10-16 10:15:26 +07:00
nntrivi2001 ecbbb4dd8f perf: faster plugin updates, half-size first bundle, images from the chat attach on Windows
- Chat view: an attached image's path is pasted on its own after the text
  (promptForPane). claude turns a paste that is only an image path into
  [Image #n], but left a Windows path inside a paste of text as a plain path.
  Other files and @-mentions stay in the text as before.
- Plugin bootstrap: depsHash covers only what npm installs (no release
  version), so a version bump no longer reinstalls runtime deps; the client's
  installed tree is kept while its manifests are unchanged (no npm ci per
  update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB);
  clientBuild is recorded only after a build that did not fail, so a failed
  one is retried; npm output goes to runtime/npm-install.log.
- Server: a port held by another program ends with a clear message and exit 1
  instead of an uncaught 'error' stack trace.
- Client: the onboarding wizard is lazy and rendered only before onboarding;
  usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the
  Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
2026-10-16 10:05:03 +07:00
nntrivi2001 a58e95a957 chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
2026-10-15 17:17:47 +07:00
nntrivi2001 4d6067650a chore: v2.8.6 — Administration page (Users + Server tabs), right-click menus, several SSH keys per host 2026-10-15 16:45:24 +07:00
nntrivi2001 5bc3f7d34a Merge branch 'feat/admin-console': Administration page (Users + Server tabs) 2026-10-15 16:44:09 +07:00
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00
nntrivi2001 758609775f chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:02:11 +07:00
nntrivi2001 09efb089cf chore: v2.8.4 — member tmux follows tmux upgrades, Members' tmux card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:45:24 +07:00
nntrivi2001 c83ddf62a0 feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last
pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a
3.8 client on a 3.6 server spun it at 100% CPU (three members needed a
manual kill on 2026-10-15).

- /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a
  member's tmux call with the binary of THEIR running server
  (/proc/<pid>/exe, found from the socket in /proc/net/unix), else
  TMUX_BIN. It runs as the member and execs only a process they own.
  sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN
  until then.
- Users → Members' tmux (GET /api/users/tmux): each member server's version
  beside the installed one, sessions, agents working. An admin restarts one
  (POST /api/users/:id/tmux-restart) or every idle outdated one
  (POST /api/users/tmux-restart-idle), with their password again, audited
  per member; a server that will not stop answers 409 ETMUXSTUCK naming
  its pid for root.
- server-s3-system.test.js: `delete` is a provision action since Delete
  user, so the "unknown action" case now uses `purge`.

Operators: run `sudo agenthub server install-system` once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +07:00
nntrivi2001 d8ab850295 chore: v2.8.3 — a stuck tmux server no longer freezes the dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:46 +07:00
nntrivi2001 bac6c04a0b fix(tmux): bound calls on our own server and run them with the server's own binary
A tmux 3.8 installed under a running 3.6 server left PATH's 3.8 client
asking the 3.6 server, which spun at 100% CPU; the dashboard's
synchronous `has-session` on it had no limit and froze every page, hook
and terminal for five minutes (14:07–14:12 on the shared server).

- Every call on our own server now has a 5 s limit and the member's
  one-minute back-off (`host: tmux server not answering` meanwhile), so a
  stuck server costs the event loop one timeout per minute, not one per
  poll.
- Every call on a running server runs that server's own binary
  (`/proc/<pid>/exe`, found from its listening socket in /proc — asking
  tmux would be the very call that hangs), re-read every 10 s; PATH's
  tmux only starts a server or answers `-V`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:16 +07:00
155 changed files with 11843 additions and 2910 deletions
+13 -1
View File
@@ -72,9 +72,21 @@
# DASHBOARD_SESSION_SYNC_MS=30000
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
# Data → Compact, on a click).
# DASHBOARD_DB_MAINTAIN_MS=3600000
# Automatic retention (OFF unless set): finished sessions that started more
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
# Settings → Data → Session cleanup, which writes it to the settings file.
# AGENTHUB_RETENTION_DAYS=90
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
# with no browser connected is skipped; the next browser to connect gets one.
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
+1
View File
@@ -11,3 +11,4 @@ client/package-lock.json
*.md
*.yml
*.yaml
server/vendor/
+27 -15
View File
@@ -310,7 +310,7 @@ graph TD
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit |
| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` |
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (bundled via the `redoc` dependency, never a CDN) so the reference works fully offline / air-gapped |
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (vendored under `server/vendor/redoc/` — the `redoc` npm package is not a dependency — never a CDN) so the reference works fully offline / air-gapped |
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
@@ -1066,7 +1066,7 @@ flowchart TD
- Uses Node.js `http` module directly — no dependencies.
- Resolution order is **env override → discovery file → default**:
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) — while `peersSharingDataDir()` finds another live dashboard on its data dir, which it checks every 30 s — it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- If neither yields anything, the handler falls back to `4820`.
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
@@ -1223,7 +1223,12 @@ common "nothing changed" tick is just a handful of `stat` calls), funnels them
through the same `parseSessionFile` + `importSession` pipeline as every other
path, and broadcasts `session_created` for newly imported sessions /
`session_updated` for grown ones — the same events hooks emit, so the UI
refreshes live. All timers and watchers are `unref`'d and best-effort; nothing
refreshes live. Two kinds of transcript are not re-parsed: one whose session
hooks are feeding **live** (status `active`, newest event a hook's, a hook
within `DASHBOARD_SYNC_LIVE_HOOK_MS`, default 5 min) is left until it goes
quiet or ends — then caught up once — and one whose session a hook created
(no `imported` flag, which `importSession` skips anyway) only has its durable
snapshot refreshed, never a parse. All timers and watchers are `unref`'d and best-effort; nothing
here blocks shutdown or can take down the server.
### Upload request sequence
@@ -1579,7 +1584,11 @@ re-point if that changes.
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
background at startup). Each file's read offset and parse state live in the
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
read from where it stopped. `token_count` events are summed per `turn_context`
read from where it stopped (a pass loads every row's size/mtime in one query).
The first pass and one every five minutes walk every journal; the poll's other
passes stat only the two newest day folders plus the journals the table saw
written in the last hour (or not yet read to the end), so a thread resumed
after an hour's quiet in an older folder is picked up by the next full pass. `token_count` events are summed per `turn_context`
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
parent's whole history into its own file, token events included, so every total
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
15000, `0` disables). agy writes one SQLite file per conversation,
15000, `0` disables; after a pass that read nothing, wrote nothing and left no
fresh session to settle it slows to once a minute until one finds work —
`server/lib/backoff-poll.js`). agy writes one SQLite file per conversation,
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
`antigravity_conversations` holds the mtime read, so a session the user deleted
@@ -1743,7 +1754,8 @@ is ever inferred.
## Cursor Sessions
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables; idle passes slow it to
once a minute like the Antigravity poll) reads the agent
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
@@ -2225,7 +2237,7 @@ sequenceDiagram
| Component | Responsibility |
| --- | --- |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 3 600 000 = 1 h, floor 60 000). A tick with no `/ws` browser connected (`getConnectionCount`) is skipped, and the first browser to connect afterwards (`onConnection` in `server/websocket.js`) gets one check 3 s later if the last one is older than the interval; the manual `POST /api/updates/check` is unaffected. First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
@@ -2325,7 +2337,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
---
@@ -2356,7 +2368,7 @@ graph TD
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
BUBBLE["SpeechBubble.tsx<br/>bubble"]
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
CSS["sagi.css<br/>keyframes + per-mood expressions"]
end
@@ -2386,8 +2398,8 @@ flowchart LR
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
DERIVED --> AVATAR["SagiAvatar"]
DERIVED --> BUBBLE["SpeechBubble"]
DERIVED --> PANEL["SagiPanel"]
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
@@ -2407,10 +2419,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. |
| **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. Styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard` — numbered menus and Claude's unnumbered folder-trust prompt alike) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
+4 -4
View File
File diff suppressed because one or more lines are too long
+407 -82
View File
@@ -1,6 +1,6 @@
# Installation
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
## Fastest path — install it as a Claude Code plugin
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
result and `/agenthub-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
the server stores data through `node:sqlite`), plus `git` and `tmux` from
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
the dashboard never comes up, see
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
dashboard or run it from source.
## Requirements
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ (22+ recommended) | Required for server and client |
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
@@ -76,7 +79,7 @@ Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
node -v # v22.5+ / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
checkout needs the same). The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
@@ -172,24 +175,20 @@ cd AgentHub
## Step 2 — Install dependencies
```bash
npm run setup
npm run setup # or: make setup
```
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
This runs `npm install` (whose `postinstall` hook installs the client's
dependencies in `client/`), installs `client/` once more explicitly, and links
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
it by hand). A plain `npm install` is enough to build and run the dashboard —
only the CLI link is missing.
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
yourself with `npm --prefix client install`.
```bash
npm install
```
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
Or via Makefile:
```bash
make setup
```
`better-sqlite3` gyp/compile warnings here are harmless — see
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
---
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
- **Dashboard** — stats updating in real-time
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
### PWA install (optional)
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
---
## Step 5 — (Optional) Import existing Claude Code history
## Step 5 — (Optional) Importing existing Claude Code history
The server **automatically imports** every session under
`~/.claude/projects/` on startup, so if you've used Claude Code on this
machine before, your historical sessions, agents, events, token counts,
and cost totals should already be visible in the Sessions list.
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
To bring in history from another machine, a backup, or a `.tar.gz`
archive a teammate sent you, use **Settings → Import History** in the
UI. It supports three modes:
### Pick the right mode
```mermaid
flowchart LR
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
UI --> M2["Scan a folder<br/>any absolute path"]
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
M1 --> P["Same parser as live<br/>hook ingestion"]
M2 --> P
M3 --> P
P --> DB[("SQLite<br/>sessions + tokens + cost")]
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
style UI fill:#a855f7,stroke:#c084fc,color:#fff
style P fill:#f59e0b,stroke:#fbbf24,color:#000
style DB fill:#10b981,stroke:#34d399,color:#fff
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
Re-imports are idempotent: sessions are deduplicated by UUID and
compaction baselines preserve pre-compaction token totals, so running
the importer twice never double-counts tokens or cost.
### Step-by-step: moving history from one machine to another
Verify it worked by opening the **Analytics** page and checking that
per-model token totals and estimated cost look correct. Full walkthrough
with per-OS archive commands in
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
### Optional: tune import limits
**On the source machine**, bundle the projects folder:
If you regularly import very large archives, these environment variables
can be raised (the defaults are generous for typical use):
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
| Variable | Default | Purpose |
| --------------------------------- | ------- | ----------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Set them before `npm run dev` or `npm start`:
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks. If you regularly import very large archives, raise them
before `npm run dev` or `npm start` (also listed under
[Environment variables](#environment-variables)):
| Variable | Default | Purpose |
| ----------------------------------- | ------- | ---------------------------------------------------------- |
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
```bash
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
```
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Production mode
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
---
## Optional: Agent extension packs
## How it works
This repository includes extension packs for both Claude Code and Codex.
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
- Claude Code loads project extensions from:
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- Codex project packs live under `.codex/`:
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- Codex:
- `AGENTS.md`
- `.codex/rules/`
- `.codex/agents/`
- `.codex/skills/`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
### Plugin bootstrap never comes up
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
The plugin's `SessionStart` hook only checks a fast path and hands the real
work (npm install, UI build, server start) to a detached background worker, so
a failure there does not show in the session. When the worker's last run
failed, the next session start prints a one-time **AgentHub:** message naming
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
At runtime the server uses this fallback chain:
| File | What it holds |
|---|---|
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
| `npm-install.log` | npm's output for the runtime dependency install |
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
| `server.log` | The server's own output, when it was started detached |
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
dependencies, the server and the service unit, and names the fix for each
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
output visible. The most common causes: Node older than 22.5 (`node -v` in the
shell Claude Code was started from), no network or a proxy blocking the npm
registry (`npm-install.log`), and a port held by another program (`server.log`,
see [Port 4820 already in use](#port-4820-already-in-use)).
If you see an error box at startup saying *"SQLite backend not available"*, either:
### `better-sqlite3` errors during `npm install` / `npm run setup`
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
- **Install build tools** so `better-sqlite3` can compile from source:
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
Only a **checkout** install sees these — the plugin installs with
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
`better-sqlite3` is an optional dependency, and when it cannot be built npm
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
**not** need Python, Visual Studio Build Tools or a C++ compiler.
Then run: `npm rebuild better-sqlite3`
To build it anyway (it is used when present), install a toolchain and run
`npm rebuild better-sqlite3`:
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
### "SQLite backend not available" error on startup
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
Node older than 22.5. Upgrade Node.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
### `npm run dev` fails immediately
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
npm run setup
```
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
```bash
rm -rf node_modules client/node_modules
@@ -409,9 +677,66 @@ that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
**Check 1 — Is the server running?**
---
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
### Port 4820 already in use
When another program holds the port, the server stops with
`Port 4820 is already in use by another program…` (a running AgentHub there is
detected and simply reused). Pick another port:
```bash
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
```
The Vite dev proxy and the Claude Code hooks follow the port on their own
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
## Ports
+12 -420
View File
@@ -1,422 +1,14 @@
# Setup Guide
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
## How it works
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- `agents/*` (the `agenthub` plugin's own agents)
- Codex:
- `AGENTS.md`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Importing existing Claude Code history
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
### Pick the right mode
```mermaid
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
### Step-by-step: moving history from one machine to another
**On the source machine**, bundle the projects folder:
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks; see the env vars table above for
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `better-sqlite3` errors during `npm install` / `npm run setup`
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
2. **Install build tools** and run `npm rebuild better-sqlite3`:
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
### "SQLite backend not available" error on startup
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
---
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
---
### Events Today shows 0 despite recent activity
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
---
### Port 4820 already in use
```bash
DASHBOARD_PORT=4821 npm run dev
```
Then update the Vite proxy in `client/vite.config.ts`:
```ts
proxy: {
"/api": "http://localhost:4821",
"/ws": { target: "ws://localhost:4821", ws: true }
}
```
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
setup guide (it and this file had grown into near-duplicates). Everything that
was here is there:
| Was in SETUP.md | Now in INSTALL.md |
|---|---|
| How it works | [How it works](./INSTALL.md#how-it-works) |
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
+58 -6
View File
@@ -3934,21 +3934,73 @@ async function cmdServer(sub, flags) {
} else {
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
}
const wasServer = auth.isServerMode();
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode on (${file})`);
// One Linux user per account (S3) is the default on Linux: install it now
// (one sudo), unless it is already in place or `--no-system` opts out.
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
const shared = flags["no-system"] === true || process.platform !== "linux";
if (!shared) {
if (linux.serverConf()) {
console.log(c.dim(" one Linux user per account is already installed"));
// The system unit serves; it reads the mode only when it starts.
if (!wasServer) {
const r = spawnSync("sudo", [
"-n",
"systemctl",
"restart",
"--no-block",
"agenthub.service",
]);
console.log(
r.status === 0
? `${c.green("✔")} agenthub.service restarted`
: c.yellow("! restart it: sudo systemctl restart agenthub")
);
}
} else {
console.log(" Installing one Linux user per account (asks for your sudo password)…");
try {
await installSystem({}, settingsEnv);
} catch (e) {
throw new Error(
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
" account with `agenthub server setup --no-system`."
);
}
}
// The admin made above (and anyone made before) gets their Linux account.
process.env.AGENTHUB_MODE = "server";
await cmdUsers("provision", [], { all: true });
}
console.log(" Next:");
console.log(
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
shared
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
: " 1. Log out and back in once, so your shell joins the agenthub groups"
);
console.log(
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
);
console.log(
c.dim(" Members see only their own workspaces, but every pane still runs as this")
);
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
if (shared) {
console.log(
c.yellow(
process.platform === "linux"
? "! --no-system: every pane runs as this one Linux account"
: "! one Linux user per account needs Linux: every pane runs as this one account"
)
);
console.log(
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
);
console.log(
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
);
}
console.log(
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
);
@@ -3989,7 +4041,7 @@ async function cmdServer(sub, flags) {
}
console.error(
c.red(
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
)
);
process.exitCode = 1;
+7 -3
View File
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
const Administration = lazy(() =>
import("./pages/Administration").then((m) => ({ default: m.Administration }))
);
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
function PageFallback() {
@@ -237,13 +239,15 @@ export default function App() {
}
/>
<Route
path="users"
path="admin"
element={
<Page>
<Users />
<Administration />
</Page>
}
/>
{/* The Users page became Administration's first tab. */}
<Route path="users" element={<Navigate to="/admin" replace />} />
<Route
path="settings/*"
element={
+14 -3
View File
@@ -76,7 +76,7 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour";
// Loaded only for an account that has not finished onboarding: the wizard
// carries the appearance, pet and notification settings screens.
const OnboardingWizard = lazy(() =>
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
);
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
+82 -32
View File
@@ -1,7 +1,11 @@
/**
* @file PetChat.tsx
* @description The pet's chat window. A question its Ask box cannot answer
* locally opens here instead of leaving the page: the first message starts a
* @description The pet's chat window — its whole UI: clicking the pet opens
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
@@ -11,16 +15,17 @@
* attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
* forgets it (the session stays in the workspace), **Open in workspace**
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
@@ -33,6 +38,8 @@ import {
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat";
@@ -141,14 +148,21 @@ function toolSummary(names: string[]): string {
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
}
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** A question to send as soon as the chat opens (the Ask box's handoff). */
initialPrompt?: string | null;
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
onPromptTaken?: () => void;
onBack: () => void;
/** Shown in place of the plain name (the rename button). */
title?: ReactNode;
/** Extra header buttons, before Open in workspace (the pat heart). */
headerActions?: ReactNode;
/** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void;
/** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void;
@@ -158,9 +172,10 @@ interface PetChatProps {
export function PetChat({
name,
initialPrompt,
onPromptTaken,
onBack,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
@@ -175,6 +190,8 @@ export function PetChat({
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => {
@@ -248,7 +265,7 @@ export function PetChat({
return () => window.clearInterval(timer);
}, [chat]);
const lines = chatLines(messages);
const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it.
useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
@@ -269,6 +286,19 @@ export function PetChat({
const prompt = text.trim();
if (!prompt || starting) return;
setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
@@ -302,17 +332,35 @@ export function PetChat({
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t]
[chat, starting, t, onLocal]
);
// The Ask box's question, sent once.
const taken = useRef(false);
useEffect(() => {
if (!initialPrompt || taken.current) return;
taken.current = true;
onPromptTaken?.();
void send(initialPrompt);
}, [initialPrompt, onPromptTaken, send]);
// New chat: the old one's session is stopped, not left running unseen —
// asking first only while it is still writing a reply.
const newChat = async () => {
if (chat) {
if (
busy &&
!(await confirmDialog({
title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => {
e?.preventDefault();
@@ -333,11 +381,11 @@ export function PetChat({
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
<ArrowLeft size={16} />
</IconButton>
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
@@ -352,6 +400,7 @@ export function PetChat({
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
@@ -362,8 +411,8 @@ export function PetChat({
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => remember(null)}
disabled={!chat || starting}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
@@ -372,6 +421,8 @@ export function PetChat({
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
@@ -475,10 +526,9 @@ export function PetChat({
e.preventDefault();
submit();
} else if (e.key === "Escape") {
// Esc leaves the chat for the panel, not the whole mascot.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onBack();
onClose();
}
}}
/>
+63 -82
View File
@@ -5,17 +5,20 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket.
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched Ask query starts a claude session in
* the "Sagi" workspace carrying it as the first prompt, then opens that
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* The "do the job" path: an unmatched question is the chat's — it starts a
* session of the default AI tool in the "Sagi" workspace; a default tool the
* chat cannot read back starts there and its session view opens
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,7 +42,7 @@
* ## Internal dependencies
* - `./SagiAvatar`
* - `./SpeechBubble`
* - `./SagiPanel`
* - `./SagiInfo`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
@@ -78,12 +81,13 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
@@ -94,7 +98,8 @@ import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { PetChat, petChatTool } from "./PetChat";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
// Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -126,23 +131,6 @@ interface Anchor {
dragging: boolean;
}
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
/**
* Fixed-position wrapper that places its content next to the avatar and clamps
* it inside the viewport. It measures itself (and re-measures on content/size
@@ -241,10 +229,8 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize());
// The chat window (PetChat.tsx): the pet's whole UI.
const [open, setOpen] = useState(false);
// The chat window (PetChat.tsx), and the Ask question it should send.
const [chatOpen, setChatOpen] = useState(false);
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
@@ -296,7 +282,6 @@ function SagiBody() {
setOpen((v) => !v);
} else if (e.key === "Escape") {
setOpen(false);
setChatOpen(false);
}
};
window.addEventListener("keydown", onKey);
@@ -346,7 +331,6 @@ function SagiBody() {
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
brain.dismissBubble();
setOpen(false);
setChatOpen(false);
};
api.run
.list()
@@ -356,21 +340,17 @@ function SagiBody() {
[brain, navigate]
);
const onAsk = useCallback(
(query: string): string | null => {
// Asked by the chat before any session runs.
const onLocal = useCallback(
(query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
return result.text;
}
// Handoff: a question the pet cannot answer goes to the chat window,
// which starts a session of the default AI tool and shows it as chat.
if (petChatTool()) {
setChatPrompt(result.prompt);
setOpen(false);
setChatOpen(true);
return null;
return { answer: result.text };
}
// A question the pet cannot answer: the chat starts a session of the
// default AI tool and shows it as chat.
if (petChatTool()) return null;
// A default tool the chat cannot read back (not Claude/Codex): start it
// in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt;
@@ -392,7 +372,7 @@ function SagiBody() {
brain.setThinking(false);
}
})();
return null;
return { handled: true };
},
[brain, navigate, t, name]
);
@@ -431,46 +411,49 @@ function SagiBody() {
return (
<>
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
does not - it is Sagi talking, and it has to stay by its mouth. */}
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
onPat={brain.pat}
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1), and kept mounted: unmounting would detach its terminal and
lose a half-typed message. */}
{chatOpen && (
it 1:1). Closing unmounts it; its chat is remembered, so reopening
re-attaches the same session. */}
{open && (
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
title={
<PetNameButton
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
setOpen(false);
}}
onBusy={onBusy}
/>
@@ -491,7 +474,7 @@ function SagiBody() {
</SagiFlyout>
)}
{!open && !menu && !chatOpen && brain.bubble && (
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
@@ -519,18 +502,16 @@ function SagiBody() {
// A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar.
if (place.consumeDrag()) return;
if (chatOpen) setChatOpen(false);
else setOpen((v) => !v);
setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setChatOpen(false);
setMenu(true);
}}
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open || chatOpen}
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open}
title={t("sagi.avatarTitle", { name })}
>
{pet.id === "sagi" || !("kind" in pet) ? (
+297
View File
@@ -0,0 +1,297 @@
/**
* @file SagiInfo.tsx
* @description What the pet knows, shown inside its chat window (`PetChat`):
* the name you rename by clicking it (`PetNameButton`), and a status strip
* (`SagiInfo`) — live / waiting / errored counts and the connection state
* always in sight, and on expanding, the sessions that need the user, by
* name, plus Mute and Clear alerts. Pure presentational: all data and
* behavior are injected by the container (`Sagi.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import {
AlertTriangle,
Bell,
BellOff,
ChevronDown,
ChevronRight,
Hourglass,
Radio,
Trash2,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
/** The pet's name; a click renames it in place (empty = its own name). */
export function PetNameButton({
name,
ownName,
nickname,
onRename,
}: {
name: string;
ownName: string;
nickname: string;
onRename: (name: string) => void;
}) {
const { t } = useTranslation("settings");
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const save = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
return renaming === null ? (
<button
type="button"
className="max-w-full truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
save();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole chat.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
);
}
interface SagiInfoProps {
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Expanded at first: the sessions that need the user and the actions. */
defaultOpen?: boolean;
}
/** The status strip under the chat's header. */
export function SagiInfo({
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
defaultOpen = false,
}: SagiInfoProps) {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(defaultOpen);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-lg text-left"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={t("sagi.chat.status")}
title={t("sagi.chat.status")}
>
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
<span
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
</button>
{open && (
<div className="mt-2 max-h-48 overflow-y-auto">
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
</div>
)}
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
/** One count, compact: icon, number, label on one line. */
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}) {
const t = TONES[tone] ?? TONE_MUTED;
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
<Icon size={11} className={t.icon} aria-hidden />
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</span>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
-392
View File
@@ -1,392 +0,0 @@
/**
* @file SagiPanel.tsx
* @description Expanded Sagi panel: a live status strip (live / waiting /
* errored stat chips + connection state), quick navigation actions, and a
* local "Ask" box. Pure presentational - all data and the ask/navigation
* behavior are injected by the container.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./brain`
*
* ## Public surface
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **SagiPanel**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useState, type FormEvent, type ReactNode } from "react";
import {
Bell,
BellOff,
Trash2,
X,
Send,
AlertTriangle,
Hourglass,
Radio,
Heart,
ChevronRight,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
interface SagiPanelProps {
/** The name the pet goes by now. */
name: string;
/** The pet's own name, which an empty nickname falls back to. */
ownName: string;
/** The user's nickname for the pet, "" when none. */
nickname: string;
onRename: (name: string) => void;
onPat: () => void;
/** Errored, then waiting sessions, named. */
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Returns an answer to display, or null when the query was handed off. */
onAsk: (query: string) => string | null;
onClose: () => void;
}
export function SagiPanel({
name,
ownName,
nickname,
onRename,
onPat,
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const saveName = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
const result = onAsk(query);
setAnswer(result); // null means it handed off (container navigates/closes)
setQuery("");
};
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none" aria-hidden>
🐾
</span>
{renaming === null ? (
<button
type="button"
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole panel.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
)}
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<div className="flex shrink-0 items-center">
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={onPat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
>
<X size={15} />
</button>
</div>
</div>
<div className="p-3">
{/* status stat chips */}
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
</div>
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
{/* ask */}
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.panel.askPlaceholder", { name })}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask", { name })}
/>
<button
type="submit"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
</form>
{answer && (
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
{answer}
</p>
)}
</div>
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}): ReactNode {
const t = TONES[tone] ?? TONE_MUTED;
return (
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
<Icon size={13} className={t.icon} aria-hidden />
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
{value}
</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</div>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
@@ -1,6 +1,6 @@
/**
* @file Sagi.test.tsx
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() {
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
});
it("opens the panel on click and answers a local status question", () => {
it("opens the chat on click and answers a local status question there", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask sagi/i);
const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("names the errored session in its bubble and in the panel", () => {
it("names the errored session in its bubble and in the chat", () => {
renderSagi();
act(() => {
eventBus.publish({
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
});
it("renames the pet from the panel header", () => {
it("renames the pet from the chat header", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
});
it("hands a question it cannot answer to the chat window", () => {
it("hands a question it cannot answer to the AI", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
});
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
const input = screen.getByLabelText(/ask sagi/i);
const input = within(chat).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
ensure.mockRestore();
});
it("a pat shows its reaction on the pet even while the panel is open", () => {
it("reopens the remembered chat, and New chat stops its session", async () => {
localStorage.setItem(
"agenthub.pet.chat",
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
);
const get = vi
.spyOn(api.run, "get")
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
});
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
get.mockRestore();
kill.mockRestore();
});
it("a pat shows its reaction on the pet even while the chat is open", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -175,16 +187,16 @@ describe("Sagi widget", () => {
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
});
it("reflects the live count in the panel status", () => {
it("reflects the live count in the chat status", () => {
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
// The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("div")!;
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
});
it("a tap (no movement) still opens the panel", () => {
it("a tap (no movement) still opens the chat", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
});
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
// No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
+3 -3
View File
@@ -73,7 +73,7 @@ import {
ChevronDown,
FolderTree,
LogOut,
UsersRound,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -479,11 +479,11 @@ export function Sidebar({
{[
// The file manager serves a shared server; locally it is the desktop's.
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
// Server mode: the Users page, for an admin or analyst.
// Server mode: Administration (users, server), for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
+56 -40
View File
@@ -9,8 +9,12 @@
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
* workspace or session, then at most every 30 s while events or session
* updates arrive (spend only moves with work) and every 2 min otherwise, while
* the tab is visible. The session counts re-read only when a session is
* created or its status / waiting / archived state changes (every 60 s
* otherwise). Each figure carries its
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
/** Spend moves only with work: at most this often while events arrive… */
const COST_POLL_MS = 30_000;
/** …and this often regardless (date rollover, budget period, a missed frame). */
const COST_FALLBACK_MS = 120_000;
/** The counts' safety poll; session frames that change a count drive the rest. */
const COUNTS_FALLBACK_MS = 60_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
@@ -384,51 +394,57 @@ export function StatusBar({
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
// Only a frame that moves a session between the counted states (a new
// session, a status / waiting / archived change) re-reads: a session row is
// re-broadcast on almost every hook.
const [countsChanged] = useState(() => sessionChangeMatcher());
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
// Bumped on every lane/session switch: an answer for the previous one is dropped.
const costGen = useRef(0);
const loadCosts = useCallback(() => {
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live() && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live() && setSessionCost(r))
.catch(() => undefined);
}
}, [lane, sessionId]);
useEffect(() => {
let live = true;
costGen.current++;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
if (document.visibilityState === "visible") loadCosts();
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
costGen.current++;
};
}, [lane, sessionId]);
}, [loadCosts]);
useLiveRefresh(loadCosts, {
intervalMs: COST_FALLBACK_MS,
throttleMs: COST_POLL_MS,
match: (msg) =>
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
});
return (
<footer
+6 -3
View File
@@ -33,13 +33,16 @@ export function Switch({
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
// Hover darkens the track (enabled only), so it reads as clickable.
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
checked
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
}`}
>
<span
aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
checked ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
+417
View File
@@ -0,0 +1,417 @@
/**
* @file ServerPanel.tsx
* @description The Administration page's Server tab: the machine AgentHub
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
* machine, resident memory, process count, the busiest processes by name, and
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
* at once. Read-only — the only action here is the members' tmux card
* (`MemberTmuxCard`, an admin's restart).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
HardDrive,
MemoryStick,
MonitorCog,
RefreshCw,
Server,
} from "lucide-react";
import {
api,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
type UserStorage,
} from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { useSpin } from "../../hooks/useSpin";
import { StatCard } from "../StatCard";
import { UserAvatar } from "../users/UserAvatar";
import { MemberTmuxCard } from "../users/MemberTmuxCard";
const POLL_MS = 5000;
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** A thin bar, amber past 75 %, red past 90 %. */
function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
}
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("admin");
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const load = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Names, pictures and home sizes: read once, they change slowly.
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
api.users
.storage()
.then((r) => setStorage(r.storage))
.catch(() => setStorage({}));
}, []);
const byId = new Map((users ?? []).map((u) => [u.id, u]));
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const loading = !stats && !error;
const mem = stats?.memory;
const toggle = (key: string) =>
setOpen((s) => {
const n = new Set(s);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("server.hostGroup");
if (g.kind === "system") return t("server.systemGroup");
const u = g.userId != null ? byId.get(g.userId) : undefined;
return u?.displayName || g.username || "";
};
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="space-y-6" data-testid="server-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<Server className="w-4 h-4 text-accent" />
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
</h2>
{stats && (
<p className="mt-0.5 text-xs text-fg-muted">
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
{" · "}
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
</p>
)}
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("server.cpu")}
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
icon={Cpu}
trend={
stats
? t("server.cores", {
n: stats.cpu.cores,
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
})
: undefined
}
raw={stats?.cpu.model ?? undefined}
help={t("server.cpuHelp")}
loading={loading}
/>
<StatCard
label={t("server.memory")}
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
icon={MemoryStick}
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.swap")}
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
icon={MonitorCog}
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.homesDisk")}
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
icon={HardDrive}
trend={
homesDisk
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
: undefined
}
help={t("server.homesDiskHelp")}
loading={loading}
/>
</div>
<div className="card overflow-x-auto">
<div className="px-5 pt-4 pb-2">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
<p className="text-xs text-fg-muted">
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
</p>
</div>
{stats && !stats.usage ? (
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
) : (
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.who")}</th>
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
<th className={`${th} text-right`}>{t("server.processes")}</th>
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && (
<tr>
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
{t("server.sampling")}
</td>
</tr>
)}
{stats?.usage?.groups.map((g) => {
const u = g.userId != null ? byId.get(g.userId) : undefined;
const home = g.userId != null && storage ? storage[g.userId] : undefined;
const isOpen = open.has(g.key);
const name = groupName(g);
return (
<Fragment key={g.key}>
<tr className="hover:bg-surface-4/40 transition-colors">
<td className="px-5 py-3">
<button
type="button"
onClick={() => toggle(g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex items-center gap-2 text-left disabled:cursor-default"
>
{g.top.length ? (
isOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
)
) : (
<span className="w-3.5" />
)}
{g.kind === "user" ? (
<UserAvatar
name={name}
avatarAt={u?.avatarAt}
userId={g.userId}
className="h-6 w-6 text-[10px]"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
<Server className="w-3.5 h-3.5 text-fg-muted" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm text-fg-primary truncate">{name}</span>
{g.kind === "user" && u?.displayName && (
<span className="block text-[11px] text-fg-muted">{g.username}</span>
)}
</span>
</button>
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(g.memory)}{" "}
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
</div>
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{g.processes}
</td>
<td className="px-5 py-3">
{home ? (
<>
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(home.bytes)}
{homesDisk && (
<span className="text-fg-muted">
{" "}
({fmtPct(pct(home.bytes, homesDisk.total))})
</span>
)}
</div>
{homesDisk && (
<Meter
value={pct(home.bytes, homesDisk.total)}
label={`${name} ${t("server.home")}`}
/>
)}
</>
) : (
<span className="text-sm text-fg-muted">—</span>
)}
</td>
</tr>
{isOpen &&
g.top.map((p) => (
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{fmtPct(p.cpu)}
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{formatBytes(p.memory)}
</td>
<td colSpan={2} />
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{stats && stats.disks.length > 0 && (
<div className="card overflow-x-auto">
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
{t("server.disks")}
</h3>
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.mount")}</th>
<th className={th}>{t("server.device")}</th>
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
<th className={`${th} text-right`}>{t("server.free")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats.disks.map((d) => (
<tr key={d.device}>
<td className="px-5 py-3 text-sm text-fg-primary">
<span className="font-mono">{d.mount}</span>
{d.mount === stats.homesMount && (
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
)}
</td>
<td className="px-5 py-3 text-xs text-fg-secondary">
<span className="font-mono">{d.device}</span> · {d.type}
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
</div>
<Meter value={pct(d.used, d.total)} label={d.mount} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{formatBytes(d.free)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{stats && stats.gpus.length > 0 && (
<div className="card p-5 space-y-3">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
{stats.gpus.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-fg-primary">{g.name}</span>
<span className="text-fg-muted tabular-nums">
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
{formatBytes(g.memoryTotal)}
</span>
</div>
<Meter value={g.utilization} label={g.name} />
</div>
))}
</div>
)}
<MemberTmuxCard isAdmin={isAdmin} />
</div>
);
}
@@ -14,6 +14,10 @@
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +83,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
// hook event. A short visibility-gated poll closes that gap and also rescues
// the conversation from missed/late WebSocket frames.
const POLL_INTERVAL_MS = 3000;
// An ended session writes nothing more: a slow safety poll only, in case it was
// resumed somewhere whose frames this view missed.
const ENDED_POLL_INTERVAL_MS = 60000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
@@ -110,6 +117,8 @@ interface ConversationViewProps {
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
/** The session has ended (and no pane holds it): poll slowly, if at all. */
ended?: boolean;
}
function ConversationViewPane({
@@ -124,6 +133,7 @@ function ConversationViewPane({
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -188,14 +198,18 @@ function ConversationViewPane({
}
}
loadTranscripts();
const interval = window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
// An ended session spawns no new sub-agents: the load above (re-run when
// it ends, for the last ones) is enough.
const interval = ended
? undefined
: window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId]);
}, [sessionId, ended]);
// Sync external initialTranscriptId to internal state
useEffect(() => {
@@ -355,9 +369,12 @@ function ConversationViewPane({
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
interval = window.setInterval(
() => {
if (isShown(onScreen)) fetchNewMessages();
},
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
);
}
function stop() {
if (interval !== null) {
@@ -382,7 +399,7 @@ function ConversationViewPane({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages, onScreen]);
}, [fetchNewMessages, onScreen, ended]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
+390 -22
View File
@@ -18,7 +18,12 @@
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both /
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
* Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge
* without waiting the 7 days.
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
* (server/lib/file-explorer.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -82,6 +87,7 @@ import {
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -96,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
let clipboard: Clip | null = null;
/** A menu item's handler that closes its menu first. */
const pick = (m: { close: () => void }, fn: () => void) => () => {
m.close();
fn();
};
/** The workspace id of a place, or null for a home. */
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
@@ -250,6 +261,10 @@ export function FileBrowser({
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
const [query, setQuery] = useState<string | null>(null);
const [trash, setTrash] = useState<FsTrashItem[]>([]);
// An admin's Trash opens on its root: one folder per user's Trash, counted
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
const [trashRoot, setTrashRoot] = useState(false);
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
@@ -269,6 +284,9 @@ export function FileBrowser({
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | null>();
// The sidebar's places and the path bar's crumbs have their own menus.
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
const crumbCtx = useContextMenu<string>();
// A new caller target (another workspace's Files tab, the page's URL) —
// not the echo of our own `go()` through the page's URL, which would wipe
@@ -330,7 +348,12 @@ export function FileBrowser({
setError(null);
setLoading(true);
try {
if (view === "trash") setTrash((await api.files.trash(target)).items);
if (view === "trash" && trashRoot) {
const out = await api.files.allTrash();
setTrash(out.items);
// A user whose trash could not be read is said so on their folder.
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
else setListing(await api.files.list(target, path));
} catch (e) {
// A dashboard that does not know /fs yet (not restarted) answers 404.
@@ -338,10 +361,11 @@ export function FileBrowser({
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
);
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
if (view === "trash") setTrash([]);
} finally {
setLoading(false);
}
}, [target, path, view, t]);
}, [target, path, view, trashRoot, t]);
useEffect(() => {
void load();
}, [load]);
@@ -635,11 +659,82 @@ export function FileBrowser({
const homeName = (who: string) =>
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
/** Delete forever — asked first when it is all of it or someone else's. */
const purge = async (id: string) => {
if (id === "all" || ro) {
const item = trash.find((i) => i.id === id);
const ok = await confirmDialog({
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
message:
id === "all"
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
});
if (!ok) return;
}
const item = trash.find((i) => i.id === id);
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
};
/** Empty one user's whole Trash from the Trash root — asked first. */
const emptyTrashOf = async (home: string, count: number) => {
const ok = await confirmDialog({
title: t("fx.emptyTrashTitle"),
message: t("fx.emptyTrashBody", {
count,
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
}),
confirmLabel: t("fx.emptyTrash"),
});
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
};
const restoreItem = (id: string) => {
const item = trash.find((i) => i.id === id);
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
};
/**
* Open a Trash. Every user has ONE recycle bin — their home's, over every
* place they deleted in — so `home` opens that user's ("me": the caller's);
* null (no home to open) falls back to the trash of the place shown, and
* "*" opens the root an admin starts on: one folder per user's Trash.
*/
const openTrash = (home: string | null) => {
if (home === "*") {
setTrashRoot(true);
setView("trash");
setSelected(new Set());
return;
}
setTrashRoot(false);
if (home && !(isHomePlace(target) && target.home === home)) {
setTarget({ home });
setPathState("");
setHistory({ back: [], fwd: [] });
onNavigate?.({ home }, "");
}
setView("trash");
setSelected(new Set());
};
// Someone else's Trash can be opened: the Trash starts on its folders.
const manyTrashes = homes.some((h) => h.id !== "me");
const openTrashes = () =>
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
const laneName = (id: number) => {
const l = lanes.find((x) => x.id === id);
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
};
// A home's Trash is its USER's recycle bin, named after them: another
// user's shows THAT user's (an admin may restore from or purge it; the rest
// of the home stays read-only). A workspace's (no home to open) is the
// trash of whoever owns its folder.
const trashLabel = trashRoot
? t("fx.trash")
: !isHomePlace(target)
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
: target.home === "me"
? t("fx.myTrash")
: t("fx.trashOf", { name: homeName(target.home) });
const crumbs = path ? path.split("/") : [];
const menuItem = ctx.menu?.item ?? null;
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
@@ -697,6 +792,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me"
)}
@@ -719,6 +815,7 @@ export function FileBrowser({
type="button"
title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id
)}
@@ -739,6 +836,7 @@ export function FileBrowser({
type="button"
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}
@@ -748,17 +846,10 @@ export function FileBrowser({
</button>
))}
</nav>
<div className="border-t border-border p-2">
<button
type="button"
onClick={() => {
setView("trash");
setSelected(new Set());
}}
className={placeClass(view === "trash")}
>
<Trash2 className="h-4 w-4" />
{t("fx.trash")}
<div className="border-t border-border p-2" data-testid="fx-trashes">
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
<Trash2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.trash")}</span>
</button>
</div>
</aside>
@@ -774,8 +865,8 @@ export function FileBrowser({
</HeadButton>
<HeadButton
label={t("fx.up")}
disabled={view !== "files" || listing?.parent == null}
onClick={goUp}
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
onClick={view === "trash" ? () => openTrash("*") : goUp}
>
<ArrowUp className="h-4 w-4" />
</HeadButton>
@@ -805,15 +896,36 @@ export function FileBrowser({
aria-label={t("fx.location")}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
>
{view === "trash" ? (
{view === "trash" && manyTrashes ? (
<>
<button
type="button"
onClick={() => openTrash("*")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
trashRoot ? "font-semibold" : "text-fg-secondary"
}`}
>
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
</button>
{!trashRoot && (
<span className="flex shrink-0 items-center">
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
<span className="px-2 py-1 text-sm font-semibold">
{isHomePlace(target) ? homeName(target.home) : trashLabel}
</span>
</span>
)}
</>
) : view === "trash" ? (
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
<Trash2 className="h-4 w-4" /> {trashLabel}
</span>
) : (
<>
<button
type="button"
onClick={() => go("")}
onContextMenu={(e) => crumbCtx.open(e, "")}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, "")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -833,6 +945,7 @@ export function FileBrowser({
<button
type="button"
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
@@ -1169,12 +1282,26 @@ export function FileBrowser({
</div>
)}
</div>
) : trashRoot ? (
<TrashFolders
homes={homes}
items={trash}
errors={trashErrors}
error={error}
loading={loading}
homeName={homeName}
onOpen={openTrash}
onEmpty={(home, count) => void emptyTrashOf(home, count)}
onRefresh={() => void load()}
/>
) : (
<TrashView
items={trash}
error={error}
laneName={laneName}
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
onPurge={(id) => void run(() => api.files.purge(target, id))}
onRestore={restoreItem}
onPurge={(id) => void purge(id)}
onRefresh={() => void load()}
/>
)}
@@ -1350,6 +1477,78 @@ export function FileBrowser({
</ContextMenu>
)}
{placeCtx.menu && (
<ContextMenu
x={placeCtx.menu.x}
y={placeCtx.menu.y}
onClose={placeCtx.close}
testId="fx-place-menu"
>
{"lane" in placeCtx.menu.item
? (() => {
const l = placeCtx.menu.item.lane;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()
: (() => {
const home = (placeCtx.menu.item as { home: string }).home;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
{t("fx.openTrash")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{crumbCtx.menu && (
<ContextMenu
x={crumbCtx.menu.x}
y={crumbCtx.menu.y}
onClose={crumbCtx.close}
testId="fx-crumb-menu"
>
{(() => {
const dir = crumbCtx.menu.item;
const name = dir.split("/").pop() || placeName(target);
return (
<>
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
<MenuItem
onSelect={pick(crumbCtx, () =>
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
)}
>
{t("fx.downloadZip")}
</MenuItem>
{!ro && clip && (
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
{t("fx.pasteInto")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{ask && (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
@@ -1651,20 +1850,157 @@ function InlineName({
);
}
/**
* An admin's Trash root: one folder per user's Trash (theirs first), each
* with its item count — or why it could not be read. Opening one shows that
* user's Trash, where its items are restored or deleted forever.
*/
function TrashFolders({
homes,
items,
errors,
error,
loading,
homeName,
onOpen,
onEmpty,
onRefresh,
}: {
homes: FsHome[];
/** Every Trash's items, merged, each tagged with its `home`. */
items: FsTrashItem[];
/** Per home id: why its Trash could not be read. */
errors: Record<string, string>;
error: string | null;
loading: boolean;
homeName: (id: string) => string;
onOpen: (home: string) => void;
onEmpty: (home: string, count: number) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<string | null>();
const counts = new Map<string, number>();
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
return (
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash-root"
onContextMenu={(e) => menu.open(e, null)}
>
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
{t("fx.trashFoldersHint")}
</div>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
{ordered.map((h) => {
const n = counts.get(h.id) ?? 0;
const failed = errors[h.id];
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
return (
<button
key={h.id}
type="button"
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
onClick={() => onOpen(h.id)}
onContextMenu={(e) => menu.open(e, h.id)}
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
data-testid="fx-trash-folder"
>
<span className="relative">
<Folder
className={`h-14 w-14 ${
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
}`}
/>
{h.id === "me" ? (
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
) : (
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
)}
</span>
<span className="w-full truncate text-[13px] font-medium">{name}</span>
<span
className={`w-full truncate text-[11px] ${
failed ? "text-status-danger" : "text-fg-muted"
}`}
title={failed}
>
{failed
? failed
: loading
? "…"
: n
? t("fx.itemsCount", { count: n })
: t("fx.trashFolderEmpty")}
</span>
</button>
);
})}
</div>
{menu.menu && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="fx-trash-root-menu"
>
{menu.menu.item !== null ? (
(() => {
const home = menu.menu.item;
const n = counts.get(home) ?? 0;
return (
<>
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
);
})()
) : (
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
)}
</ContextMenu>
)}
</div>
);
}
function TrashView({
items,
error,
laneName,
onRestore,
onPurge,
onRefresh,
}: {
items: FsTrashItem[];
error: string | null;
onRefresh: () => void;
laneName: (id: number) => string;
onRestore: (id: string) => void;
onPurge: (id: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const menu = useContextMenu<FsTrashItem | null>();
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash"
onContextMenu={(e) => menu.open(e, null)}
>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
<span className="flex-1">{t("fx.trashHint")}</span>
<button
@@ -1692,6 +2028,7 @@ function TrashView({
key={i.id}
className="border-b border-border/40 hover:bg-surface-2"
data-testid="fx-trash-row"
onContextMenu={(e) => menu.open(e, i)}
>
<td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2" title={i.path}>
@@ -1732,13 +2069,44 @@ function TrashView({
))}
</tbody>
</table>
{!items.length && (
{!items.length && !error && (
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
<Trash2 className="h-14 w-14 opacity-40" />
<p className="text-sm">{t("fx.trashEmpty")}</p>
</div>
)}
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
{menu.menu.item ? (
(() => {
const i = menu.menu.item;
return (
<>
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
{t("fx.restore")}
</MenuItem>
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
{t("fx.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
{t("fx.purge")}
</MenuItem>
</>
);
})()
) : (
<>
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}
@@ -132,7 +132,11 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
@@ -3,7 +3,11 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, and restores from the Trash.
* batch over 500 MB before sending anything, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -12,6 +16,9 @@ import userEvent from "@testing-library/user-event";
import { FileExplorer } from "../FileExplorer";
import { api, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
@@ -32,6 +39,7 @@ vi.mock("../../../lib/api", () => ({
copy: vi.fn(),
remove: vi.fn(),
trash: vi.fn(),
allTrash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
@@ -70,6 +78,7 @@ beforeEach(() => {
} as never);
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
vi.mocked(api.files.homes).mockResolvedValue({
homes: [
{ id: "me", label: "An", readOnly: false },
@@ -212,10 +221,178 @@ describe("FileExplorer", () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "Trash" }));
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
);
});
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const trashes = screen.getByTestId("fx-trashes");
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
const folders = await screen.findAllByTestId("fx-trash-folder");
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
await user.click(folders[1]!);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
)
);
expect(api.files.purge).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
await waitFor(() =>
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
// Back to the folders through the path bar.
await user.click(
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
name: "Trash",
})
);
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
});
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
vi.mocked(api.files.allTrash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
home: "bob",
owner: "Bob",
},
],
errors: [],
});
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "bob12345",
name: "old",
laneId: null,
path: "old",
isDir: true,
deletedAt: "2026-10-15T08:00:00.000Z",
store: "host",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
const folders = await screen.findAllByTestId("fx-trash-folder");
// My Trash is empty: its Empty Trash is disabled.
fireEvent.contextMenu(folders[0]!);
expect(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
).toHaveProperty("disabled", true);
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(folders[1]!);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
name: "Empty Trash",
})
);
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
await user.click(
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
);
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
await user.click(
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
);
await waitFor(() =>
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
);
});
it("right-clicks a sidebar workspace and a path crumb", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
);
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
await user.click(
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
);
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
});
it("shows why a Trash could not be read instead of an empty one", async () => {
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
);
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
expect(screen.queryByText("The Trash is empty.")).toBeNull();
});
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
@@ -242,7 +419,7 @@ describe("FileExplorer", () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: /Bob/ }));
await user.click(await screen.findByRole("button", { name: "Bob" }));
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
+11 -4
View File
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import {
ModelMenu,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
@@ -491,7 +497,7 @@ export const PaneChat = memo(function PaneChat({
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = async (line: string) => {
const command = async (line: string, files: string[] = []) => {
if (
notStarted &&
!(await confirmDialog({
@@ -501,14 +507,15 @@ export const PaneChat = memo(function PaneChat({
}))
)
return false;
const ok = submitToRun(runId, line);
const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = async () => {
if (!text || uploading) return;
if (!(await command(text))) return;
const out = promptForPane(prompt, images);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
+3 -3
View File
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
import { promptForPane } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -246,8 +246,8 @@ const ITEM =
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
const { text, files } = promptForPane(draft.prompt, draft.images);
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
+17
View File
@@ -208,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
export function promptForPane(
text: string,
images: PromptImage[]
): { text: string; files: string[] } {
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
files: images.filter(isImage).map((i) => i.path),
};
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
+6 -2
View File
@@ -666,8 +666,12 @@ export const TerminalView = memo(function TerminalView({
() =>
registerPane(
runId,
(text, submit) => {
termRef.current?.paste(text);
(text, submit, files = []) => {
const term = termRef.current;
// The text, then each image's path in a paste of its own — the one
// shape `claude` turns into `[Image #n]` on every platform.
if (text) term?.paste(files.length ? `${text} ` : text);
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
// Enter a beat after the paste, so a TUI that times its paste
// detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard.
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,8 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
[]
);
});
@@ -490,7 +491,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
});
it("sends without asking once the session has started", async () => {
@@ -499,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => {
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ import {
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "../RunSetup";
@@ -586,3 +587,23 @@ describe("ModelMenu", () => {
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
});
});
describe("promptForPane — images apart, every other file in the text", () => {
it("keeps @-mentions and other files in the text, and takes images out", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n].
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
];
expect(promptForPane(" look ", images)).toEqual({
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
files: [
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
"/data/drops/0123456789abcdef.JPG",
],
});
});
});
@@ -9,17 +9,24 @@
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* the machine, database included (scripts/uninstall.js). Quit is a plain
* button and Uninstall a danger one, both the same width with an icon, and
* each spins while its request runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Power, Trash2 } from "lucide-react";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
// The two action buttons share one width, so they line up under the switches.
const ACTION = "min-w-[6.5rem] justify-center text-xs";
const ICON = "h-3.5 w-3.5 shrink-0";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
@@ -126,9 +133,11 @@ export function AutostartSettings() {
type="button"
data-testid="quit-agenthub"
disabled={quitting}
aria-busy={quitting}
onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-secondary ${ACTION}`}
>
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
{t("quit.button")}
</button>
}
@@ -141,9 +150,15 @@ export function AutostartSettings() {
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
aria-busy={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
className={`btn-danger ${ACTION}`}
>
{uninstalling ? (
<Loader2 className={ICON + " animate-spin"} />
) : (
<Trash2 className={ICON} />
)}
{t("uninstall.button")}
</button>
}
@@ -2,14 +2,19 @@
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without
* one), the SSH
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
* since a member's commits fail without one; a new SSH key is labelled with
* that email), the SSH
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works) — and, for
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
@@ -19,12 +24,23 @@
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
import { PageTabs } from "../PageTabs";
import { Switch } from "../Switch";
const INPUT =
@@ -176,6 +192,8 @@ export function GitIdentitySettings() {
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
// null = not picked: opens on the first kind of host that has a key.
const [sshTab, setSshTab] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@@ -205,9 +223,36 @@ export function GitIdentitySettings() {
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
// The default key of a type (a named key sits beside it).
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
@@ -259,7 +304,7 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1" });
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
@@ -269,23 +314,55 @@ export function GitIdentitySettings() {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user }),
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "" });
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const [other, setOther] = useState({ host: "", user: "" });
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run(
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
t("gitIdentity.otherSaved", { host })
() =>
api.gitIdentity.setSshUser({
host,
user: other.user.trim(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
if (ok) setOther({ host: "", user: "" });
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="select !py-1 !text-xs"
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
);
const removeLogin = async (host: string) => {
if (
@@ -308,13 +385,38 @@ export function GitIdentitySettings() {
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? "",
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
// The SSH keys, one tab per kind of host — every section at once was a wall.
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
];
const activeSshTab =
sshTab && sshTabs.some((x) => x.id === sshTab)
? sshTab
: !keyOf("ed25519") && keyOf("rsa")
? "aws"
: "git";
const saveAuthor = async () => {
const ok = await run(
() =>
@@ -389,50 +491,262 @@ export function GitIdentitySettings() {
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
<PageTabs
ariaLabel={t("gitIdentity.sshTitle")}
tabs={sshTabs}
activeId={activeSshTab}
onSelect={setSshTab}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
)}
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.host} className="mt-1 flex items-center gap-2">
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
)}
{activeSshTab === "other" && (
<section
aria-label={t("gitIdentity.otherTitle")}
className="space-y-1.5 rounded border border-border p-2.5"
>
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.otherTitle")}
</div>
<div className="space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.alias ?? r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.region} · {r.user}
{r.alias ? `ssh://${r.alias}/ → ` : ""}
{r.user}@{r.host} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -441,49 +755,50 @@ export function GitIdentitySettings() {
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={other.alias}
onChange={(e) => setOther({ ...other, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(other.key, (key) => setOther({ ...other, key }))}
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.host}>git clone ssh://{r.host}/v1/repos/&lt;repo&gt;</Code>
))}
</div>
</div>
</section>
)}
</KeyCard>
</PageTabs>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
@@ -503,67 +818,6 @@ export function GitIdentitySettings() {
/>
</div>
)}
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
<summary className="cursor-pointer text-[11px] text-fg-primary">
{t("gitIdentity.otherTitle")}
</summary>
<div className="mt-1.5 space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.user}@{r.host}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</details>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
@@ -0,0 +1,180 @@
/**
* @file RetentionSettings.tsx
* @description Settings → Data → Session cleanup, under the manual purge: the
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
* that the hourly maintenance applies, the database's size and free space, and
* **Compact now** — a full VACUUM behind a confirm, polled through
* `GET /api/settings/retention` while it runs, then toasted. Backed by
* `server/lib/db-retention.js`; a value the environment set is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import type { RetentionState } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
export function RetentionSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<RetentionState | null>(null);
const [days, setDays] = useState("0");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.retention
.get()
.then((s) => {
setState(s);
setDays(String(s.days));
})
.catch(() => undefined);
}, []);
// A Compact takes a while on a large database: poll until it ends.
const compacting = !!state?.compacting;
useEffect(() => {
if (!compacting) return;
const id = window.setInterval(() => {
api.settings.retention
.get()
.then((s) => {
setState(s); // the draft keeps its value
if (s.compacting || !s.last_compact) return;
if (s.last_compact.ok) {
toast.success(
t("retention.compactDone", {
before: formatBytes(s.last_compact.size_before ?? 0),
after: formatBytes(s.last_compact.size_after ?? 0),
})
);
} else {
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
}
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [compacting, t]);
const max = state?.max_days ?? 3650;
const n = Number(days);
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
const changed = state !== null && valid && n !== state.days;
const owned = !!state?.env_owned.days;
async function save() {
setSaving(true);
try {
const s = await api.settings.retention.save(n);
setState(s);
setDays(String(s.days));
toast.success(t("retention.saved"));
} catch (err) {
toast.error(err);
} finally {
setSaving(false);
}
}
async function compact() {
const ok = await confirmDialog({
title: t("retention.compactConfirmTitle"),
message: t("retention.compactConfirmBody"),
confirmLabel: t("retention.compact"),
});
if (!ok) return;
try {
setState(await api.settings.retention.compact());
} catch (err) {
toast.error(err);
}
}
const purged = state?.last_run?.retention?.purged_sessions;
return (
<div className="border-t border-border pt-4 space-y-3">
<div>
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
{t("retention.days")}
</label>
<div className="flex items-center gap-2">
<input
id="retention-days"
type="number"
min="0"
max={max}
step="1"
inputMode="numeric"
value={days}
disabled={!state || owned}
onChange={(e) => setDays(e.target.value)}
aria-invalid={!valid || undefined}
aria-describedby={!valid ? "retention-days-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{!valid && (
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
{t("retention.invalid", { max })}
</p>
)}
</div>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || owned}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
<p className="text-xs text-fg-secondary">
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
{state?.last_run && purged !== undefined && (
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
)}
</p>
{owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void compact()}
disabled={!state || compacting}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
>
{compacting ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Minimize2 className="w-3 h-3 inline mr-1" />
)}
{compacting ? t("retention.compacting") : t("retention.compact")}
</button>
{state?.space && (
<span className="text-xs text-fg-muted">
{t("retention.space", {
size: formatBytes(state.db_size),
free: formatBytes(state.space.free_bytes),
mode: state.space.auto_vacuum,
})}
</span>
)}
</div>
</div>
);
}
+31 -4
View File
@@ -4,7 +4,8 @@
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* rows in order. A string description shows its `backticked` words as code,
* and a `disabled` row dims its label, so an unavailable control reads as one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,30 +33,55 @@ export function SettingsSection({
);
}
/** `text` with every `backticked` run drawn as inline code (a command to type). */
export function withCode(text: string): ReactNode {
const parts = text.split(/`([^`]+)`/);
if (parts.length === 1) return text;
return parts.map((p, i) =>
i % 2 ? (
<code
key={i}
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
{p}
</code>
) : (
p
)
);
}
export function SettingsRow({
label,
description,
control,
alignTop = false,
disabled = false,
id,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
/** The control cannot be used here: the label is dimmed with it. */
disabled?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) {
return (
<div
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
<div className="min-w-0">
<p className="text-sm text-fg-secondary">{label}</p>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
{description && (
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
{typeof description === "string" ? withCode(description) : description}
</p>
)}
</div>
<div className="shrink-0">{control}</div>
</div>
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
id={id}
label={label}
description={description}
disabled={disabled}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
/>
);
@@ -4,7 +4,10 @@
* first, and a member (S3) adds an HTTPS login of their own while the host's
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — so the sections never mix.
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -21,6 +24,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
removeKeyById: vi.fn(),
setTerminalSsh: vi.fn(),
setSshUser: vi.fn(),
removeSshUser: vi.fn(),
@@ -44,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
...over,
});
/** Opens one tab of the SSH keys. */
const tab = async (name: string | RegExp) =>
userEvent.click(await screen.findByRole("tab", { name }));
beforeEach(() => {
vi.clearAllMocks();
});
@@ -97,6 +105,7 @@ describe("GitIdentitySettings", () => {
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
);
render(<GitIdentitySettings />);
await tab("AWS CodeCommit");
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
@@ -116,11 +125,14 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
const gh = await screen.findByRole("region", { name: /GitHub/ });
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
// Each section explains only its own host.
// One tab at a time: the other host's section is not on screen.
expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull();
expect(within(gh).queryByText(/IAM/)).toBeNull();
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull();
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(within(aws).queryByText(/GitHub/)).toBeNull();
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
@@ -143,7 +155,7 @@ describe("GitIdentitySettings", () => {
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1")).toBeInTheDocument();
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
@@ -157,7 +169,7 @@ describe("GitIdentitySettings", () => {
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByText("Other SSH hosts (advanced)"));
await tab("Other hosts");
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
await userEvent.type(screen.getByLabelText("User name"), "deploy");
const form = screen.getByLabelText("Host").closest("form")!;
@@ -166,7 +178,7 @@ describe("GitIdentitySettings", () => {
host: "git.example.com",
user: "deploy",
});
expect(await screen.findByText("deploy@git.example.com")).toBeInTheDocument();
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
@@ -194,4 +206,69 @@ describe("GitIdentitySettings", () => {
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
it("makes a named key and saves a second AWS account under its name with that key", async () => {
const RSA = {
id: "id_rsa",
type: "rsa" as const,
name: null,
publicKey: "ssh-rsa A x",
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
type: "rsa" as const,
name: "aws-prod",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [RSA, PROD], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys");
let more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
await tab("More keys (1)");
more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
);
});
});
@@ -0,0 +1,267 @@
/**
* @file MemberTmuxCard.tsx
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -0,0 +1,100 @@
/**
* @file MemberTmuxCard.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux
* user per account, lists each running member server with its version and a
* badge when it is older than the installed tmux, warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one,
* the working member left out — only with their own password typed; an
* analyst sees no restart control.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api";
const users = vi.hoisted(() => ({
tmux: vi.fn(),
tmuxRestart: vi.fn(),
tmuxRestartIdle: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users } };
});
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard";
const DATA: MemberTmux = {
available: true,
installed: "3.8",
matched: true,
users: [
{ id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true },
{ id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true },
{
id: 4,
username: "ann",
running: true,
version: "3.8",
sessions: 3,
busy: 0,
outdated: false,
},
{ id: 5, username: "bob", running: false, busy: 0, outdated: false },
],
};
beforeEach(() => {
vi.clearAllMocks();
users.tmux.mockResolvedValue(DATA);
});
describe("MemberTmuxCard", () => {
it("renders nothing without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("lists running servers and marks the ones older than the installed tmux", async () => {
render(<MemberTmuxCard isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
// An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
});
it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
expect(go).toBeDisabled();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(go);
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
});
it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
});
});
@@ -0,0 +1,27 @@
/**
* @file usePrefersReducedMotion.ts
* @description The OS "reduce motion" preference, followed live. Its own
* module so a settings screen can read it without importing the pet overlay
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
* pulled into every page's first bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
+1
View File
@@ -115,6 +115,7 @@ export const i18nReady = i18n
"splash",
"lanes",
"users",
"admin",
"onboarding",
"artifacts",
],
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Administration",
"description": "Accounts and the server AgentHub runs on.",
"tabs": {
"users": "Users",
"server": "Server"
},
"server": {
"title": "Server",
"uptime": "up {{v}}",
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cores": "{{n}} cores · load {{load}}",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"homesDisk": "Homes disk",
"homesDiskHelp": "The disk that holds the users' home folders.",
"free": "free",
"usageTitle": "Usage by user",
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"who": "User",
"processes": "Processes",
"home": "Home",
"sampling": "Sampling…",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"disks": "Disks",
"mount": "Mount",
"device": "Device",
"used": "Used",
"homesBadge": "homes",
"gpus": "GPUs"
}
}
@@ -29,8 +29,10 @@
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"open": "Open",
"copyLink": "Copy link",
"preview": "Preview",
"download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
+13 -2
View File
@@ -577,7 +577,7 @@
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
"fx.pasted": "Pasted.",
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
"fx.conflictTitle": "Already there",
"fx.conflictBody": "This name is taken in the destination: {{names}}",
"fx.conflictAll": "Do this for every remaining file",
@@ -591,6 +591,17 @@
"fx.purge": "Delete forever",
"fx.restored": "Restored.",
"fx.trashEmpty": "The Trash is empty.",
"fx.trashOf": "{{name}}'s Trash",
"fx.myTrash": "My Trash",
"fx.trashAt": "Trash ({{name}})",
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
"fx.trashFolderEmpty": "Empty",
"fx.openTrash": "Open Trash",
"fx.purged": "Deleted forever.",
"fx.emptyTrashTitle": "Empty the Trash?",
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
"fx.purgeTitle": "Delete forever?",
"fx.purgeBody": "“{{name}}” in {{where}} will be deleted forever. This cannot be undone.",
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
"changes.openInExplorer": "Open in File Explorer",
"fx.places": "Workspaces",
@@ -620,7 +631,7 @@
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
"fx.myHome": "Home",
"fx.userHomes": "Users' homes",
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
+1
View File
@@ -87,5 +87,6 @@
"sidebar": "Sidebar",
"now": "now",
"users": "Users",
"admin": "Administration",
"files": "Files"
}
+39 -6
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
"panel": {
"label": "{{name}} companion",
"live": "Live",
"offline": "Offline",
"close": "Close {{name}}",
"statLive": "live",
"statWaiting": "waiting",
"statErrored": "errored",
"mute": "Mute",
"unmute": "Unmute",
"clearAlerts": "Clear alerts",
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
"ask": "Ask {{name}}",
"send": "Send",
"rename": "Click to rename {{name}}",
"renameLabel": "Pet name",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Chat with {{name}}",
"back": "Back",
"close": "Close {{name}}",
"new": "New chat",
"status": "Status — click to show the sessions that need you",
"endTitle": "End this chat?",
"endBody": "It is still writing a reply. A new chat stops this session.",
"endConfirm": "Stop and start new",
"openWorkspace": "Open in workspace",
"typing": "typing…",
"placeholder": "Ask {{name}} anything…",
@@ -937,7 +936,24 @@
"otherHost": "Host",
"otherUser": "User name",
"otherSaved": "SSH user saved for {{host}}",
"privateNote": "Only the public key is ever shown; the private key never leaves the server."
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
"anyKey": "Any key",
"defaultEd": "ED25519 (default)",
"defaultRsa": "RSA (default)",
"loginKey": "Key",
"alias": "Account name",
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
"moreKeysTitle": "More SSH keys",
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
"newKey": "New key",
"keyName": "Key name",
"keyNamePlaceholder": "e.g. aws-prod",
"keyType": "Key type",
"tabGit": "GitHub · GitLab",
"tabMore": "More keys",
"tabOther": "Other hosts"
},
"gitCreds": {
"title": "Git credentials",
@@ -1200,6 +1216,23 @@
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"retention": {
"title": "Automatic cleanup",
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
"days": "Delete sessions older than",
"off": "Automatic cleanup is off.",
"on": "Sessions older than {{days}} days are deleted every hour.",
"invalid": "A whole number from 0 (off) to {{max}}",
"saved": "Automatic cleanup saved",
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
"compact": "Compact now",
"compacting": "Compacting…",
"compactConfirmTitle": "Compact the database?",
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
"compactDone": "Database compacted: {{before}} → {{after}}",
"compactFailed": "Compact failed: {{error}}"
},
"backup": {
"title": "Daily backup",
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
+35
View File
@@ -204,5 +204,40 @@
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
"confirm": "Delete for good",
"needsPassword": "Enter your own password below first."
},
"tmux": {
"title": "Members' tmux",
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
"refresh": "Refresh",
"notMatched": "Run `agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
"empty": "No member has a tmux server running.",
"user": "User",
"version": "tmux",
"sessions": "Sessions",
"agents": "Agents",
"stuck": "not answering",
"outdated": "older than installed",
"since": "since {{at}}",
"working_one": "{{count}} working",
"working_other": "{{count}} working",
"idle": "idle",
"restart": "Restart {{name}}'s tmux",
"restartIdle_one": "Restart {{count}} idle member",
"restartIdle_other": "Restart {{count}} idle members",
"restartIdleTitle": "Restart idle members' tmux",
"restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.",
"restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.",
"restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.",
"restartButton": "Restart",
"done": "{{name}}'s tmux restarted",
"doneIdle": "Restarted: {{names}}",
"errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
}
},
"rowMenu": {
"openHome": "Open home in Files",
"copyUsername": "Copy username",
"copyEmail": "Copy email"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Quản trị",
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
"tabs": {
"users": "Người dùng",
"server": "Máy chủ"
},
"server": {
"title": "Máy chủ",
"uptime": "đã chạy {{v}}",
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cores": "{{n}} lõi · tải {{load}}",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"homesDisk": "Ổ chứa thư mục người dùng",
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
"free": "trống",
"usageTitle": "Mức sử dụng theo người dùng",
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"who": "Người dùng",
"processes": "Tiến trình",
"home": "Thư mục home",
"sampling": "Đang lấy mẫu…",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"disks": "Ổ đĩa",
"mount": "Điểm gắn",
"device": "Thiết bị",
"used": "Đã dùng",
"homesBadge": "home",
"gpus": "GPU"
}
}
@@ -29,8 +29,10 @@
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"open": "Mở",
"copyLink": "Sao chép link",
"preview": "Xem",
"download": "Tải về",
"noPreview": "Không xem trước được loại file này — hãy tải về để mở.",
"install": "Cài",
"installs": "{{count}} lượt cài",
"installed": "Đã cài vào {{path}}",
+13 -2
View File
@@ -577,7 +577,7 @@
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
"fx.pasted": "Đã dán.",
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
"fx.conflictTitle": "Đã có sẵn",
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
@@ -591,6 +591,17 @@
"fx.purge": "Xoá vĩnh viễn",
"fx.restored": "Đã khôi phục.",
"fx.trashEmpty": "Thùng rác trống.",
"fx.trashOf": "Thùng rác của {{name}}",
"fx.myTrash": "Thùng rác của tôi",
"fx.trashAt": "Thùng rác ({{name}})",
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
"fx.trashFolderEmpty": "Trống",
"fx.openTrash": "Mở Thùng rác",
"fx.purged": "Đã xoá vĩnh viễn.",
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
"fx.purgeTitle": "Xoá vĩnh viễn?",
"fx.purgeBody": "“{{name}}” trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
"changes.openInExplorer": "Mở trong Quản lý file",
"fx.places": "Workspace",
@@ -620,7 +631,7 @@
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
"fx.myHome": "Thư mục home",
"fx.userHomes": "Home của các user",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
+1
View File
@@ -87,5 +87,6 @@
"sidebar": "Thanh bên",
"now": "bây giờ",
"users": "Người dùng",
"admin": "Quản trị",
"files": "Quản lý file"
}
+39 -6
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
"panel": {
"label": "Trợ lý {{name}}",
"live": "Trực tuyến",
"offline": "Ngoại tuyến",
"close": "Đóng {{name}}",
"statLive": "đang chạy",
"statWaiting": "đang chờ",
"statErrored": "lỗi",
"mute": "Tắt tiếng",
"unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo",
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
"ask": "Hỏi {{name}}",
"send": "Gửi",
"rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Trò chuyện với {{name}}",
"back": "Quay lại",
"close": "Đóng {{name}}",
"new": "Cuộc trò chuyện mới",
"status": "Trạng thái — bấm để xem các phiên cần bạn",
"endTitle": "Kết thúc chat này?",
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
"endConfirm": "Dừng và bắt đầu mới",
"openWorkspace": "Mở trong workspace",
"typing": "đang trả lời…",
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
@@ -937,7 +936,24 @@
"otherHost": "Host",
"otherUser": "Tên user",
"otherSaved": "Đã lưu SSH user cho {{host}}",
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server."
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
"anyKey": "Mọi key",
"defaultEd": "ED25519 (mặc định)",
"defaultRsa": "RSA (mặc định)",
"loginKey": "Key",
"alias": "Tên tài khoản",
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"moreKeysTitle": "Thêm SSH key",
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
"newKey": "Key mới",
"keyName": "Tên key",
"keyNamePlaceholder": "vd aws-prod",
"keyType": "Loại key",
"tabGit": "GitHub · GitLab",
"tabMore": "Key khác",
"tabOther": "Host khác"
},
"gitCreds": {
"title": "Git credentials",
@@ -1200,6 +1216,23 @@
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
},
"retention": {
"title": "Tự động dọn dẹp",
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
"days": "Xóa phiên cũ hơn",
"off": "Tự động dọn dẹp đang tắt.",
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
"saved": "Đã lưu tự động dọn dẹp",
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
"compact": "Nén ngay",
"compacting": "Đang nén…",
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
"compactFailed": "Nén thất bại: {{error}}"
},
"backup": {
"title": "Backup hằng ngày",
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
+35
View File
@@ -204,5 +204,40 @@
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
"confirm": "Xoá vĩnh viễn",
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
},
"tmux": {
"title": "Tmux của member",
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
"refresh": "Làm mới",
"notMatched": "Chạy `agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
"empty": "Không member nào đang chạy tmux server.",
"user": "Người dùng",
"version": "tmux",
"sessions": "Session",
"agents": "Agent",
"stuck": "không trả lời",
"outdated": "cũ hơn bản cài",
"since": "từ {{at}}",
"working_one": "{{count}} đang chạy",
"working_other": "{{count}} đang chạy",
"idle": "rảnh",
"restart": "Khởi động lại tmux của {{name}}",
"restartIdle_one": "Khởi động lại {{count}} member đang rảnh",
"restartIdle_other": "Khởi động lại {{count}} member đang rảnh",
"restartIdleTitle": "Khởi động lại tmux của member đang rảnh",
"restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.",
"restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.",
"restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.",
"restartButton": "Khởi động lại",
"done": "Đã khởi động lại tmux của {{name}}",
"doneIdle": "Đã khởi động lại: {{names}}",
"errors": {
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
}
},
"rowMenu": {
"openHome": "Mở home trong Files",
"copyUsername": "Sao chép username",
"copyEmail": "Sao chép email"
}
}
+53 -1
View File
@@ -207,6 +207,35 @@ describe("panePaste", () => {
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
vi.useFakeTimers();
const m = parsePaneMenu(TRUST)!;
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
expect(m.options.every((o) => o.n === null)).toBe(true);
expect(m.cursor).toBe(0);
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
expect(m.question).toContain("Security guide");
const keys: string[] = [];
const off = registerPane(
"agenthub-lane-12",
() => {},
() => true,
(k) => keys.push(k)
);
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\r"]);
off();
vi.useRealTimers();
});
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
expect(parsePaneMenu(CLAUDE)).toBeNull();
expect(
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
).toBeNull();
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
" enteresc",
];
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
const TRUST = [
RULE,
" Accessing workspace:",
"",
" /home/me",
"",
" Quick safety check: Is this a project you created or one you trust?",
"",
" Security guide",
"",
" ❯ No, exit",
" Yes, I trust this folder",
"",
"",
];
describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
for (const menu of [
ASK,
MULTI,
PERMISSION,
CODEX_APPROVAL,
[...TRUST, " Enter to confirm · Esc to cancel"],
]) {
expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true);
}
@@ -0,0 +1,34 @@
/**
* @file sessionChange.test.ts
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
* and on a change of the keyed columns, never on a re-broadcast that moved none.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { sessionChangeMatcher } from "../sessionChange";
import type { WSMessage } from "../types";
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
describe("sessionChangeMatcher", () => {
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
expect(
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
true
);
});
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
expect(m(msg("session_updated", null))).toBe(true);
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
});
});
+184 -15
View File
@@ -544,6 +544,71 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
username: string;
running: boolean;
/** Did not answer (hung, or backed off). */
stuck?: boolean;
version?: string | null;
pid?: number | null;
startedAt?: number | null;
sessions?: number | null;
/** Workspaces of theirs with an agent working now. */
busy: number;
/** Runs an older tmux than the installed one. */
outdated: boolean;
}
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
export interface MemberTmux {
available: boolean;
installed?: string | null;
/** Calls on an older server already use its own binary (tmux-member installed). */
matched?: boolean;
users: MemberTmuxRow[];
}
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
export interface HostUsageGroup {
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
key: string;
kind: "user" | "host" | "system";
userId?: number;
username?: string;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
memory: number;
memoryPercent: number;
processes: number;
/** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[];
}
/** `GET /api/host` — hardware, load, disks and per-user usage. */
export interface HostStats {
at: string;
hostname: string;
os: { platform: string; release: string; distro: string | null; arch: string };
uptime: number;
cpu: { model: string | null; cores: number; load: number[] };
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: {
device: string;
mount: string;
type: string;
kind: "HDD" | "SSD" | null;
total: number;
used: number;
free: number;
}[];
/** The mount holding the users' homes, or null. */
homesMount: string | null;
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
@@ -817,6 +882,11 @@ export interface FsTrashItem {
path: string;
isDir: boolean;
deletedAt: string;
/** A home's recycle bin only: which of the user's trash folders holds it. */
store?: "home" | "host";
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
home?: string;
owner?: string;
}
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
@@ -888,6 +958,10 @@ export const api = {
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
host: {
stats: () => request<HostStats>("/host"),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
@@ -915,6 +989,24 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
tmux: () => request<MemberTmux>("/users/tmux"),
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
tmuxRestart: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
tmuxRestartIdle: (currentPassword: string) =>
request<{
restarted: string[];
skipped: string[];
failed: { username: string; message: string }[];
}>("/users/tmux-restart-idle", {
method: "POST",
body: JSON.stringify({ currentPassword }),
}),
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
get: (id: number) => request<UserDetail>(`/users/${id}`),
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
@@ -1557,6 +1649,23 @@ export const api = {
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
retention: {
/** GET /api/settings/retention */
get: () => request<RetentionState>("/settings/retention"),
/** PUT /api/settings/retention — `days` 0 turns it off */
save: (days: number) =>
request<RetentionState & { ok: boolean }>("/settings/retention", {
method: "PUT",
body: JSON.stringify({ days }),
}),
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
compact: () =>
request<RetentionState & { ok: boolean }>("/settings/compact", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -2595,15 +2704,22 @@ export const api = {
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
restore: (t: FsPlace, id: string) =>
/** Every Trash the caller may open, merged (an admin: every user's). */
allTrash: () =>
request<{
items: FsTrashItem[];
errors?: { home: string; owner: string; message: string }[];
keepDays?: number;
}>("/homes/trash"),
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify(store ? { id, store } : { id }),
}),
purge: (t: FsPlace, id: string | "all") =>
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify(store ? { id, store } : { id }),
}),
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
zipUrl: (t: FsPlace, paths: string[]) => {
@@ -2665,22 +2781,30 @@ export const api = {
* Make the SSH key; `replace` swaps an existing one for a new pair.
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
*/
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace, type }),
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
}),
/** DELETE /api/me/git/ssh-key — the key of `type`, or every key. */
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
removeKey: (type?: "ed25519" | "rsa") =>
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
removeKeyById: (id: string) =>
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
/** PUT /api/me/git/ssh-user — the SSH login name for a host (CodeCommit: the SSH key ID). */
setSshUser: (body: { host: string; user: string }) =>
/**
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
* the user): for the host itself, or with `alias` one more account on it
* (`ssh://<alias>/…`); `key` = the only key it uses.
*/
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
removeSshUser: (host: string) =>
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(host)}`, { method: "DELETE" }),
/** DELETE a login by its host (the host's own rule) or its alias. */
removeSshUser: (from: string) =>
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) =>
@@ -4211,14 +4335,29 @@ export interface GitIdentity {
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
terminal?: "on" | "off" | "foreign";
} | null;
/** Every key the account holds (ED25519 first); absent from an older server. */
sshKeys?: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string }[];
/**
* Every key the account holds: the default ED25519 and RSA first, then the
* named ones (`name`; `id` = its file name); absent from an older server.
*/
sshKeys?: {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/** SSH login name per host (`url.<…>.insteadOf` in `~/.gitconfig`); absent from an older server. */
sshUsers?: { host: string; user: string }[];
/**
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
* it uses, null = every key. Absent from an older server.
*/
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
/** The suggested email while none is set: `<username>@smartgift.vn`. */
defaultEmail?: string | null;
https: {
protocol: "http" | "https";
host: string;
@@ -5339,6 +5478,36 @@ export interface BackupState {
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
* `last_run`/`last_compact` are this server process's own. */
export interface RetentionState {
days: number;
max_days: number;
env_owned: { days: boolean };
last_run: {
at: string;
retention?: { purged_sessions: number; purged_events: number; error?: string };
reclaim?: { reclaimed_pages: number; error?: string };
} | null;
space: {
auto_vacuum: "none" | "full" | "incremental";
page_size: number;
page_count: number;
freelist_pages: number;
free_bytes: number;
} | null;
db_size: number;
compacting: boolean;
last_compact: {
at: string;
ok: boolean;
size_before?: number;
size_after?: number;
ms?: number;
error?: string;
} | null;
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+47 -8
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -24,7 +27,8 @@
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
/** `files`: image paths pasted after `text`, one paste each. */
paste: (text: string, submit?: boolean, files?: string[]) => void;
visible: () => boolean;
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
@@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean {
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
p.paste(text, false, files);
return true;
}
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
p.paste(text, true, files);
return true;
}
@@ -176,8 +180,9 @@ export type PaneMenu = {
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -224,7 +229,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
return parseBareMenu(r.slice(0, end + 1));
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
@@ -241,6 +247,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
};
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
* cursor row's text column, and nothing under them is a rule — Claude's input
* box draws one above and below its `❯` row, so neither the box nor a prompt
* in the history above it reads as a menu. Picked by arrows and Enter. */
function parseBareMenu(r: string[]): PaneMenu | null {
let at = -1;
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
return null;
const m = BARE_CURSOR.exec(r[at]!)!;
const indent = " ".repeat((m[1] ?? "").length + 2);
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
let top = at;
while (top > 0 && sibling(r[top - 1])) top--;
let last = at;
while (last < r.length - 1 && sibling(r[last + 1])) last++;
if (last === top) return null;
const options = r.slice(top, last + 1).map((l, i) => ({
n: null,
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
hint: "",
checked: null,
}));
// The question: the paragraphs above, up to the dialog's top rule.
const lines: string[] = [];
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
if (r[q]) lines.unshift(r[q]!.trim());
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
+32
View File
@@ -0,0 +1,32 @@
/**
* @file sessionChange.ts
* @description `session_created` / `session_updated` matcher for live-refresh
* callers that care about only a few session columns. A session row is
* re-broadcast on nearly every hook (last activity, name, tokens…), so
* matching the bare message types made every such frame refetch; this one
* fires on a new session, or when the chosen key of a session actually
* changed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Session, WSMessage } from "./types";
/** The columns that move a session between the active / waiting / archived counts. */
export const sessionStateKeyOf = (s: Partial<Session>): string =>
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
export function sessionChangeMatcher(
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
): (msg: WSMessage) => boolean {
const seen = new Map<string, string>();
return (msg) => {
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
const s = msg.data as Partial<Session> | undefined;
// A frame we cannot key is taken as a change: refetching is the safe side.
if (!s || typeof s.id !== "string") return true;
const key = keyOf(s);
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
seen.set(s.id, key);
return true;
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst: a tab
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
* (the machine's hardware and each user's share of it, `ServerPanel`).
* Local mode and a developer are sent home, like the Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { useAuth } from "../components/AuthGate";
import { ServerPanel } from "../components/admin/ServerPanel";
import { Users } from "./Users";
interface AdminTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
}
export function Administration() {
const { t } = useTranslation("admin");
const auth = useAuth();
const [params, setParams] = useSearchParams();
const me = auth.state.mode === "server" ? auth.state.user : null;
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const tabs: AdminTab[] = [
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: Server },
];
const first = tabs[0]!;
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<PageTabs
ariaLabel={t("title")}
activeId={active.id}
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
tabs={tabs}
>
{active.id === "server" ? (
<ServerPanel isAdmin={me.role === "admin"} />
) : (
<Users embedded />
)}
</PageTabs>
</div>
);
}
+97 -9
View File
@@ -11,7 +11,11 @@
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
* card offers the same actions as its buttons, plus copying a page's link.
* Preview is offered only for what a browser shows inside that sandbox (text,
* images, HTML, audio/video); an Office file, a zip or a PDF is downloaded —
* the sandbox blocks both a download and the PDF viewer, so it would be blank.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +47,8 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import {
FileUploadPanel,
PluginAddPanel,
@@ -60,6 +66,9 @@ function fmtBytes(n: number | null): string {
}
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
// What the sandboxed preview can show: the server's typed list minus PDF.
const PREVIEWABLE =
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
export function Artifacts() {
const { t } = useTranslation("artifacts");
@@ -70,6 +79,7 @@ export function Artifacts() {
const [items, setItems] = useState<Artifact[] | null>(null);
const [adding, setAdding] = useState<ArtifactKind | null>(null);
const [addMenu, setAddMenu] = useState(false);
const addMenuRef = useRef<HTMLDivElement>(null);
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
useEffect(() => {
@@ -102,6 +112,23 @@ export function Artifacts() {
[load]
);
// The Add menu closes on Escape or a click anywhere outside it.
useEffect(() => {
if (!addMenu) return;
const onDown = (e: MouseEvent) => {
if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setAddMenu(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [addMenu]);
const server = !!settings?.serverMode;
const share = async (a: Artifact) => {
@@ -183,11 +210,13 @@ export function Artifacts() {
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<div className="relative" ref={addMenuRef}>
<button
type="button"
className="btn-primary flex items-center gap-1.5 text-sm"
data-testid="artifacts-add"
aria-haspopup="menu"
aria-expanded={addMenu}
onClick={() => setAddMenu((v) => !v)}
>
<Plus className="h-4 w-4" />
@@ -325,12 +354,22 @@ function ArtifactCard({
}) {
const { t } = useTranslation("artifacts");
const Icon = KIND_ICON[a.kind];
const menu = useContextMenu<null>();
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
const canPreview =
a.files.length > 0 &&
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath)));
const btn =
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
return (
<li
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
@@ -363,12 +402,8 @@ function ArtifactCard({
{t("open")}
</a>
)}
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
<button
type="button"
className={btn}
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
>
{canPreview && (
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
<Eye className="h-3 w-3" />
{t("preview")}
</button>
@@ -410,6 +445,47 @@ function ArtifactCard({
)}
</span>
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
{a.kind === "page" && a.url && (
<>
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
{t("open")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
</>
)}
{canPreview && (
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
)}
{a.kind === "file" && a.files.length > 0 && (
<MenuItem
onSelect={pick(() => {
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
})}
>
{t("download")}
</MenuItem>
)}
{(a.kind === "skill" || a.kind === "plugin") && (
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
{t("install")}
</MenuItem>
)}
{a.canChange && (
<>
<MenuSeparator />
{server && (
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
<MenuItem danger onSelect={pick(onDelete)}>
{t("delete")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</li>
);
}
@@ -550,6 +626,7 @@ function PreviewDialog({
path: string;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [current, setCurrent] = useState(path);
const url = api.artifacts.rawUrl(item.id, current);
return (
@@ -576,7 +653,18 @@ function PreviewDialog({
</ul>
)}
<div className="min-w-0 flex-1 rounded border border-border bg-white">
{IMAGE.test(current) && !/\.svg$/i.test(current) ? (
{!PREVIEWABLE.test(current) ? (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center">
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
<a
href={api.artifacts.rawUrl(item.id, current, true)}
className="btn-primary flex items-center gap-1.5 text-sm"
>
<Download className="h-4 w-4" />
{t("download")}
</a>
</div>
) : IMAGE.test(current) && !/\.svg$/i.test(current) ? (
<img src={url} alt={current} className="h-full w-full object-contain" />
) : (
// No allow-* tokens: a shared page renders, nothing in it runs.
+5 -2
View File
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, { intervalMs: 10000 });
// New events arrive over the WebSocket (prepended below); a reload is only
// for what the socket cannot carry: a remote source's import, a gap while it
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
// A new event is prepended at once (no reload); the poll reconciles.
// A new event is prepended at once (no reload); the reloads reconcile.
useEffect(
() =>
eventBus.subscribe((msg: WSMessage) => {
+5 -1
View File
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
{visitedTabs.has("conversation") && (
<div hidden={activeTab !== "conversation"}>
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
<ConversationView
sessionId={session.id}
initialTranscriptId={pendingTranscriptId}
ended={session.status !== "active"}
/>
</div>
)}
+4 -1
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { RetentionSettings } from "../components/settings/RetentionSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
@@ -1054,6 +1055,8 @@ export function Settings({
</button>
{actionBanner(["cleanup"])}
<RetentionSettings />
</section>
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
+64 -9
View File
@@ -1,6 +1,7 @@
/**
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* @description The Users tab of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`) and how much disk its home folder takes
@@ -9,16 +10,19 @@
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
* Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set.
* name and email under the username when set; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
@@ -49,11 +53,13 @@ import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users() {
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
@@ -64,6 +70,8 @@ export function Users() {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const rowMenu = useContextMenu<ServerUserUsage>();
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
@@ -124,11 +132,16 @@ export function Users() {
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
{!embedded && (
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
)}
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{/* Under Administration's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
@@ -228,6 +241,7 @@ export function Users() {
return (
<tr
key={u.id}
onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
@@ -295,6 +309,47 @@ export function Users() {
</table>
</div>
{rowMenu.menu &&
(() => {
const u = rowMenu.menu.item;
const pick = (fn: () => void) => () => {
rowMenu.close();
fn();
};
return (
<ContextMenu
x={rowMenu.menu.x}
y={rowMenu.menu.y}
onClose={rowMenu.close}
testId="users-row-menu"
>
{isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)}
{isAdmin && !u.disabled && (
<MenuItem
onSelect={pick(() =>
navigate(
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
)
)}
>
{t("rowMenu.openHome")}
</MenuItem>
)}
{isAdmin && <MenuSeparator />}
<MenuItem onSelect={pick(() => void copyText(u.username))}>
{t("rowMenu.copyUsername")}
</MenuItem>
{u.email && (
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
{t("rowMenu.copyEmail")}
</MenuItem>
)}
</ContextMenu>
);
})()}
<AccountPoolCard isAdmin={isAdmin} />
<section className="card">
+25 -24
View File
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
@@ -1044,9 +1047,10 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
// fallback poll below, and a fresh-but-identical payload would otherwise
// re-render the lane strip, the lane card and the whole console area for
// nothing.
const refreshList = useCallback(() => {
api.run
.list()
@@ -1061,33 +1065,30 @@ export function Workspace() {
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
}, [activeRuns]);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
// status flips appear within seconds without needing a manual refresh.
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
// starting, ending or learning its session in slot 0 moves the lane's run
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
// shows as a new session, one ending as a session status change, and a pane
// dying under its agent as `runs_interrupted`. This page's own starts, stops
// and a shown terminal's exit call `refreshList` directly
// (`onRunsChanged`). What none of those cover — a shell tab opened or closed
// from another browser, server-boot reconciliation — is caught by the
// 30 s fallback poll, on reconnect, and when the tab is shown or focused.
const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf));
const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`));
useEffect(() => {
refreshList();
const tick = setInterval(() => {
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useLiveRefresh(refreshList, {
intervalMs: 30_000,
throttleMs: 1500,
match: (msg) =>
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
});
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
return () => window.removeEventListener("focus", onFocus);
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
@@ -0,0 +1,171 @@
/**
* @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff land on the Users tab; the Server tab shows the
* machine's hardware, every disk, and each user's CPU/memory/home share from
* `GET /api/host`, with a user's busiest processes on click.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, HostStats } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
}));
const GB = 1024 ** 3;
const stats: HostStats = {
at: "2026-10-15T00:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
uptime: 90000,
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
disks: [
{
device: "/dev/sdb2",
mount: "/",
type: "ext4",
kind: "SSD",
total: 200 * GB,
used: 50 * GB,
free: 150 * GB,
},
{
device: "/dev/sda1",
mount: "/data",
type: "ext4",
kind: "HDD",
total: 500 * GB,
used: 0,
free: 500 * GB,
},
],
homesMount: "/",
gpus: [],
usage: {
cpu: 40,
sampleMs: 1000,
groups: [
{
key: "user:2",
kind: "user",
userId: 2,
username: "mia",
cpu: 25,
memory: 2 * GB,
memoryPercent: 12.5,
processes: 3,
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
},
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
],
},
};
const api = vi.hoisted(() => ({
host: { stats: vi.fn() },
users: {
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
tmux: vi.fn(async () => ({ available: false, users: [] })),
avatarUrl: () => null,
},
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
...api,
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
},
};
});
import { Administration } from "../Administration";
const renderAt = (url: string) =>
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/admin" element={<Administration />} />
<Route path="/" element={<div data-testid="home" />} />
</Routes>
</MemoryRouter>
);
beforeEach(() => {
api.host.stats.mockResolvedValue(stats);
api.users.list.mockResolvedValue({
users: [
{
id: 1,
username: "boss",
role: "admin",
disabled: false,
workspaces: 0,
sessions: 0,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
},
{
id: 2,
username: "mia",
role: "developer",
displayName: "Mia",
disabled: false,
workspaces: 1,
sessions: 2,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
},
],
});
api.users.audit.mockResolvedValue({ entries: [] });
api.users.storage.mockResolvedValue({
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
measuring: false,
});
});
describe("Administration", () => {
it("sends local mode and a developer home", async () => {
authState = { mode: "local" };
renderAt("/admin");
expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
renderAt("/admin");
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
});
it("opens on the Users tab", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
});
it("shows hardware and each user's share on the Server tab", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server");
const table = await screen.findByTestId("server-usage-table");
expect(await screen.findByText("box")).toBeTruthy();
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
const mia = await within(table).findByText("Mia");
const row = mia.closest("tr")!;
expect(row.textContent).toContain("25%");
expect(row.textContent).toContain("(13%)");
// 20 GB home on the 200 GB homes disk.
expect(row.textContent).toContain("10%");
expect(within(table).queryByText("claude")).toBeNull();
fireEvent.click(mia.closest("button")!);
expect(within(table).getByText("claude")).toBeTruthy();
});
});
+52 -3
View File
@@ -6,10 +6,13 @@
* a plugin is picked from the list its marketplace link answers (a link the
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* A card's right-click menu runs the same actions (Preview, Delete). A file
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
* Add menu closes on Escape or a click outside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts";
@@ -81,7 +84,13 @@ beforeEach(() => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({}),
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
files: [{ path: "SKILL.md", size: 3 }],
}),
],
});
vi.mocked(confirmDialog).mockReset();
@@ -145,7 +154,9 @@ describe("Adding", () => {
await addMenu(user, "Skill");
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("skill-files"), md);
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
expect(await screen.findByTestId("skill-shape")).toHaveTextContent(
"becomes the skill's SKILL.md"
);
const name = screen.getByLabelText(/Skill name/);
await waitFor(() => expect(name).toHaveValue("notes"));
await user.clear(name);
@@ -229,4 +240,42 @@ describe("Artifacts", () => {
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
});
it("opens a card's actions on right-click", async () => {
const user = userEvent.setup();
open();
await screen.findByText("report.html");
fireEvent.contextMenu(screen.getByTestId("artifact-1"));
const menu = screen.getByTestId("artifact-menu");
expect(within(menu).getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Install" })).toBeNull();
await user.click(within(menu).getByRole("menuitem", { name: "Preview" }));
expect(screen.queryByTestId("artifact-menu")).toBeNull();
expect((await screen.findByTestId("artifact-preview")).getAttribute("src")).toBe(
"/api/artifacts/1/raw?path=report.html"
);
});
it("offers no preview for a file the sandbox cannot show, only its download", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ title: "plan.docx", files: [{ path: "plan.docx", size: 10 }] })],
});
open();
const card = await screen.findByTestId("artifact-1");
expect(within(card).queryByRole("button", { name: "Preview" })).toBeNull();
expect(within(card).getByRole("link", { name: "Download" })).toBeInTheDocument();
});
it("closes the Add menu on Escape and on a click outside it", async () => {
const user = userEvent.setup();
open();
await screen.findByText("deploy");
await user.click(screen.getByTestId("artifacts-add"));
expect(screen.getByRole("button", { name: "Skill" })).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
await user.click(screen.getByTestId("artifacts-add"));
await user.click(screen.getByText("report.html"));
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
});
});
@@ -77,6 +77,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
})),
},
retention: {
get: vi.fn(async () => ({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
})),
},
},
},
};
+30 -3
View File
@@ -5,14 +5,15 @@
* with their own password typed (the confirm stays disabled until then), sees
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username.
* display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" };
@@ -40,6 +41,8 @@ const users = vi.hoisted(() => ({
setPassword: vi.fn(),
assignWorkspace: vi.fn(),
get: vi.fn(),
// No S3 here: the members' tmux card stays hidden.
tmux: vi.fn(async () => ({ available: false, users: [] })),
}));
vi.mock("../../lib/api", async (importOriginal) => {
@@ -58,12 +61,17 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { Users } from "../Users";
function FilesProbe() {
return <div data-testid="files">{useLocation().search}</div>;
}
function renderPage() {
return render(
<MemoryRouter initialEntries={["/users"]}>
<Routes>
<Route path="/users" element={<Users />} />
<Route path="/" element={<div data-testid="home" />} />
<Route path="/files" element={<FilesProbe />} />
</Routes>
</MemoryRouter>
);
@@ -190,4 +198,23 @@ describe("Users page", () => {
expect(screen.getByText("@mia")).toBeTruthy();
expect(screen.getByText("mia@x.vn")).toBeTruthy();
});
it("opens a row's menu on right-click: Open home for an admin, copy only for an analyst", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderPage();
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
const menu = screen.getByTestId("users-row-menu");
expect(within(menu).getByRole("menuitem", { name: "Copy username" })).toBeTruthy();
fireEvent.click(within(menu).getByRole("menuitem", { name: "Open home in Files" }));
expect((await screen.findByTestId("files")).textContent).toBe("?home=mia");
});
it("gives an analyst's row menu no admin actions", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
fireEvent.contextMenu((await screen.findByText("mia")).closest("tr")!);
const menu = screen.getByTestId("users-row-menu");
expect(within(menu).queryByRole("menuitem", { name: "Open home in Files" })).toBeNull();
expect(within(menu).queryByRole("menuitem", { name: "Edit" })).toBeNull();
});
});
@@ -7022,7 +7022,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7033,7 +7033,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
@@ -7044,19 +7044,19 @@ exports[`screen snapshots > Settings — about 1`] = `
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/>
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7067,7 +7067,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Show AgentHub icon in the status area
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
</p>
@@ -7078,19 +7078,19 @@ exports[`screen snapshots > Settings — about 1`] = `
<button
aria-checked="true"
aria-label="Show AgentHub icon in the status area"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-accent-solid enabled:hover:bg-accent-solid-hover"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-[18px]"
/>
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7101,25 +7101,51 @@ exports[`screen snapshots > Settings — about 1`] = `
Quit AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run
<code
class="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
agenthub start
</code>
to bring it back.
</p>
</div>
<div
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
aria-busy="false"
class="btn-secondary min-w-[6.5rem] justify-center text-xs"
data-testid="quit-agenthub"
type="button"
>
<svg
class="lucide lucide-power h-3.5 w-3.5 shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2v10"
/>
<path
d="M18.4 6.6a9 9 0 1 1-12.77.04"
/>
</svg>
Quit
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7130,7 +7156,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Uninstall AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
</p>
@@ -7139,10 +7165,45 @@ exports[`screen snapshots > Settings — about 1`] = `
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
aria-busy="false"
class="btn-danger min-w-[6.5rem] justify-center text-xs"
data-testid="uninstall-agenthub"
type="button"
>
<svg
class="lucide lucide-trash2 h-3.5 w-3.5 shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6h18"
/>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
/>
<line
x1="10"
x2="10"
y1="11"
y2="17"
/>
<line
x1="14"
x2="14"
y1="11"
y2="17"
/>
</svg>
Uninstall
</button>
</div>
@@ -7162,7 +7223,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7173,12 +7234,12 @@ exports[`screen snapshots > Settings — about 1`] = `
Release
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
<span
class="text-status-warning/90"
>
UI build v2.8.2 (rebuild client to match)
UI build v2.8.9 (rebuild client to match)
</span>
</p>
</div>
@@ -7193,7 +7254,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7215,7 +7276,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7240,7 +7301,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7262,7 +7323,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7284,7 +7345,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7295,7 +7356,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Live connections
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
Browser tabs and tools receiving live updates
</p>
@@ -7325,7 +7386,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7355,7 +7416,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -7366,7 +7427,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Database file
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
<span
class="font-mono break-all"
@@ -7386,7 +7447,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -7408,7 +7469,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -8960,7 +9021,7 @@ exit 0
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
@@ -8971,7 +9032,7 @@ exit 0
Redact secrets before storing
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
>
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
</p>
@@ -8982,13 +9043,13 @@ exit 0
<button
aria-checked="false"
aria-label="Redact secrets before storing"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/>
</button>
</div>
@@ -9357,6 +9418,108 @@ exit 0
</svg>
Run Cleanup
</button>
<div
class="border-t border-border pt-4 space-y-3"
>
<div>
<h4
class="text-xs font-medium text-fg-secondary"
>
Automatic cleanup
</h4>
<p
class="text-xs text-fg-muted"
>
Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.
</p>
</div>
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
>
<label
class="text-xs text-fg-secondary block mb-2"
for="retention-days"
>
Delete sessions older than
</label>
<div
class="flex items-center gap-2"
>
<input
class="input w-20 text-sm text-right font-mono"
id="retention-days"
inputmode="numeric"
max="3650"
min="0"
step="1"
type="number"
value="0"
/>
<span
class="text-xs text-fg-muted"
>
days
</span>
</div>
</div>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
</div>
<p
class="text-xs text-fg-secondary"
>
Automatic cleanup is off.
</p>
<div
class="flex flex-wrap items-center gap-3"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
type="button"
>
<svg
class="lucide lucide-minimize2 w-3 h-3 inline mr-1"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="4 14 10 14 10 20"
/>
<polyline
points="20 10 14 10 14 4"
/>
<line
x1="14"
x2="21"
y1="10"
y2="3"
/>
<line
x1="3"
x2="10"
y1="21"
y2="14"
/>
</svg>
Compact now
</button>
</div>
</div>
</section>
<section
class="scroll-mt-24"
@@ -9637,7 +9800,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
class="min-w-0 flex-1"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
@@ -9654,13 +9817,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
<button
aria-checked="false"
aria-label="Enable Browser Notifications"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/>
</button>
</div>
@@ -362,6 +362,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
}),
},
retention: {
get: r({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
}),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
+50 -12
View File
@@ -85,7 +85,7 @@ 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 **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). 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`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
@@ -292,7 +292,7 @@ come `fromLane` another workspace the caller can see, of the same account
(`403 EFOREIGN` otherwise). An upload is at most 500 MB (`413 ETOOBIG`, from
`Content-Length` before a byte is stored), a zip at most 2 GB / 50,000 files.
Under S3 a move/copy/delete needs the installed `write-as` to know
`move`/`copy`: `409 EUPGRADE` until `sudo agenthub server install-system` is
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
re-run.
#### File Explorer on a home
@@ -311,7 +311,32 @@ the host account's home. Every dot entry at the TOP of a home (`.ssh`,
refused for every read and write — also through a symlink, a download or a
zip (`403 ESECRET`); dot files deeper down are ordinary. `:who` is `me`; an
admin may also name another user — read-only (`403 EREADONLY` on every
write), each read written to the audit log. A copy goes home ↔ workspace
write), each read written to the audit log. The one exception is that user's
**Trash**.
A home's Trash is its USER's one recycle bin, whatever place they deleted in:
`GET …/trash` merges their home account's trash (a member's
`~/.local/share/agenthub/trash/`) with their host trash
(`<data dir>/trash/<user id>/`, what they deleted in a host-folder workspace)
and tags each item `store: "home" | "host"`; `POST …/trash/restore` and
`POST …/trash/purge` take `{id, store}` (`store` defaults to `home`;
`{id: "all"}` empties every store). A host item whose workspace is gone
answers `404 ENOFILE` (nowhere of that account to put it back). An admin may
restore and purge in ANOTHER user's Trash too, each written to the audit log
with the item id — so a member's deleted files can be restored or freed
without waiting the 7 days. `GET /api/homes/trash` merges every Trash the
caller may open into one list (`{items, errors, keepDays}`; an admin's is
every user's, each other user's read audited): each item carries `home` (the
`:who` its restore/purge goes to) and `owner`, and a user whose trash cannot
be read is listed in `errors` rather than left out. The Files sidebar has ONE
**Trash** entry: a user's opens their own Trash; an admin's opens on one
folder per user (**My Trash** first, each counted from that merged list, an
unreadable one showing its error), and opening a folder shows that user's
Trash (path bar **Trash › user**, Up goes back to the folders). A trash that
could not be read shows as an error rather than as empty, and deleting
forever asks first.
A copy goes home ↔ workspace
(`fromHome` on the workspace route, `fromLane` on the home one) when both are
one Linux account; each side is checked under its own rule. A home is never a
workspace. The **Files** page opens on `?home=me` by default.
@@ -981,6 +1006,9 @@ cannot collide on `index.lock`.
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
- Any action on a host with no `git` binary (or none on the dashboard's PATH)
is `503 EGITMISSING`: a missing dependency, not a bad request; the message
carries the install command (like `ETMUXMISSING` for tmux).
- `push` pushes `HEAD`, never a branch name from the request, so a workspace can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
@@ -1042,15 +1070,15 @@ cannot collide on `index.lock`.
#### Your git identity
```
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ type, publicKey, fingerprint }] (ed25519 first), sshTerminal, https: [{ protocol, host, username, hasToken }] }
POST /api/me/git/ssh-key Make the SSH key of `type` — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS when that TYPE exists, without replace; the other type is kept — an account may hold one of each, and every ssh git offers both) → 201, same shape
DELETE /api/me/git/ssh-key Delete the key of `?type=ed25519|rsa`, or every key without it (404 ENOTFOUND, 400 EBADKEYTYPE) — also removes our terminal `core.sshCommand`
PUT /api/me/git/ssh-terminal Use the key for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (ours first, then `~/.ssh/id_*`); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user The SSH login name for one host — Body: { host, user } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` to the account's `~/.gitconfig` (replacing ours for that host), so a plain `ssh://<host>/…` URL logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`); 400 EBADHOST / EBADSSHUSER. Listed as `sshUsers: [{host, user}]` in GET /api/me/git
DELETE /api/me/git/ssh-user/:host Drop that rule (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account
PUT /api/me/git/author Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account. A new SSH key is labelled with that email, else `defaultEmail` (`<username>@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion
```
`GET` also answers `author: { name, email }` (null when unset). A member's
@@ -1570,7 +1598,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then that workspace's instructions folder `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository)
@@ -2929,7 +2957,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
```
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 (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's panel is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
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 (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); 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 — 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), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); 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.
@@ -3032,6 +3060,16 @@ POST /api/settings/backup/run 202 — started (or already running); poll GE
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
### Retention and Compact
```
GET /api/settings/retention { days, max_days, env_owned: { days }, last_run, space: { auto_vacuum, page_size, page_count, freelist_pages, free_bytes }, db_size, compacting, last_compact }
PUT /api/settings/retention { days } → the same state; 0 turns it off (the default)
POST /api/settings/compact { confirm: true } → 202 — started (or already running); poll GET while `compacting`
```
Settings → Data → **Session cleanup** (`server/lib/db-retention.js`). Automatic retention is OFF unless `days` (1–3650, `AGENTHUB_RETENTION_DAYS` in the settings file) is set; nothing is deleted by default. When set, the hourly maintenance (`DASHBOARD_DB_MAINTAIN_MS`) purges finished (completed/error/abandoned) sessions that started more than `days` ago — the same rule and the same deletion (`purgeSessionIds`: events, agents, token usage, the session) as `POST /api/settings/cleanup` with `purge_days` — 25 sessions per transaction with a yield between, at most 5000 per run. A new database is created with `auto_vacuum = INCREMENTAL`, an existing one up to 32 MB is converted by a VACUUM at startup; the same hourly run then returns free pages to the OS (`incremental_vacuum`, in 1024-page slices, at most 65536 pages per run) once more than 1024 are free. A larger, older database is converted only by **Compact**: a full VACUUM in a worker thread followed by a TRUNCATE checkpoint — it holds the write lock while it runs (tens of seconds on a GB-size file), so hooks wait meanwhile; it runs only on a click. In server mode both writes are an admin's (`/settings` is in `ADMIN_WRITE_PREFIXES`).
---
## WebSocket API
+1 -1
View File
@@ -104,7 +104,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server setup [--hostname name] [--admin name] [--no-system]` | 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. On Linux it then installs **one Linux user per account** by default — runs `install-system` (one sudo; skipped when `/etc/agenthub/server.conf` already exists) and `users provision --all`; if that fails, server mode stays on and the error says how to finish. `--no-system` (and any non-Linux machine) keeps every pane on the one host account, as before; then restart the dashboard yourself. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
+1 -1
View File
@@ -915,7 +915,7 @@ worker's own writable connection. SQLite's automatic checkpoint runs inside whic
per commit under load. A failed checkpoint switches the automatic one back on. A second
process on the same file (another dashboard, the CLI) keeps its own setting.
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Nothing runs `VACUUM`: it rewrites the whole file under an exclusive lock, so space freed by a cleanup is reused for new rows instead of returned to the disk.
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Then `runScheduledMaintenance` (`server/lib/db-retention.js`) applies the OPT-IN retention (`AGENTHUB_RETENTION_DAYS`, off by default: finished sessions older than N days, purged 25 per transaction like the manual cleanup) and, when the database is in `auto_vacuum = INCREMENTAL` and more than 1024 pages are free, a bounded `incremental_vacuum` (1024-page slices, ≤ 65536 pages per run) that returns them to the disk. A new database is created INCREMENTAL; an existing one up to 32 MB is converted by one `VACUUM` at startup (`convertAutoVacuum`), a larger one only by Settings → Data → **Compact** (`POST /api/settings/compact`: a full `VACUUM` in a worker thread, write lock held while it runs). No automatic path runs a full `VACUUM` on a large file. See docs/API.md § Retention and Compact.
**Growth.** `events.data` is the hook payload TRIMMED by `trim_event_data(?)` (`server/lib/event-payload.js`, a SQL function registered on the connection in `server/db.js`, used by every event `INSERT` and by the duplicate guard): Edit/MultiEdit/NotebookEdit/Write drop `tool_response.originalFile`/`oldString`/`newString` (Write also `content`), Read drops `tool_response.file.content`/`base64`, and any string inside `tool_input`/`tool_response` over 8 KB keeps its first 6 KB and last 2 KB; `structuredPatch` and every field outside those two are kept, and `_trimmed` lists what was dropped. The full text stays in the session transcript and AgentHub's snapshot of it under `<data dir>/transcripts/`, which the Conversation tab reads. Rows stored before this are trimmed once in the background (`sweepEventPayloads`, 200 rows per transaction, `user_version` 4 after marker 3); measured on a 1.2 GB install it took `events.data` from 913 MB to ~400 MB. Freed pages are reused, not returned to the disk (no `VACUUM`). No ROW is pruned automatically. The only deletions are the user's: Settings → clear data / cleanup, a workspace's purge, `scripts/clear-data.js`. Clear data runs in ONE transaction (foreign keys off around it, so workflows are deleted explicitly), cleanup deletes in 500-id chunks inside one transaction. `GET /api/settings/export` streams `events` in keyset pages of 500 instead of loading the table.
+2 -2
View File
@@ -256,9 +256,9 @@ DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle + Codex sessions/tokens
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
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)
+3 -1
View File
@@ -491,7 +491,8 @@ graph TB
> entry whose PID is no longer alive** and POSTs the hook payload to one
> port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s; a server
> alone on its data dir only looks for such a peer every 30 s), so its
> browsers see a workspace's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`.
>
@@ -589,6 +590,7 @@ Claude Code waits only for the handler process: Node start-up, reading stdin, se
- **Fire-and-forget POST** — no wait for the dashboard's reply.
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
- **Only new transcript entries are written** — a per-transcript mark (`transcriptMarks` in `server/routes/hooks.js`, set once the hook's transaction commits) skips the token, compaction, API-error and turn-duration writes when the transcript did not change, and walks only the entries after the last one handled when it grew.
- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -115,7 +115,7 @@ Internal build — all rights reserved.
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 is end-of-life) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
+1 -1
View File
@@ -81,7 +81,7 @@ Internal build — all rights reserved.
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 đã hết hạn hỗ trợ) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
+21 -10
View File
@@ -61,13 +61,13 @@ claude --plugin-dir plugins/agenthub-analytics
## Prerequisites
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- Node **>= 22.5** when installing via the `agenthub` plugin. A plugin install has no
native `better-sqlite3`, so the server stores data through `node:sqlite`, which
landed in 22.5. The bootstrap refuses with one line on anything older instead
of letting the server crash. (A checkout install still works on Node >= 20.)
- Node **>= 22.5**. A plugin install has no native `better-sqlite3`, so the
server stores data through `node:sqlite`, which landed in 22.5. The bootstrap
refuses on anything older instead of letting the server crash, and says so in
the session (below). A checkout needs the same (`engines` in `package.json`).
- The Agent Monitor dashboard reachable at `http://localhost:4820` — the `agenthub`
plugin starts it for you; a checkout starts it with `npm start` (see
[SETUP.md](../SETUP.md))
[INSTALL.md](../INSTALL.md))
Skills and commands are invoked as `/agenthub-<plugin>:<name>`. Agents are dispatched automatically by Claude Code (or named explicitly).
@@ -124,12 +124,12 @@ waits on an install:
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). Also checked by the foreground pass itself (it costs nothing), which then spawns no worker and tells the session at once.
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Install** — only when the dependencies changed (`depsHash` covers what npm installs — the dependency lists and the lockfile's package tree — never the release's own `version`, so a plain version bump reinstalls nothing): `npm install --omit=dev --ignore-scripts` into the runtime dir, output in `runtime/npm-install.log`, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --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.
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 — skipped while `client/package.json` and its lockfile are unchanged, because the installed tree in `runtime/client-src/node_modules` is kept with a `.agenthub-deps` stamp; a failed build drops the stamp so the next one installs from clean — then `vite build` run by the bootstrap's own `node`: the vite half of the client's `build` script without `npm run`. The `tsc -b` half is the release's job, not every user's: it emits nothing the bundle needs and cost ~25 s and ~900 MB per update), 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 (`clientBuild` in `state.json`, recorded only after a build that did not fail, so the next session retries a failed one); 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 → System; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
@@ -149,10 +149,21 @@ waits on an install:
The first run takes a few minutes — most of it is the UI build, which is what
makes client-only routes (`http://localhost:4820/run`, and every other page)
work the moment `claude` starts, with no manual `/agenthub-open` step. Progress
goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own
output goes to `client-build.log` next to it; the server's own output goes to
goes to `~/.claude/agenthub/runtime/bootstrap.log`; npm's own output goes to
`npm-install.log` and the UI build's to `client-build.log` next to it; the server's own output goes to
`server.log`.
**A failure is reported once, in the session.** The worker's output is
discarded, so a Node that is too old, a failed npm install or a failed UI build
is recorded in `runtime/last-failure.json` (kind, message, log file). The NEXT
foreground pass — its stdout is what Claude Code reads — prints it as a
SessionStart `{"systemMessage": "AgentHub: … — details in <log>. Run
/agenthub-doctor …"}` and marks it reported, so the same failure is not repeated
every session; a different failure is reported again, and a bootstrap that
succeeds removes the file. The steady state prints nothing, and the report is a
single small read (plus one write, only when there is something to show) — the
fast path still spawns nothing.
### A retired build never acts
`claude plugin update` unpacks the new version into a **new** cache directory
+6 -7
View File
@@ -9,8 +9,7 @@ Comprehensive documentation for the Agent Dashboard project.
- [Architecture Overview](../ARCHITECTURE.md) - System design and technical reference
- [I18N Architecture](./I18N.md) - Internationalization architecture and usage guide
- [CLI Reference](./CLI.md) - The `agenthub` terminal CLI: every command, discovery, safety model
- [Setup Guide](../SETUP.md) - Installation and configuration
- [Installation](../INSTALL.md) - Detailed installation instructions
- [Installation & setup](../INSTALL.md) - Installation, configuration and troubleshooting
---
@@ -20,7 +19,7 @@ Comprehensive documentation for the Agent Dashboard project.
```mermaid
graph TB
Start[Start Here] --> Setup[SETUP.md<br/>Installation & Config]
Start[Start Here] --> Setup[INSTALL.md<br/>Installation & Config]
Start --> Architecture[ARCHITECTURE.md<br/>System Design]
Setup --> Client[Client README<br/>React UI docs]
@@ -62,7 +61,7 @@ graph TB
```mermaid
graph LR
A[New to Project] --> B[Read SETUP.md]
A[New to Project] --> B[Read INSTALL.md]
B --> C[Install Dependencies]
C --> D[Run npm run dev]
D --> E[Open localhost:5173]
@@ -75,7 +74,7 @@ graph LR
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
2. Read [SETUP.md](../SETUP.md)
2. Read the rest of [INSTALL.md](../INSTALL.md)
3. Run `npm run setup`
4. Run `npm run dev`
5. Open browser to `http://localhost:5173`
@@ -287,7 +286,7 @@ graph LR
**Documentation:**
- [Hook System Guide](./HOOKS.md)
- [Hook Processing](../server/README.md#hook-processing)
- [Installation](../SETUP.md#install-hooks)
- [Installation](../INSTALL.md#hook-auto-installation)
---
@@ -441,4 +440,4 @@ This documentation covers:
- ✅ **Internationalization** - Language resources, switching flow, locale formatting, rollout checklist
- ✅ **Development guides** - Setup, testing, contributing
**Start with:** [SETUP.md](../SETUP.md) for installation, then explore specific areas based on your role.
**Start with:** [INSTALL.md](../INSTALL.md) for installation, then explore specific areas based on your role.
+77 -19
View File
@@ -15,8 +15,9 @@ Local mode does not change at all. The login code stays dormant until
> **What server mode separates, and what it does not.** In the dashboard and
> its API, each developer sees only their own workspaces and the sessions,
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
> Until [one Linux user per account](#one-linux-user-per-account) is installed
> (`agenthub server install-system`), every terminal pane runs as the one Linux
> On Linux, `agenthub server setup` installs [one Linux user per account](#one-linux-user-per-account)
> by default. Without it (`--no-system`, a machine that is not Linux, or an
> install that failed), every terminal pane runs as the one Linux
> account that runs the dashboard. An agent in a pane can then read every
> user's folders, transcripts and the database straight from disk. Without it,
> use server mode only with people you trust.
@@ -27,12 +28,14 @@ On the machine that will host the dashboard, logged in as the account that
runs it:
```bash
# 1. Turn server mode on, allow the public name, and create the first admin
# 1. Turn server mode on, allow the public name, create the first admin and,
# on Linux, install one Linux user per account (one sudo; it moves the
# dashboard to the system unit agenthub.service, which picks the mode up)
agenthub server setup --hostname agenthub.example.com --admin boss
# (asks for the admin's password twice; from a script: pipe it on stdin)
# 2. Restart the dashboard so it picks the mode up
systemctl --user restart agenthub # or: agenthub start
# 2. Log out and back in once, so your shell joins the agenthub groups
# (with --no-system instead: systemctl --user restart agenthub)
# 3. Add the other people
agenthub users add alice # developer
@@ -42,7 +45,14 @@ agenthub users add carol --role analyst # reads everyone, works in their o
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
AgentHub's settings file (`~/.config/agenthub/settings.env`). The real
environment and a checkout's `.env` still take priority over that file. Run
setup again whenever you need to: it creates an admin only when none exists.
setup again whenever you need to: it creates an admin only when none exists,
runs `install-system` only while `/etc/agenthub/server.conf` is missing, and
gives every user their Linux account (`users provision --all`, idempotent).
The machine needs what [What a new machine needs](#what-a-new-machine-needs-before-install-system)
lists first. If the install fails, server mode stays on with every pane on the
host account, and the error says so: fix the cause and run
`agenthub server install-system`, or choose that shared account on purpose
with `agenthub server setup --no-system`.
## Put HTTPS in front of it
@@ -143,7 +153,21 @@ Workspaces made before server mode, or by the host account, have none.
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
Users page in the sidebar shows it, with the audit log under it.
**Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it.
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU, and each user's live
share of it — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size
as a share of the homes disk. A process counts for a user when it runs as
their Linux account (S3) or, for the host account, when it inherited a
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
runs in their workspaces' panes; the rest is **Host account** or
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
per-user part. The page re-reads every 5 s while shown.
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
how much disk each user's home takes (their Linux home under S3, their
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
@@ -544,9 +568,9 @@ The pieces, and what each is for (the install steps are in the
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
@@ -562,7 +586,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one section per kind of host, each with only its own steps: **GitHub · GitLab · Gitea · Bitbucket** (ED25519) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), plus a collapsed **Other SSH hosts (advanced)**. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
@@ -675,6 +699,29 @@ a pane really is. Each case below is refused or bounded, with a test in
click makes on a member's pane (start, type, stop) still waits, up to the
3 s. Every other synchronous call
that runs as a member (git in their folder, `write-as`) stops after 5 s.
- **The host's own tmux server is bounded too.** Every call on it stops
after 5 s, and one that timed out is not asked again for a minute
(`host: tmux server not answering` meanwhile), so a spinning server can no
longer freeze the dashboard (it once did for five minutes). Every call on a
running host server uses that server's own binary (`/proc/<pid>/exe`, found
from its socket in `/proc`), so installing a newer tmux under a running
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
100% CPU.
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
the call with the binary of the member's running server (`/proc/<pid>/exe`,
found from their socket), else with `TMUX_BIN`, so a newer tmux never asks
an older server. Until `agenthub server install-system` has installed
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
version until their last pane closes; the next start runs `TMUX_BIN`.
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
running server with its version beside the installed one (an *older than
installed* badge), its sessions and whether an agent of theirs is working.
**Restart** ends one member's server — every pane of theirs closes, their
Claude sessions resume from the workspace — and **Restart N idle members**
does it for every outdated member with no agent working; both take the
admin's password again and are audited per member. A server that will not
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
- **A member's huge or sparse files.** Transcripts and journals are read line
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
a whole member file is read only up to a cap (1 MiB for a login or a
@@ -736,15 +783,25 @@ itself, once.
### After an update that changes the member helpers
`write-as`, `pane-launch` and the sudoers file are copied to
`write-as`, `pane-launch`, `tmux-member` and the sudoers file are copied to
`/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does
not touch root-owned files. When a release changes them (v2.7.7: `write-as`
learned `move`/`copy` for the File Explorer), re-run it once:
learned `move`/`copy` for the File Explorer; v2.8.4 added `tmux-member`),
re-run it once, as the host account and **without** `sudo` in front (it asks
for the sudo password itself; as root it refuses, and `sudo agenthub …` fails
with `command not found` because sudo's `secure_path` has no `~/.local/bin`):
```bash
sudo agenthub server install-system
agenthub server install-system
```
A member server that a newer tmux already reached before `tmux-member` was
installed can stay wedged: every call on that socket — `tmux-member`'s too —
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
no panes, stop that one server (its pid is the `ah-<name>` process running
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
Until then the File Explorer's move, copy and delete in a member's folder
answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
The same re-run installs `agenthub-provision delete`, behind the Users page's
@@ -779,9 +836,10 @@ again under the same name gets a NEW, empty Linux account and home.
4. AgentHub installed for the host account, as the plugin or a checkout
(`npm run setup && npm run build`), and running once
(`agenthub start`).
5. Server mode on, with the first admin:
`agenthub server setup --hostname agenthub.example.com --admin boss`
(see [Set it up](#set-it-up)).
5. Server mode itself is NOT a step before this: `agenthub server setup`
(see [Set it up](#set-it-up)) turns it on and then runs `install-system`
and `users provision --all` below itself. Run the Install steps by hand only
after a `setup --no-system` or a failed install, or to repeat one.
6. The AI tools members will use, installed for the host account (the AI
Tools page or by hand).
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
@@ -811,7 +869,7 @@ again changes nothing that is already in place):
host account's);
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
4. installs `agenthub-provision`, `write-as` and `pane-launch` from
4. installs `agenthub-provision`, `write-as`, `pane-launch` and `tmux-member` from
`scripts/server/`;
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
@@ -836,8 +894,8 @@ again changes nothing that is already in place):
What a new machine still needs by hand after it:
1. `agenthub users provision --all`: users created before S3 was on (the
admin from `server setup` is one) have no Linux account yet.
1. `agenthub users provision --all`: users created before S3 was on have no
Linux account yet (`server setup` already ran this for you).
2. Log out and back in once, so your own shell joins `agenthub` and
`agenthub-proc` (the unit gets them on its own).
3. `chmod o-rwx ~` for the host account if the installer warned: the database
+1 -1
View File
@@ -19,7 +19,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
## Rules (binding, same spirit as CLAUDE.md)
- **What is shared is a SNAPSHOT, never the owner's live file.** Sharing a `file`/`skill` copies it (size-capped, regular files only, no symlinks followed) into `<data dir>/artifacts/<id>/`, written once like `session_snapshots`. Viewers read only that copy, so no route ever reads another user's home for them. Re-sharing makes a new snapshot.
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a `page` is only a link out, accepted only as `https://claude.ai/…`.
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
+1 -1
View File
@@ -112,7 +112,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
- Verify: an e2e against a fake upstream (header swapped, streaming kept, a dead pane's token refused, another member's profile refused).
### P2. Host setup (sudo; on this machine with the agh-test users)
- [x] `sudo agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
- [x] `agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
- creates groups `agenthub` and `agenthub-members`;
- creates `/srv/agenthub/users` (0751) and `/opt/agenthub/{tools,app,libexec,sbin}`;
- writes the sudoers file and checks it with `visudo -c`;
+495 -4
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.7.7
version: 2.8.9
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -3952,6 +3952,10 @@ components:
enum:
- true
example: true
archived:
type: string
nullable: true
description: 'Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).'
LaneWorktreeCreateRequest:
type: object
required:
@@ -4819,6 +4823,10 @@ components:
email:
type: string
nullable: true
avatarAt:
type: string
nullable: true
description: When the avatar was last set (null = none)
createdAt:
type: string
nullable: true
@@ -11205,6 +11213,14 @@ paths:
format: uuid
description: Run id (RunHandle.id).
example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c
- name: archive
in: query
required: false
schema:
type: string
enum:
- '1'
description: '`1` (the UI''s Stop) also archives the session the pane held, so it leaves its workspace''s lists. Omitted (the UI''s Sleep, the CLI) the session stays listed, to be resumed later.'
responses:
'200':
description: Kill issued (or run already terminated).
@@ -13312,6 +13328,178 @@ paths:
description: 'ENOBACKUPDIR: no backup folder is set'
'403':
description: 'EBADORIGIN: another site asked'
/api/settings/retention:
get:
tags:
- Settings
summary: Automatic retention and database space
operationId: getRetention
responses:
'200':
description: Automatic retention setting, database space and the last Compact
content:
application/json:
schema:
type: object
required:
- days
- max_days
- env_owned
- compacting
properties:
days:
type: integer
minimum: 0
maximum: 3650
description: Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)
max_days:
type: integer
env_owned:
type: object
properties:
days:
type: boolean
last_run:
type: object
nullable: true
description: 'This process''s last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}'
space:
type: object
nullable: true
properties:
auto_vacuum:
type: string
enum:
- none
- full
- incremental
page_size:
type: integer
page_count:
type: integer
freelist_pages:
type: integer
free_bytes:
type: integer
db_size:
type: integer
description: Database file size in bytes
compacting:
type: boolean
last_compact:
type: object
nullable: true
description: 'This process''s last Compact: {at, ok, size_before?, size_after?, ms?, error?}'
put:
tags:
- Settings
summary: Set the automatic retention
description: Persisted to the settings file as AGENTHUB_RETENTION_DAYS. 0 turns it off. Same-origin only; in server mode an admin's.
operationId: setRetention
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- days
properties:
days:
type: integer
minimum: 0
maximum: 3650
responses:
'200':
description: The new state
content:
application/json:
schema:
type: object
required:
- days
- max_days
- env_owned
- compacting
properties:
days:
type: integer
minimum: 0
maximum: 3650
description: Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)
max_days:
type: integer
env_owned:
type: object
properties:
days:
type: boolean
last_run:
type: object
nullable: true
description: 'This process''s last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}'
space:
type: object
nullable: true
properties:
auto_vacuum:
type: string
enum:
- none
- full
- incremental
page_size:
type: integer
page_count:
type: integer
freelist_pages:
type: integer
free_bytes:
type: integer
db_size:
type: integer
description: Database file size in bytes
compacting:
type: boolean
last_compact:
type: object
nullable: true
description: 'This process''s last Compact: {at, ok, size_before?, size_after?, ms?, error?}'
'400':
description: 'INVALID_INPUT: days is not a whole number from 0 to 3650'
'403':
description: 'EBADORIGIN: another site asked'
'409':
description: 'EENVOWNED: the environment set AGENTHUB_RETENTION_DAYS'
'500':
description: 'ESAVE: the settings file could not be written'
/api/settings/compact:
post:
tags:
- Settings
summary: Compact the database now (full VACUUM)
description: Rewrites the database in a worker thread, switching it to incremental auto_vacuum, then truncates the WAL. Hook writes wait while it runs. Answers at once; poll GET /api/settings/retention while `compacting`. Same-origin only; in server mode an admin's.
operationId: compactDatabase
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- confirm
properties:
confirm:
type: boolean
enum:
- true
responses:
'202':
description: Started (or already running); the retention state
'400':
description: 'ECONFIRM: confirm: true is missing'
'403':
description: 'EBADORIGIN: another site asked'
/api/settings/codex-hooks:
get:
tags:
@@ -14282,6 +14470,94 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/avatar:
get:
tags:
- Auth
operationId: getMyAvatar
summary: The account's own picture (nosniff, sandbox CSP)
responses:
'200':
description: The image (PNG, JPEG, GIF or WebP)
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOAVATAR
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
tags:
- Auth
operationId: setMyAvatar
summary: Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'200':
description: Set
content:
application/json:
schema:
$ref: '#/components/schemas/MyAccount'
'400':
description: EBADIMAGE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: EAUTH — no login cookie (a pane token is refused)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'413':
description: ETOOBIG
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
tags:
- Auth
operationId: removeMyAvatar
summary: Remove the account's picture
responses:
'200':
description: Removed
content:
application/json:
schema:
$ref: '#/components/schemas/MyAccount'
'401':
description: EAUTH — no login cookie (a pane token is refused)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users:
get:
tags:
@@ -14391,6 +14667,118 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/tmux:
get:
tags:
- Auth
operationId: listMemberTmux
summary: 'Each member''s own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)'
responses:
'200':
description: '{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3'
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/tmux-restart:
post:
tags:
- Auth
operationId: restartMemberTmux
summary: End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: '{user}'
'403':
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/tmux-restart-idle:
post:
tags:
- Auth
operationId: restartIdleMemberTmux
summary: Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: '{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}'
'403':
description: EREAUTH / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/avatar:
get:
tags:
- Auth
operationId: getUserAvatar
summary: One account's picture, for the sidebar's owner groups (admin/analyst)
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: The image (PNG, JPEG, GIF or WebP)
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOAVATAR
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}:
get:
tags:
@@ -14741,6 +15129,10 @@ paths:
type: number
spend7d:
type: number
tokens5h:
type: integer
tokens7d:
type: integer
quota:
type: object
nullable: true
@@ -14770,6 +15162,10 @@ paths:
type: number
spend7d:
type: number
tokens5h:
type: integer
tokens7d:
type: integer
quota:
type: object
nullable: true
@@ -15761,12 +16157,12 @@ paths:
operationId: getGitIdentity
responses:
'200':
description: '{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}'
description: '{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}'
/api/me/git/ssh-key:
post:
tags:
- Git identity
summary: Make (or with `replace`, replace) the SSH key
summary: Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it
operationId: generateSshKey
requestBody:
required: true
@@ -15778,17 +16174,112 @@ paths:
properties:
replace:
type: boolean
type:
type: string
enum:
- ed25519
- rsa
name:
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,31}$
responses:
'201':
description: The identity.
'400':
description: EBADKEYNAME
'409':
description: EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS
delete:
tags:
- Git identity
summary: Delete the SSH key
summary: Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key
operationId: deleteSshKey
parameters:
- name: type
in: query
required: false
schema:
type: string
enum:
- ed25519
- rsa
- name: id
in: query
required: false
schema:
type: string
responses:
'200':
description: The identity.
/api/me/git/ssh-terminal:
put:
tags:
- Git identity
summary: Use the SSH key for git in the account's own terminal (core.sshCommand)
operationId: setTerminalSsh
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- 'on'
properties:
'on':
type: boolean
responses:
'200':
description: The identity; `ssh.terminal` is on, off or foreign.
'409':
description: EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand.
/api/me/git/ssh-user:
put:
tags:
- Git identity
summary: 'An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key'
operationId: setSshUser
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- host
- user
properties:
host:
type: string
user:
type: string
alias:
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
key:
type: string
responses:
'200':
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
'400':
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
/api/me/git/ssh-user/{from}:
delete:
tags:
- Git identity
summary: Drop the SSH login of a host or alias (and its key link)
operationId: deleteSshUser
parameters:
- name: from
in: path
required: true
schema:
type: string
responses:
'200':
description: The identity.
'404':
description: ENOTFOUND
/api/me/git/https:
post:
tags:
+21 -983
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.2",
"version": "2.8.9",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -76,7 +76,6 @@
"cors": "^2.8.5",
"express": "^4.21.2",
"multer": "^2.0.0",
"redoc": "^2.5.3",
"swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4",
"tar": "^7.4.3",
@@ -93,6 +92,6 @@
"prettier": "^3.8.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.5.0"
}
}
+60
View File
@@ -1937,6 +1937,47 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
}
}
// Session sync: a session counts as fed LIVE by hooks while it is active, its
// newest event came from a hook (not this importer), and a hook touched it
// within this window. Such a transcript is left to the hooks until it goes
// quiet. DASHBOARD_SYNC_LIVE_HOOK_MS=0 turns the deferral off.
const LIVE_HOOK_MS = (() => {
const n = Number(process.env.DASHBOARD_SYNC_LIVE_HOOK_MS ?? 5 * 60_000);
return Number.isFinite(n) && n >= 0 ? n : 5 * 60_000;
})();
// mtimeCache value for a deferred file: present (so the fs watcher ignores its
// appends) yet never equal to a real mtime (so every sweep re-checks it).
const DEFERRED_MTIME = -1;
const IMPORTED_DATA_RE = /"imported"\s*:\s*true/;
const latestEventStmts = new WeakMap();
function rowIsImported(row) {
try {
return !!(row.metadata && JSON.parse(row.metadata).imported);
} catch {
return false;
}
}
function isHookLive(dbModule, row) {
if (!(LIVE_HOOK_MS > 0) || row.status !== "active") return false;
const touched = Date.parse(row.updated_at);
if (!Number.isFinite(touched) || Date.now() - touched > LIVE_HOOK_MS) return false;
try {
let stmt = latestEventStmts.get(dbModule.db);
if (!stmt) {
stmt = dbModule.db.prepare(
"SELECT data FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1"
);
latestEventStmts.set(dbModule.db, stmt);
}
const ev = stmt.get(row.id);
return !!ev && !IMPORTED_DATA_RE.test(ev.data || "");
} catch {
return false; // when in doubt, sync as before
}
}
/**
* Incremental, change-driven sync of the default `~/.claude/projects` directory.
*
@@ -2024,6 +2065,25 @@ async function syncDefaultProjects(dbModule, options = {}) {
continue;
}
}
// A session hooks are feeding RIGHT NOW: its transcript keeps growing
// between hooks, so `mtime > updated_at` on nearly every sweep, and a
// full re-parse + snapshot copy here bought nothing the hooks do not
// already record. Leave it until it goes quiet (or ends): the sentinel
// keeps the watcher ignoring its appends, and never equals a real
// mtime, so the first sweep after the hooks stop catches it up.
if (existingRow && isHookLive(dbModule, existingRow)) {
mtimeCache.set(sourcePath, DEFERRED_MTIME);
continue;
}
// A session a hook created (no `imported` flag): importSession() skips
// it outright, so parsing the whole transcript + subagents was pure
// waste — only the durable snapshot copy still matters.
if (existingRow && !rowIsImported(existingRow)) {
mtimeCache.set(sourcePath, mtime);
if (require("../server/lib/run-as").sameOwner(sourcePath, existingRow.cwd))
await snapshotTranscript(sourcePath, sessionId);
continue;
}
const existed = !!existingRow;
const session = await parseSessionForImport(projPath, sourcePath);
// Record the mtime even when the file yields no session, so we don't
+171 -25
View File
@@ -138,14 +138,38 @@ function serverIsLive() {
return livePids().length > 0;
}
/**
* What npm installs from a manifest: its dependency lists, or the lockfile's
* package tree — never the release's own `version`, which both files carry.
* Hashing the whole files reinstalled every dependency on each update (minutes
* on a slow link) when the bump changed nothing npm would fetch.
*/
function installedPart(file, text) {
const json = JSON.parse(text);
if (file === "package.json") {
const { dependencies, optionalDependencies, overrides } = json;
return JSON.stringify({ dependencies, optionalDependencies, overrides });
}
const { "": own, ...packages } = json.packages || {};
const { version: _version, ...rest } = own || {};
return JSON.stringify({ own: rest, packages });
}
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
function depsHash(root = PLUGIN_ROOT) {
const h = crypto.createHash("sha256");
for (const f of ["package.json", "package-lock.json"]) {
let text;
try {
h.update(fs.readFileSync(path.join(root, f)));
text = fs.readFileSync(path.join(root, f), "utf8");
} catch {
h.update(f); // absent counts as its own state
continue;
}
try {
h.update(installedPart(f, text));
} catch {
h.update(text); // not JSON npm could read either: the bytes decide
}
}
return h.digest("hex").slice(0, 16);
@@ -196,6 +220,81 @@ function nodeVersionOk(version = process.versions.node) {
return true;
}
/* ------------------------------------------------------- last failure report */
/**
* The worker runs detached with its output discarded, so a failed install, a
* Node that is too old or a failed UI build used to live only in a log nobody
* opens — and every session silently retried. The last failure is kept in one
* small file; the NEXT foreground pass (which Claude Code reads) prints it once
* as a SessionStart `systemMessage`, and a bootstrap that succeeds removes it.
*/
function failurePath(rt = runtimeDir()) {
return path.join(rt, "last-failure.json");
}
function readFailure(rt = runtimeDir()) {
try {
return JSON.parse(fs.readFileSync(failurePath(rt), "utf8"));
} catch {
return null;
}
}
/** Record a failure; the same kind again keeps its `reported` flag (once per failure). */
function recordFailure(rt, kind, message, logFile) {
const prev = readFailure(rt);
const same = prev && prev.kind === kind && prev.message === message;
const rec = {
kind,
message,
log: logFile,
at: new Date().toISOString(),
reported: Boolean(same && prev.reported),
};
try {
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(failurePath(rt), JSON.stringify(rec, null, 2) + "\n", "utf8");
} catch {
/* best effort */
}
}
function clearFailure(rt = runtimeDir()) {
try {
fs.rmSync(failurePath(rt), { force: true });
} catch {
/* best effort */
}
}
/**
* The SessionStart output for an unreported failure, or null. Marks it
* reported, so a failure is shown once — not on every session until fixed.
*/
function takeFailureReport(rt = runtimeDir()) {
const rec = readFailure(rt);
if (!rec || rec.reported) return null;
try {
fs.writeFileSync(failurePath(rt), JSON.stringify({ ...rec, reported: true }, null, 2) + "\n");
} catch {
return null; // could not mark it: stay quiet rather than repeat forever
}
return {
systemMessage:
`AgentHub: ${rec.message}` +
(rec.log ? ` — details in ${rec.log}` : "") +
". Run /agenthub-doctor for a diagnosis.",
};
}
function nodeTooOldMessage(version = process.versions.node) {
return (
`Node ${version} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session`
);
}
/* -------------------------------------------------------------------- lock */
function lockPath(rt = runtimeDir()) {
@@ -422,13 +521,22 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
}
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: "inherit",
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
// npm's output goes to a log: the worker's own stdout is discarded, so
// inheriting it threw away the one explanation of a failed install.
const logPath = path.join(rt, "npm-install.log");
require("./log-rotate").rotateIfLarge(logPath);
const logFd = fs.openSync(logPath, "a");
try {
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: ["ignore", logFd, logFd],
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
} finally {
fs.closeSync(logFd);
}
}
/* ------------------------------------------------------------------ server */
@@ -671,11 +779,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
}
if (!nodeVersionOk()) {
log(
rt,
`Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
);
log(rt, nodeTooOldMessage());
recordFailure(rt, "node-too-old", nodeTooOldMessage(), path.join(rt, "bootstrap.log"));
return "node-too-old";
}
@@ -695,7 +800,16 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
log(rt, "installing runtime dependencies (first run takes a few minutes)");
if (!installDeps(rt)) {
log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it");
log(
rt,
`npm install failed — see ${path.join(rt, "npm-install.log")}; run /agenthub-doctor after fixing it`
);
recordFailure(
rt,
"install-failed",
"installing the dashboard's runtime dependencies failed",
path.join(rt, "npm-install.log")
);
return "install-failed";
}
}
@@ -713,17 +827,24 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// Built eagerly (not lazily behind /agenthub-open) so the dashboard, including
// client-only routes like /run, works the instant `claude` is started —
// same trigger as the dependency install above: missing, or this plugin
// version has not built one yet.
// when missing, or when this plugin version has not built one yet
// (`clientBuild` is recorded only after a build that did not fail, so the
// next session retries a failed one instead of keeping the old UI for good).
const { buildClient } = require("./plugin-open");
const uiResult = buildClient({
rt,
root: PLUGIN_ROOT,
force: force || state.depsHash !== wantDeps,
force: force || state.clientBuild !== pluginVersion(),
logPath: path.join(rt, "client-build.log"),
});
if (uiResult === "failed") {
log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry");
recordFailure(
rt,
"ui-build-failed",
"the dashboard UI build failed (the API still works; /agenthub-open retries it)",
path.join(rt, "client-build.log")
);
} else if (uiResult === "built") {
log(rt, "built the dashboard UI bundle");
}
@@ -829,6 +950,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
{
pluginVersion: pluginVersion(),
depsHash: wantDeps,
clientBuild: uiResult === "failed" ? state.clientBuild : pluginVersion(),
pluginRoot: PLUGIN_ROOT,
claudeHome: getClaudeHome(),
updatedAt: new Date().toISOString(),
@@ -836,6 +958,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
rt
);
if (uiResult !== "failed") clearFailure(rt);
// After the state is durable, so a prune that goes wrong cannot also cost
// us the record of a bootstrap that went right.
pruneRetiredPluginRoots(rt);
@@ -868,13 +992,26 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
* milliseconds in the steady state, and otherwise hands off to a detached
* worker so the session never waits on an install.
*/
function main() {
if (notTheHost() || fastPathOk()) return;
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
function main(rt = runtimeDir()) {
if (notTheHost()) return null;
// Checked here too, not only in the worker: it costs nothing, the worker
// could only log it, and this pass can tell the user right now. A retired
// build stays silent — its worker would do nothing either.
if (!nodeVersionOk()) {
if (supersededBy()) return null;
recordFailure(rt, "node-too-old", nodeTooOldMessage(), null);
return takeFailureReport(rt);
}
if (!fastPathOk(rt)) {
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
}
// A failure the previous worker recorded (it ran after that session's
// foreground pass had already returned). Pure fs; writes only to mark it shown.
return takeFailureReport(rt);
}
// Exported before the entry block below, not after it: `bootstrap()` requires
@@ -897,6 +1034,12 @@ module.exports = {
depsHash,
fastPathOk,
nodeVersionOk,
failurePath,
readFailure,
recordFailure,
clearFailure,
takeFailureReport,
main,
lockPath,
lockIsStale,
acquireLock,
@@ -934,7 +1077,10 @@ if (require.main === module) {
}
} else {
try {
main();
const report = main();
// SessionStart reads a JSON object on stdout; `systemMessage` is shown
// to the user. Nothing at all is printed in the steady state.
if (report) process.stdout.write(JSON.stringify(report) + "\n");
} catch {
// A bootstrap failure must never break a Claude Code session.
}
+46 -12
View File
@@ -68,7 +68,19 @@ function buildClient({
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try {
const src = path.join(rt, "client-src");
fs.rmSync(src, { recursive: true, force: true });
// The installed tree is kept across updates while the client's manifests
// are unchanged (`stamp`, written after a successful install): a release
// rarely touches them, and the clean `npm ci` below fetched all of the
// ~200 MB toolchain again on every update.
const stamp = path.join(src, "node_modules", ".agenthub-deps");
const want = clientDepsHash(root);
const reuse = readText(stamp) === want;
if (reuse) {
for (const e of fs.readdirSync(src))
if (e !== "node_modules") fs.rmSync(path.join(src, e), { recursive: true, force: true });
} else {
fs.rmSync(src, { recursive: true, force: true });
}
fs.cpSync(path.join(root, "client"), src, {
recursive: true,
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
@@ -101,19 +113,25 @@ function buildClient({
// esbuild is the only dependency with an install script, and it finds its
// platform binary without it.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
if (!reuse) {
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
}
fs.mkdirSync(path.dirname(stamp), { recursive: true });
fs.writeFileSync(stamp, want);
}
// = client/package.json's "build" script (`tsc -b && vite build`); a test
// keeps the two in step.
// client/package.json's "build" script is `tsc -b && vite build`; only the
// vite half runs here. The type check emits nothing the bundle needs, and on
// a user's machine it cost ~25 s and ~900 MB of memory per update — and a
// type error in a release then kept the old UI everywhere (v2.7.0). The
// release itself still runs `tsc -b`. A test keeps the two in step.
const bin = (...p) => path.join(src, "node_modules", ...p);
if (
!exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) ||
!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)
) {
if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) {
// Maybe a damaged kept tree: the next build starts from a clean install.
fs.rmSync(stamp, { force: true });
return "failed";
}
@@ -125,6 +143,22 @@ function buildClient({
}
}
function readText(file) {
try {
return fs.readFileSync(file, "utf8");
} catch {
return null;
}
}
/** The client's manifests, which decide whether its installed tree still fits. */
function clientDepsHash(root) {
const h = require("crypto").createHash("sha256");
for (const f of ["package.json", "package-lock.json"])
h.update(readText(path.join(root, "client", f)) ?? `no ${f}`);
return h.digest("hex").slice(0, 16);
}
function dashboardUrl() {
try {
return `http://localhost:${resolveDashboardPort()}`;
+24 -3
View File
@@ -3,14 +3,20 @@
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
* old dashboard PID until it exits (server/index.js's own SIGTERM handler
* has a 5s force-exit backstop, so this never waits forever), then starts a
* fresh server from the same entry point and exits itself.
* fresh server from the same entry point and exits itself. The new server's
* stdout/stderr are appended to the log file it is given (the checkout's
* `data/agenthub-server.log`, the one `agenthub start` writes), so a crash
* after an update still leaves Node's stack trace behind.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { rotateIfLarge } = require("./log-rotate");
const [, , pidArg, entry] = process.argv;
const [, , pidArg, entry, logArg] = process.argv;
const pid = parseInt(pidArg, 10);
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
@@ -26,10 +32,25 @@ function isAlive(p) {
}
}
/** Append fd for the new server's output, or "ignore" when there is none. */
function openLog(file) {
if (!file) return "ignore";
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
rotateIfLarge(file);
const fd = fs.openSync(file, "a");
fs.writeSync(fd, `[${new Date().toISOString()}] restart-helper: old server ${pid} gone, starting ${entry}\n`);
return fd;
} catch {
return "ignore"; // a log must never stop the restart
}
}
function restart() {
const out = openLog(logArg);
const child = spawn(process.execPath, [entry], {
detached: true,
stdio: "ignore",
stdio: ["ignore", out, out],
env: process.env,
});
child.unref();
+3 -1
View File
@@ -16,6 +16,7 @@
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
# /etc/sudoers.d/agenthub checked with visudo before it is installed
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
@@ -105,6 +106,7 @@ chmod 0644 /etc/agenthub/server.conf
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
NODE_VER=$("$NODE" -p process.version)
@@ -138,7 +140,7 @@ visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty"
{
echo "# AgentHub server mode (install-system). Members are never in sudoers."
[ -z "$NOTTY" ] || echo "$NOTTY"
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, /opt/agenthub/libexec/tmux-member, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
for a in start stop restart enable disable; do
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# @file tmux-member.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/tmux-member, the one tmux the host may run AS a
# member (sudoers). Called as `tmux-member -S <socket> <tmux args…>`, it runs
# the args with the binary of the tmux server already listening on that
# socket (`/proc/<pid>/exe`, which still runs after an upgrade replaced the
# file), else with TMUX_BIN from /etc/agenthub/server.conf (no server yet:
# this call starts one, on the installed version). A client must match its
# server: a tmux 3.8 client asking a 3.6 server kept it at 100% CPU.
#
# It runs as the member and execs only a process THEY own (under hidepid they
# see no other), so it can run nothing they could not run themselves.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -u
bin=$(sed -n 's/^TMUX_BIN=//p' /etc/agenthub/server.conf 2>/dev/null | head -n 1)
[[ "$bin" == /* ]] || { echo "tmux-member: no TMUX_BIN in /etc/agenthub/server.conf" >&2; exit 2; }
if [ "${1:-}" = -S ] && [ -n "${2:-}" ]; then
# /proc/net/unix: Num RefCount Protocol Flags Type St Inode Path; 00010000 = listening.
inode=$(awk -v s="$2" '$4 == "00010000" && $8 == s { print $7; exit }' /proc/net/unix 2>/dev/null)
if [ -n "$inode" ]; then
for d in /proc/[0-9]*; do
[ -O "$d" ] || continue
read -r comm <"$d/comm" 2>/dev/null || continue
[[ "$comm" == tmux* ]] || continue
for fd in "$d"/fd/*; do
if [ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && [ -x "$d/exe" ]; then
exec "$d/exe" "$@"
fi
done
done
fi
fi
exec "$bin" "$@"
+1 -1
View File
@@ -6,7 +6,7 @@
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
* itself unavailable off Linux). Both export the same API — and so does the
* third, `systemd-system-service.js`, chosen while the server-mode SYSTEM unit
* `sudo agenthub server install-system` wrote exists (pure fs check).
* `agenthub server install-system` wrote exists (pure fs check).
* Require THIS module, never one of the three directly.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+2 -2
View File
@@ -3,7 +3,7 @@
* @file systemd-system-service.js
* @description Server mode with one Linux user per account (S3): the dashboard
* runs as a SYSTEM unit, `/etc/systemd/system/agenthub.service`, written once
* by `sudo agenthub server install-system` (scripts/server/install-system.sh).
* by `agenthub server install-system` (scripts/server/install-system.sh).
* The unit runs as the host account with group `agenthub` and
* CAP_DAC_READ_SEARCH — the two things a `systemd --user` unit cannot give.
*
@@ -162,7 +162,7 @@ function installUserService(opts) {
return result;
}
/** Removing the system unit is root's: `sudo agenthub server install-system --uninstall`. */
/** Removing the system unit is root's: `agenthub server install-system --uninstall`. */
function uninstallUserService() {
return "foreign";
}
+3 -2
View File
@@ -439,7 +439,7 @@ All endpoints return JSON unless noted. Error responses use:
| `GET` | `/api/openapi.json` | Raw OpenAPI 3.0.3 spec |
| `GET` | `/api/docs` | Interactive **Swagger UI** (try-it-out request execution) |
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (via the `redoc` dependency, never a CDN — works offline) |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
@@ -1600,10 +1600,11 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version), only while one runs (checked every 30 s); 0 = off
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone while hooks fed its session within this window; 0 = never defer
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
+7
View File
@@ -213,6 +213,13 @@ describe("OpenAPI / Swagger", () => {
const res = await fetch("/api/redoc/redoc.standalone.js");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /javascript/);
// The vendored file (server/vendor/redoc/), licence banner intact — the
// `redoc` npm package is no longer a dependency.
assert.match(
res.body,
/For license information please see redoc\.standalone\.js\.LICENSE\.txt/
);
assert.doesNotThrow(() => require("fs").statSync(require("../lib/redoc").redocBundlePath()));
});
});
+168 -47
View File
@@ -42,19 +42,21 @@ function request(method, p, body, headers = {}) {
method,
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({
status: r.status,
headers: r.headers,
text: await r.text(),
})).then((r) => {
let json = null;
try {
json = JSON.parse(r.text);
} catch {
/* not JSON */
}
return { ...r, body: json };
});
})
.then(async (r) => ({
status: r.status,
headers: r.headers,
text: await r.text(),
}))
.then((r) => {
let json = null;
try {
json = JSON.parse(r.text);
} catch {
/* not JSON */
}
return { ...r, body: json };
});
}
before(async () => {
@@ -63,18 +65,23 @@ before(async () => {
fs.writeFileSync(path.join(ws, "main.js"), "code");
const skill = path.join(ws, ".claude", "skills", "deploy");
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
fs.writeFileSync(
path.join(skill, "SKILL.md"),
"---\nname: deploy\ndescription: Ship it\n---\nDo it.\n"
);
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
ws,
lane.id
);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)"
).run(ws, lane.id);
const ev = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
);
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
ev.run(
"Write",
JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } })
);
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
server = http.createServer(createApp());
@@ -125,7 +132,10 @@ describe("files", () => {
});
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
const r = await request("POST", "/api/artifacts/file", {
laneId: lane.id,
path: "out/report.html",
});
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
// The live file changes; the artifact does not.
@@ -138,7 +148,10 @@ describe("files", () => {
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
assert.equal(other.status, 404);
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
const escape = await request("POST", "/api/artifacts/file", {
laneId: lane.id,
path: "../t.db",
});
assert.equal(escape.status, 400);
const del = await request("DELETE", `/api/artifacts/${id}`);
@@ -154,10 +167,7 @@ describe("skills", () => {
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
assert.equal(r.status, 201, r.text);
assert.equal(r.body.item.description, "Ship it");
assert.deepEqual(
r.body.item.files.map((f) => f.path).sort(),
["SKILL.md", "scripts/run.sh"]
);
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
const id = r.body.item.id;
const first = await request("POST", `/api/artifacts/${id}/install`, {});
@@ -240,7 +250,12 @@ function upload(p, files, fields = {}) {
const snapText = (item, file = "SKILL.md") =>
fs.readFileSync(
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
path.join(
process.env.DASHBOARD_DATA_DIR,
"artifacts",
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot,
file
),
"utf8"
);
@@ -263,7 +278,10 @@ describe("plugins from a marketplace link", () => {
n("https://git.example.vn/Org/skills/src/branch/main/"),
"https://git.example.vn/Org/skills"
);
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
assert.equal(
n("https://git.example.vn/Org/skills.git"),
"https://git.example.vn/Org/skills.git"
);
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
});
@@ -281,7 +299,10 @@ describe("plugins from a marketplace link", () => {
assert.equal(r.status, 200, r.text);
assert.equal(asked, "https://git.example.vn/Org/skills.git");
assert.equal(r.body.name, "sg");
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
assert.deepEqual(
r.body.plugins.map((p) => p.key),
["sg-dev@sg", "sg-common@sg"]
);
const add = (title) =>
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
@@ -312,14 +333,21 @@ describe("plugins from a marketplace link", () => {
describe("skills from files or written", () => {
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
name: "Hướng dẫn deploy",
description: "Use when deploying",
});
const r = await upload(
"/api/artifacts/skill/upload",
[["Notes cho đội.md", "# Do this\nStep 1\n"]],
{
name: "Hướng dẫn deploy",
description: "Use when deploying",
}
);
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "Huong-dan-deploy");
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
assert.deepEqual(
item.files.map((f) => f.path),
["SKILL.md"]
);
const text = snapText(item);
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
assert.match(text, /# Do this/);
@@ -364,15 +392,23 @@ describe("skills from files or written", () => {
["b/SKILL.md", "no frontmatter"],
]);
assert.equal(r.body.error.code, "ENODESCRIPTION");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
const secret = await upload(
"/api/artifacts/skill/upload",
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n,
before
);
const secret = await upload("/api/artifacts/skill/upload", [
["s/SKILL.md", "---\ndescription: S\n---\n"],
["s/.env", "K=1"],
]);
assert.equal(secret.body.error.code, "ESECRET");
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
description: "d",
});
assert.equal(out.body.error.code, "EBADPATH");
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
const none = await upload("/api/artifacts/skill/upload", [
["a.png", "x"],
["b.png", "y"],
]);
assert.equal(none.body.error.code, "ENOSKILLMD");
});
@@ -383,13 +419,22 @@ describe("skills from files or written", () => {
content: "Summarise.",
});
assert.equal(r.status, 201, r.text);
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
assert.match(
snapText(r.body.item),
/^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./
);
const bad = await request("POST", "/api/artifacts/skill/new", {
name: "!!!",
description: "d",
content: "c",
});
assert.equal(bad.body.error.code, "EBADNAME");
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
title: "Report",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.item.kind, "file");
assert.equal(r.body.item.title, "Report");
@@ -408,13 +453,25 @@ describe("sharing and visibility", () => {
});
it("shows a server-mode developer their own rows and shared ones only", () => {
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
db.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')"
).run();
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
const titles = artifacts.list(dev).map((i) => i.title).sort();
const titles = artifacts
.list(dev)
.map((i) => i.title)
.sort();
assert.deepEqual(titles, ["a", "b"]);
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
assert.deepEqual(
artifacts.list(dev, { scope: "hub" }).map((i) => i.title),
["b"]
);
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
assert.equal(artifacts.canSeeId(dev, hidden), false);
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
@@ -431,3 +488,67 @@ describe("sharing and visibility", () => {
assert.equal(r.status, 403);
});
});
describe("search, frontmatter and marketplace limits", () => {
it("finds a title holding `_` or `%` literally, and only that one", () => {
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'my_skill', 'https://claude.ai/u1')"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'myXskill', 'https://claude.ai/u2')"
).run();
db.prepare(
"INSERT INTO artifacts (kind, title, url) VALUES ('page', '100% done', 'https://claude.ai/u3')"
).run();
assert.deepEqual(
artifacts.list(null, { q: "my_skill" }).map((i) => i.title),
["my_skill"]
);
assert.deepEqual(
artifacts.list(null, { q: "100%" }).map((i) => i.title),
["100% done"]
);
});
it("replaces a multi-line description whole, and quotes one YAML would misread", () => {
const src =
"---\nname: x\ndescription: >\n old line one\n old line two\nallowed-tools: Read\n---\nbody\n";
assert.equal(
artifacts.withFrontmatter(src, "x", "new desc"),
"---\nname: x\ndescription: new desc\nallowed-tools: Read\n---\nbody\n"
);
assert.match(
artifacts.withFrontmatter("body", "x", "- Use for reports"),
/^description: "- Use for reports"$/m
);
assert.match(
artifacts.withFrontmatter("body", "x", "Use for reports"),
/^description: Use for reports$/m
);
});
it("stops reading a marketplace.json link past 1 MB", async () => {
const real = globalThis.fetch;
let pulled = 0;
globalThis.fetch = async () => {
const body = new ReadableStream({
pull(c) {
pulled += 1;
if (pulled > 100) return c.close();
c.enqueue(new Uint8Array(64 * 1024));
},
});
const res = new Response(body);
Object.defineProperty(res, "url", { value: "https://x.example/m.json" });
return res;
};
try {
await assert.rejects(artifacts.inspectMarket("https://x.example/m.json"), {
code: "ETOOBIG",
});
} finally {
globalThis.fetch = real;
}
assert.ok(pulled < 30, `read ${pulled} chunks`);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file Tests server/lib/backoff-poll.js — the poll that runs at its base pace
* while passes find work (or fail) and slows to its idle pace after a pass
* that resolved `false`, returning to the base pace once work shows up again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { startBackoffPoll, idleDefault } = require("../lib/backoff-poll");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
describe("startBackoffPoll", () => {
it("slows down after idle passes and speeds up again on work", async () => {
const answers = [true, false, false, true, "x"];
const at = [];
const t0 = Date.now();
const poll = startBackoffPoll(
() => {
at.push(Date.now() - t0);
const a = answers.shift();
if (a === "x") throw new Error("boom");
return a;
},
{ baseMs: 10, idleMs: 80 }
);
await wait(260);
poll.stop();
assert.ok(at.length >= 5, `ran ${at.length} times`);
const gaps = at.slice(1).map((t, i) => t - at[i]);
assert.ok(gaps[0] < 60, "work → base pace");
assert.ok(gaps[1] >= 75, "idle → idle pace");
assert.ok(gaps[2] >= 75, "still idle");
assert.ok(gaps[3] < 60, "work again → base pace");
});
it("idles at a minute by default, never faster than the base pace", () => {
assert.equal(idleDefault(15_000), 60_000);
assert.equal(idleDefault(120_000), 120_000);
});
});
+78 -1
View File
@@ -179,7 +179,9 @@ describe("ingestCodexUsage", () => {
});
it("settles an active session to completed once its journals go quiet", async () => {
const now = Date.now();
// A whole second: utimes rounds through a float, and a millisecond `now`
// came back from stat 1 ms early about half the time.
const now = Math.floor(Date.now() / 1000) * 1000;
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
@@ -536,3 +538,78 @@ describe("service tier", () => {
]);
});
});
describe("ingestCodexUsage — recent pass", () => {
const NOW = Date.parse("2026-09-29T03:00:00Z");
function writeAt(day, id, lines, mtime) {
const dir = path.join(process.env.CODEX_HOME, "sessions", ...day.split("/"));
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `rollout-${day.replace(/\//g, "-")}T01-00-00-${id}.jsonl`);
fs.writeFileSync(file, jsonl(lines));
fs.utimesSync(file, mtime, mtime);
return file;
}
it("stats only the newest day folders and the journals still growing", async () => {
const quiet = writeAt(
"2026/09/01",
"old1",
[meta("old1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 20 * 86_400_000)
);
const live = writeAt(
"2026/09/02",
"old2",
[meta("old2"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 60_000)
);
writeAt("2026/09/27", "a", [meta("a"), ctx("gpt-a")], new Date(NOW - 86_400_000));
writeAt("2026/09/28", "b", [meta("b"), ctx("gpt-a")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW }); // the full first pass
// Both old journals grow; only the one written within the hour is looked at.
fs.appendFileSync(quiet, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.appendFileSync(live, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.utimesSync(quiet, new Date(NOW - 1000), new Date(NOW - 1000));
fs.utimesSync(live, new Date(NOW - 1000), new Date(NOW - 1000));
// A new journal in today's folder is found too.
writeAt(
"2026/09/29",
"new1",
[meta("new1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("old2"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }], "left to a full pass");
assert.deepEqual(usage("new1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
await ingestCodexUsage({ now: NOW }); // the next full pass catches it up
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
});
it("finds a fork's parent in an older folder on a recent pass", async () => {
const parent = [
meta("p9"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeAt("2026/08/01", "p9", parent, new Date(NOW - 50 * 86_400_000));
writeAt("2026/09/28", "x", [meta("x")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW });
writeAt(
"2026/09/29",
"f9",
[
meta("f9", { forked_from_id: "p9" }),
meta("p9"),
...parent.slice(1),
tc([350, 0, 35], [50, 0, 5]),
],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("f9"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @file Tests server/lib/db-retention.js and its Settings routes — a new
* database is INCREMENTAL auto_vacuum, retention is off by default and purges
* nothing then, when on it removes only finished sessions older than the
* cutoff (with their events, agents and token rows) in bounded batches, the
* free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention
* validate and persist the setting, and POST /api/settings/compact needs
* confirm and runs a VACUUM in a worker. The database and settings file are
* temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
delete process.env.AGENTHUB_RETENTION_DAYS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const retention = require("../lib/db-retention");
let server;
let BASE;
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
delete process.env.AGENTHUB_RETENTION_DAYS;
fs.rmSync(ROOT, { recursive: true, force: true });
});
async function call(method, urlPath, body) {
const res = await fetch(BASE + urlPath, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
}
const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString();
function seed(id, status, startedAt, events = 3) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)"
).run(id, id, status, startedAt, startedAt);
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)"
);
for (let i = 0; i < events; i++)
ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt);
}
const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
const eventCount = (id) =>
db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n;
describe("auto_vacuum", () => {
it("a new database is INCREMENTAL", () => {
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental");
});
});
describe("retentionConfig", () => {
it("is off by default and for anything out of range", () => {
assert.equal(retention.retentionConfig({}).days, 0);
for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) {
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v);
}
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30);
});
});
describe("scheduled maintenance", () => {
it("purges nothing while retention is off", async () => {
seed("ret-off-old", "completed", daysAgo(400));
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention, undefined);
assert.ok(exists("ret-off-old"));
db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run();
});
it("purges only finished sessions older than the cutoff, with their rows", async () => {
seed("ret-old-done", "completed", daysAgo(100));
seed("ret-old-err", "error", daysAgo(100));
seed("ret-old-active", "active", daysAgo(100));
seed("ret-new-done", "completed", daysAgo(5));
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)"
).run();
process.env.AGENTHUB_RETENTION_DAYS = "30";
try {
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention.purged_sessions, 2);
assert.equal(out.retention.purged_events, 6);
} finally {
delete process.env.AGENTHUB_RETENTION_DAYS;
}
assert.ok(!exists("ret-old-done"));
assert.ok(!exists("ret-old-err"));
assert.equal(eventCount("ret-old-done"), 0);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n,
0
);
assert.ok(exists("ret-old-active"), "an active session is never purged");
assert.ok(exists("ret-new-done"), "a recent session is kept");
assert.equal(eventCount("ret-new-done"), 3);
});
it("works in bounded batches and stops at the per-run cap", async () => {
for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1);
const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(first.purged_sessions, 5);
const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(second.purged_sessions, 2);
const third = await retention.applyRetention({ db, days: 10 });
assert.equal(third.purged_sessions, 0);
});
it("returns free pages to the OS", async () => {
for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20);
await retention.applyRetention({ db, days: 30 });
const before = db.pragma("freelist_count", { simple: true });
assert.ok(before > 50, `expected free pages, got ${before}`);
const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 });
assert.ok(out.reclaimed_pages > 0);
assert.ok(db.pragma("freelist_count", { simple: true }) < before);
});
});
describe("routes", () => {
it("GET reports retention off by default", async () => {
const r = await call("GET", "/api/settings/retention");
assert.equal(r.status, 200);
assert.equal(r.body.days, 0);
assert.equal(r.body.compacting, false);
assert.equal(r.body.space.auto_vacuum, "incremental");
});
it("PUT validates and persists, 0 turns it off", async () => {
for (const bad of [-1, 1.5, "7", 99999, null]) {
const r = await call("PUT", "/api/settings/retention", { days: bad });
assert.equal(r.status, 400, String(bad));
}
let r = await call("PUT", "/api/settings/retention", { days: 90 });
assert.equal(r.status, 200);
assert.equal(r.body.days, 90);
assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/);
r = await call("PUT", "/api/settings/retention", { days: 0 });
assert.equal(r.body.days, 0);
});
it("POST /compact needs confirm and then vacuums", async () => {
let r = await call("POST", "/api/settings/compact", {});
assert.equal(r.status, 400);
r = await call("POST", "/api/settings/compact", { confirm: true });
assert.equal(r.status, 202);
for (let i = 0; i < 200 && retention.isCompacting(); i++) {
await new Promise((res) => setTimeout(res, 50));
}
const last = retention.lastCompactResult();
assert.equal(last?.ok, true, last?.error);
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
});
});
+135 -4
View File
@@ -3,7 +3,9 @@
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
* becomes a workspace, the SSH key persisted into the clone, 409
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk), and the member (S3) path of
* only the public half returned, 0600 on disk; named keys beside the default
* ones, SSH logins per alias tied to one key, which a clone then offers
* alone), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's
* store as it was.
@@ -246,6 +248,8 @@ describe("/api/me/git", () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
// No commit author yet: the key is labelled with the default email.
assert.match(made.body.ssh.publicKey, /@smartgift\.vn$/);
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
@@ -311,6 +315,7 @@ describe("/api/me/git", () => {
it("reads and sets the commit author, refusing a bad one", async () => {
const r0 = await request("GET", "/api/me/git");
assert.deepEqual(r0.body.author, { name: null, email: null });
assert.equal(r0.body.defaultEmail, `${os.userInfo().username}@smartgift.vn`);
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADAUTHOR");
@@ -318,6 +323,10 @@ describe("/api/me/git", () => {
assert.equal(ok.status, 200);
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
// A key made now is labelled with the author's email.
const key = await request("POST", "/api/me/git/ssh-key", { replace: true });
assert.match(key.body.ssh.publicKey, / vi@example\.com$/);
await request("DELETE", "/api/me/git/ssh-key");
});
it("saves an https login without ever returning the token", async () => {
@@ -404,7 +413,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
user: "APKAEXAMPLE1",
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
assert.deepEqual(r.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE1", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
// A second save replaces, never stacks a second rewrite.
@@ -412,7 +423,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
host: HOST,
user: "APKAEXAMPLE2",
});
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
assert.deepEqual(again.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE2", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
assert.deepEqual(del.body.sshUsers, []);
@@ -434,7 +447,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
assert.equal(bad.body.error.code, "EBADSSHUSER");
// Ours lists only our shape; a rule the user wrote for another host is kept.
const r = await request("GET", "/api/me/git");
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
assert.deepEqual(r.body.sshUsers, [
{ host: "mine.test", user: "me", alias: null, key: null },
]);
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
} finally {
@@ -443,6 +458,122 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
});
});
describe("several accounts: named keys and SSH logins per alias", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const HOST = "git-codecommit.us-east-1.amazonaws.com";
it("keeps named keys beside the default ones, and deletes one by id", async () => {
await request("DELETE", "/api/me/git/ssh-key");
const a = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "AWS-Prod" });
assert.equal(a.status, 201, JSON.stringify(a.body));
const b = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
const d = await request("POST", "/api/me/git/ssh-key", {});
assert.deepEqual(
d.body.sshKeys.map((k) => [k.id, k.type, k.name]),
[
["id_ed25519", "ed25519", null],
["id_rsa_aws-dev", "rsa", "aws-dev"],
["id_rsa_aws-prod", "rsa", "aws-prod"],
]
);
assert.notEqual(b.body.sshKeys[0].publicKey, a.body.sshKeys[0].publicKey);
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa_aws-prod")).mode & 0o777, 0o600);
// One name per key, whatever its type; a bad name is refused.
const dup = await request("POST", "/api/me/git/ssh-key", { name: "aws-dev" });
assert.equal(dup.status, 409);
const bad = await request("POST", "/api/me/git/ssh-key", { name: "../x" });
assert.equal(bad.body.error.code, "EBADKEYNAME");
const gone = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev");
assert.deepEqual(
gone.body.sshKeys.map((k) => k.id),
["id_ed25519", "id_rsa_aws-prod"]
);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev")).status, 404);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=../x")).status, 400);
// The terminal command names every key once switched on, and follows them.
await request("PUT", "/api/me/git/ssh-terminal", { on: true });
const cmd = () =>
execFileSync("git", ["config", "--global", "core.sshCommand"], { encoding: "utf8" });
assert.match(cmd(), /id_rsa_aws-prod'/);
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
assert.equal(again.body.sshTerminal, "on");
assert.match(cmd(), /id_rsa_aws-dev'/);
await request("PUT", "/api/me/git/ssh-terminal", { on: false });
});
it("logs each alias in as its own user, and a clone of it uses that key alone", async () => {
const prod = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "APKAPROD",
alias: "AWS-Prod",
key: "id_rsa_aws-prod",
});
assert.equal(prod.status, 200, JSON.stringify(prod.body));
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEV", alias: "aws-dev" });
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEFAULT" });
const rows = (await request("GET", "/api/me/git")).body.sshUsers;
assert.deepEqual(
rows.map((r) => [r.alias, r.user, r.key]).sort(),
[
["aws-dev", "APKADEV", null],
["aws-prod", "APKAPROD", "id_rsa_aws-prod"],
[null, "APKADEFAULT", null],
].sort()
);
assert.equal(getUrl("ssh://aws-prod/v1/repos/x"), `ssh://APKAPROD@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), `ssh://APKADEV@${HOST}/v1/repos/x`);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKADEFAULT@${HOST}/v1/repos/x`);
// Refused: an alias with a dot (it could shadow a real host), a key that is not there.
const dot = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "a.b",
});
assert.equal(dot.body.error.code, "EBADALIAS");
const nokey = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "x",
key: "id_rsa_nope",
});
assert.equal(nokey.body.error.code, "ENOKEY");
// The test forge stands in for CodeCommit: a longer insteadOf wins.
const bare = path.join(ROOT, "bare.git");
execFileSync("git", [
"config",
"--global",
"--add",
`url.file://${bare}.insteadOf`,
"ssh://aws-prod/v1/repos/r",
]);
const r = await request("POST", "/api/lanes/clone", {
url: "ssh://aws-prod/v1/repos/r",
parent: WORK,
name: "via-alias",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const cmd = execFileSync(
"git",
["-C", path.join(WORK, "via-alias"), "config", "core.sshCommand"],
{ encoding: "utf8" }
).trim();
assert.match(cmd, /^ssh -o IdentityFile='.*\/id_rsa_aws-prod' -o IdentitiesOnly=yes$/);
// Deleting the key unties the login; deleting the login drops the rule.
const unkeyed = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-prod");
assert.equal(unkeyed.body.sshUsers.find((x) => x.alias === "aws-prod").key, null);
for (const f of ["aws-prod", "aws-dev", HOST]) {
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${f}`)).status, 200);
}
assert.deepEqual((await request("GET", "/api/me/git")).body.sshUsers, []);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), "ssh://aws-dev/v1/repos/x");
await request("DELETE", "/api/me/git/ssh-key");
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
+142 -1
View File
@@ -5,7 +5,8 @@
* (also through a symlink, a download or a zip) while a nested dotfile is
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
* under each side's own rule — and in server mode a developer never reaches
* another user's home while an admin gets it read-only.
* another user's home while an admin gets it read-only — except its Trash,
* which the admin lists, restores from and purges as the member's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -204,3 +205,143 @@ describe("homes in server mode", () => {
assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly));
});
});
describe("an admin manages a member's Trash", () => {
const auth = require("../lib/auth");
const USERS = path.join(ROOT, "users");
let cara;
let dan;
const call = (method, p, cookie, body) =>
fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", cookie },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
const login = async (username, password) => {
const r = await fetch(`${base}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return r.headers.get("set-cookie").split(";")[0];
};
before(async () => {
fs.mkdirSync(path.join(USERS, "cara"), { recursive: true });
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
await auth.createUser("cara", "cara-pass-1", "developer");
await auth.createUser("dan", "dan-pass-1", "admin");
cara = await login("cara", "cara-pass-1");
dan = await login("dan", "dan-pass-1");
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
});
it("lists, restores and purges the member's own trash — the rest stays read-only", async () => {
const C = "/api/homes/me/fs";
const D = "/api/homes/cara/fs";
for (const n of ["old", "junk"]) {
assert.equal((await call("POST", `${C}/mkdir`, cara, { path: n })).status, 201);
}
assert.equal((await call("POST", `${C}/delete`, cara, { paths: ["old", "junk"] })).status, 200);
// The admin sees CARA's trash, not their own.
let items = (await call("GET", `${D}/trash`, dan)).body.items;
assert.deepEqual(items.map((i) => i.name).sort(), ["junk", "old"]);
assert.equal((await call("GET", "/api/homes/me/fs/trash", dan)).body?.items?.length ?? 0, 0);
// …and every user's in one list, each item tagged with its home and owner.
const all = (await call("GET", "/api/homes/trash", dan)).body;
assert.deepEqual(
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
[
["junk", "cara", "cara"],
["old", "cara", "cara"],
]
);
// A developer's merged view is only their own.
assert.deepEqual(
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
["me", "me"]
);
const old = items.find((i) => i.name === "old");
const r = await call("POST", `${D}/trash/restore`, dan, { id: old.id });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.ok(fs.statSync(path.join(USERS, "cara", "old")).isDirectory());
assert.equal((await call("POST", `${D}/trash/purge`, dan, { id: "all" })).body.purged, 1);
assert.equal((await call("GET", `${C}/trash`, cara)).body.items.length, 0);
// Everything else in another user's home is still refused.
const w = await call("POST", `${D}/mkdir`, dan, { path: "nope" });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
assert.equal((await call("POST", `${D}/delete`, dan, { paths: ["old"] })).status, 403);
// A developer still never reaches another user's trash.
assert.equal((await call("GET", "/api/homes/dan/fs/trash", cara)).status, 404);
});
});
describe("one recycle bin per user", () => {
const fx = require("../lib/file-explorer");
const runAs = require("../lib/run-as");
const MEMBER = path.join(ROOT, "member-home");
const put = (root, id, name, laneId) => {
fs.mkdirSync(path.join(root, id, "item"), { recursive: true });
fs.writeFileSync(
path.join(root, id, "meta.json"),
JSON.stringify({
id,
name,
laneId,
rel: name,
isDir: true,
deletedAt: new Date().toISOString(),
})
);
};
it("has one store when the home is the host's", async () => {
const out = await fx.listUserTrash(HOME, "solo");
assert.deepEqual(out.items, []);
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "solo"), "solo0001", "x", null);
const items = (await fx.listUserTrash(HOME, "solo")).items;
assert.deepEqual(
items.map((i) => [i.name, i.store]),
[["x", "home"]]
);
assert.equal(
(await fx.purgeUserTrash(HOME, "solo", "host", "solo0001").catch((e) => e)).code,
"EBADPATH"
);
});
it("merges a member's home trash with their host trash, each item tagged", async (t) => {
fs.mkdirSync(MEMBER, { recursive: true });
const acct = { name: "mem", home: MEMBER, uid: process.getuid() };
const orig = runAs.accountForPath;
t.after(() => {
runAs.accountForPath = orig;
});
runAs.accountForPath = (p) => (String(p).startsWith(MEMBER) ? acct : null);
put(path.join(MEMBER, ".local", "share", "agenthub", "trash"), "memhome01", "fromHome", null);
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77"), "memhost01", "fromHostWs", 5);
const items = (await fx.listUserTrash(MEMBER, "77")).items;
assert.deepEqual(items.map((i) => [i.name, i.store]).sort(), [
["fromHome", "home"],
["fromHostWs", "host"],
]);
// A host item whose workspace is gone has nowhere to go back to.
const r = await fx
.restoreUserTrash(MEMBER, "77", "host", "memhost01", () => null)
.catch((e) => e);
assert.equal(r.code, "ENOFILE");
assert.equal((await fx.purgeUserTrash(MEMBER, "77", "host", "memhost01")).purged, 1);
assert.ok(
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77", "memhost01"))
);
});
});

Some files were not shown because too many files have changed in this diff Show More