feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+26 -2
View File
@@ -84,6 +84,8 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` |
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
| `GET /api/me/prefs` | The caller's OWN UI preferences: `{prefs: {key: string}, onboardedAt}` — row `u<id>` in server mode (by the cookie, `401` without one), `local` in local mode; the request never names an account |
| `PUT /api/me/prefs` `{prefs: {key: string \| null}}` | Merges into that row (`null` removes a key) and answers like the GET. Only the keys in `PREF_KEYS` (`server/routes/me.js`; today `agenthub.chatUi`, `"1"` = sessions open as a chat), each a string ≤ 8 KB, else `400 EBADPREFS`; same-origin |
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
@@ -341,6 +343,8 @@ Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every r
Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names a session a live pane already holds — two `--resume`s of one session would be two agents writing one transcript. `error.run_id` names that pane; open it instead. A fork (`forkSession: true`) makes a new session and is allowed. A `resumeSessionId` on a catalog tool (anything but Claude/Codex) is `400 EUNSUPPORTED`, since it would silently start fresh.
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action
@@ -1348,7 +1352,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<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`.
@@ -1829,6 +1833,14 @@ POST /api/settings/quit
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.
#### Uninstall AgentHub
```http
POST /api/settings/uninstall
```
Settings → System's **Uninstall AgentHub** (same-origin only). Body `{ "confirm": true }` — anything else is `400 ECONFIRM`; in server mode `403 ESERVERMODE` (a shared dashboard is removed with `agenthub uninstall` on the host). Spawns `scripts/uninstall.js --yes` detached and answers `202 { ok: true, log }` at once; the worker then stops this server and removes everything `agenthub uninstall --yes` does (see [CLI.md](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-<uid>.log`, the only record left afterwards.
---
### Tools
@@ -2661,6 +2673,18 @@ A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
### Daily database backup
```
GET /api/settings/backup { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
PUT /api/settings/backup { dir?, hour?, keep? } → the same state; dir "" turns backups off
POST /api/settings/backup/run 202 — started (or already running); poll GET while `running`
```
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<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`.
---
## WebSocket API
@@ -2841,7 +2865,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub workspace shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
+1
View File
@@ -101,6 +101,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
| `agenthub 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) |
+4 -26
View File
@@ -1004,6 +1004,10 @@ function validateSession(session) {
## Backup Strategies
### Built-in daily backup (Settings → Data → Daily backup)
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there once a day at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
### Online Backup (Recommended)
```sql
@@ -1025,32 +1029,6 @@ cp /var/lib/agenthub/dashboard.db /backups/dashboard_$(date +%Y%m%d).db
systemctl start agenthub
```
### Backup Schedule
```mermaid
graph TB
subgraph "Automated Backups"
Daily[Daily Backup<br/>2 AM UTC]
Weekly[Weekly Backup<br/>Sunday 2 AM]
Monthly[Monthly Backup<br/>1st of month]
end
subgraph "Retention"
Daily --> R7[Keep 7 days]
Weekly --> R4[Keep 4 weeks]
Monthly --> R12[Keep 12 months]
end
subgraph "Storage"
R7 --> Local[Local Disk]
R4 --> S3[AWS S3]
R12 --> Glacier[AWS Glacier]
end
style Daily fill:#3B82F6
style S3 fill:#FF9900
```
---
## Summary
+174 -416
View File
@@ -89,26 +89,19 @@ graph TB
```mermaid
sequenceDiagram
participant Claude as Claude Code
participant HookScript as Hook Script<br/>(Python)
participant Handler as hook-handler.js
participant Server as Dashboard Server
participant DB as SQLite
participant WS as WebSocket
Claude->>HookScript: Execute (stdin: JSON)
HookScript->>HookScript: Read stdin
HookScript->>Handler: exec node hook-handler.js
Handler->>Handler: Parse JSON
Handler->>Server: HTTP POST /hooks/{type}
Server->>DB: Insert/Update data
DB-->>Server: Success
Server->>WS: Broadcast event
WS-->>Server: Sent to clients
Server-->>Handler: 200 OK
Handler-->>HookScript: exit 0
HookScript-->>Claude: exit 0 (non-blocking)
Note over Claude: Continues execution<br/>without waiting
Claude->>Handler: node hook-handler.js <HookType> (stdin: JSON)
Handler->>Handler: Wrap stdin as {hook_type, data}
Handler->>Server: HTTP POST /api/hooks/event (one per dashboard database)
Handler-->>Claude: exit 0 once the body is sent (never waits for the reply)
Server->>DB: Insert/Update (one transaction)
Server->>WS: Broadcast session_updated / agent_updated / new_event …
Note over Claude: Continues as soon as<br/>the handler exits
```
> **Security:** the hook handler POSTs to the loopback dashboard (`127.0.0.1:<port>`).
@@ -130,9 +123,9 @@ graph TB
end
subgraph "Implementation"
Timeout[5s Timeout]
ErrorLog[Silent Error Logging]
Async[Async HTTP POST]
Timeout[2 s request timeout<br/>2.5 s hard exit]
ErrorLog[Always exit 0<br/>errors are dropped]
Async[Fire-and-forget POST<br/>no wait for the reply]
JSON[JSON Serialization]
end
@@ -189,79 +182,58 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
This copies hook scripts from `scripts/hooks/` to `.githooks/`:
For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
```mermaid
graph LR
Source[scripts/hooks/*.py] -->|Copy| Target[.githooks/*.py]
Handler[scripts/hook-handler.js] -->|Reference| Target
Target -->|chmod +x| Executable[Executable Hooks]
style Source fill:#3B82F6
style Executable fill:#10B981
```
### Manual Installation
```bash
#!/bin/bash
# scripts/install-hooks.js
HOOKS_DIR=".githooks"
SOURCE_DIR="scripts/hooks"
# Create hooks directory
mkdir -p "$HOOKS_DIR"
# Copy hook scripts
cp "$SOURCE_DIR/session-start.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/pre-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/post-tool-use.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/subagent-stop.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/notification.py" "$HOOKS_DIR/"
cp "$SOURCE_DIR/session-end.py" "$HOOKS_DIR/"
# Make executable
chmod +x "$HOOKS_DIR"/*.py
echo "Hooks installed in .githooks/"
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" }
]
}
],
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" SessionStart" }
]
}
]
}
}
```
### Verification
```bash
# Check hook files exist
ls -la .githooks/
# Expected output:
# session-start.py
# pre-tool-use.py
# post-tool-use.py
# stop.py
# subagent-stop.py
# notification.py
# session-end.py
# One line per hook type with an AgentHub entry in settings.json
# (with the plugin installed the hooks live in its plugin.json, so this prints nothing)
grep -o 'hook-handler.js[^ ]* [A-Za-z]*' ~/.claude/settings.json
```
The Settings page reports the same (`source: "plugin"` when the plugin supplies them), and `/agenthub-doctor` reports missing or duplicated entries.
---
## Hook Types
### 1. SessionStart
Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
Triggered when a Claude Code session starts. The `source` field distinguishes the trigger: `startup` (fresh launch), `resume` (`--resume`/`--continue`), `clear` (`/clear`), and `fork`, and `compact` — which fires **mid-turn** when auto-compaction kicks in while Claude is actively working, not at a fresh prompt.
**Payload Example:**
```json
{
"type": "sessionStart",
"sessionId": "sess_abc123",
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4",
"timestamp": "2024-03-18T12:00:00Z"
"model": "claude-opus-5-5"
}
```
@@ -281,10 +253,12 @@ Triggered the moment the user hits enter on a prompt — fires *before* Claude d
```json
{
"type": "userPromptSubmit",
"sessionId": "sess_abc123",
"prompt": "Refactor this function...",
"timestamp": "2024-03-18T12:00:30Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "Refactor this function..."
}
```
@@ -302,14 +276,19 @@ Triggered before a tool executes.
```json
{
"type": "preToolUse",
"sessionId": "sess_abc123",
"agentId": "agent_main_001",
"toolName": "bash",
"timestamp": "2024-03-18T12:01:00Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test", "description": "Run the tests" },
"tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv"
}
```
A tool call made inside a subagent also carries `agent_id` and `agent_type`.
**Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; Claude can only call a tool after fresh user input)
- Set agent to `working`, set `current_tool`
@@ -326,40 +305,36 @@ Triggered after a tool completes execution.
```json
{
"type": "postToolUse",
"sessionId": "sess_abc123",
"agentId": "agent_main_001",
"toolName": "bash",
"durationMs": 1234,
"success": true,
"inputTokens": 1500,
"outputTokens": 800,
"timestamp": "2024-03-18T12:01:01.234Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test", "description": "Run the tests" },
"tool_response": { "stdout": "…", "stderr": "", "interrupted": false },
"tool_use_id": "toolu_01AbCdEfGhIjKlMnOpQrStUv",
"duration_ms": 1234
}
```
The payload carries no token counts: tokens are read from the transcript (see [Transcript-derived sync](#transcript-derived-sync)).
**Purpose:**
- Clear `awaiting_input_since` (and `awaiting_reason` — both reset to NULL together; covers permission-prompt approval mid-tool)
- Clear `current_tool` on agent (agent stays `working`)
- Update agent token counts via shared transcript cache
- Calculate and update cost
- Rollup cost to session
- Sync the session's tokens from its transcript, like every event that carries a `transcript_path`
**Cost Calculation Flow:**
**Token and cost flow:** no cost is stored. Tokens land in `token_usage`, one row per session and model bucket, and cost is computed when it is read (`calculateCost`, e.g. `GET /api/pricing/cost`) from those rows and the editable `model_pricing` table.
```mermaid
graph TB
PostHook[PostToolUse Hook] --> Tokens{Has Token<br/>Counts?}
Tokens -->|Yes| Pricing[Fetch Pricing Rule]
Tokens -->|No| Skip[Skip Cost Update]
Pricing --> Calculate[Cost = <br/>input/1M * input_price +<br/>output/1M * output_price]
Calculate --> UpdateAgent[Update agent.cost]
UpdateAgent --> Rollup[Rollup to session.total_cost]
Rollup --> Broadcast[Broadcast Updates]
style Calculate fill:#10B981
style Broadcast fill:#F59E0B
Hook[Any hook with transcript_path] --> Cache[TranscriptCache<br/>reads only the new bytes]
Cache --> Usage[token_usage<br/>per session + model bucket]
Usage --> Read[Cost computed on read<br/>token_usage × model_pricing]
style Usage fill:#10B981
style Read fill:#3B82F6
```
---
@@ -372,10 +347,13 @@ Triggered when Claude finishes a turn (NOT when the session is closed).
```json
{
"type": "stop",
"sessionId": "sess_abc123",
"stop_reason": "end_turn",
"timestamp": "2024-03-18T12:05:00Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"permission_mode": "default",
"hook_event_name": "Stop",
"stop_hook_active": false,
"last_assistant_message": "Done — the tests pass."
}
```
@@ -396,11 +374,16 @@ Triggered when a sub-agent (explore, task, etc.) completes.
```json
{
"type": "subagentStop",
"sessionId": "sess_abc123",
"agentId": "agent_explore_002",
"agentType": "explore",
"timestamp": "2024-03-18T12:03:00Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"permission_mode": "default",
"hook_event_name": "SubagentStop",
"agent_id": "a73b0d3904535a49c",
"agent_type": "Explore",
"agent_transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01/subagents/agent-a73b0d3904535a49c.jsonl",
"stop_hook_active": false,
"last_assistant_message": "Found 3 call sites…"
}
```
@@ -422,14 +405,17 @@ Triggered when Claude Code sends a system notification.
```json
{
"type": "notification",
"sessionId": "sess_abc123",
"notificationType": "backgroundTaskComplete",
"message": "Explore agent completed successfully",
"timestamp": "2024-03-18T12:03:00Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"hook_event_name": "Notification",
"message": "Claude needs your permission to use Bash",
"notification_type": "permission_prompt"
}
```
Other `notification_type` values seen: `idle_prompt`, `agent_completed`, `auth_success`.
**Purpose:**
- Log the event for the activity feed
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
@@ -446,12 +432,16 @@ Triggered when a Claude Code session ends.
```json
{
"type": "sessionEnd",
"sessionId": "sess_abc123",
"timestamp": "2024-03-18T14:30:00Z"
"session_id": "8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01",
"transcript_path": "/home/you/.claude/projects/-home-you-repo/8d1f0c2e-5b7a-4c3e-9f10-2a6b7c8d9e01.jsonl",
"cwd": "/home/you/repo",
"hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"
}
```
`reason` is one of `prompt_input_exit`, `clear`, `resume`, `other`.
**Purpose:**
- Drop `awaiting_input_since` (and `awaiting_reason`, cleared to NULL together) on the session and any agents that still have it
- Mark all agents and the session as `completed` — **unless the session is in `error` AND that error is still unrecovered at the transcript tail** (`isErrorAtTail`: the latest API error has no successful turn after it), in which case `error` is preserved. A transient error the CLI retried past (successful assistant turns after the last error) finalizes as `completed` instead of freezing in a stale `error`
@@ -467,120 +457,29 @@ Triggered when a Claude Code session ends.
```mermaid
graph TB
CLI[CLI Args] --> Parse[Parse Hook Type]
Stdin[stdin] --> ReadJSON[Read JSON]
Parse --> HookType{Hook Type?}
ReadJSON --> Payload[Event Payload]
HookType -->|session-start| Endpoint1[POST /hooks/session-start]
HookType -->|pre-tool-use| Endpoint2[POST /hooks/pre-tool-use]
HookType -->|post-tool-use| Endpoint3[POST /hooks/post-tool-use]
HookType -->|stop| Endpoint4[POST /hooks/stop]
HookType -->|subagent-stop| Endpoint5[POST /hooks/subagent-stop]
HookType -->|notification| Endpoint6[POST /hooks/notification]
HookType -->|session-end| Endpoint7[POST /hooks/session-end]
Payload --> Endpoint1
Payload --> Endpoint2
Payload --> Endpoint3
Payload --> Endpoint4
Payload --> Endpoint5
Payload --> Endpoint6
Payload --> Endpoint7
Endpoint1 --> HTTP[HTTP POST]
Endpoint2 --> HTTP
Endpoint3 --> HTTP
Endpoint4 --> HTTP
Endpoint5 --> HTTP
Endpoint6 --> HTTP
Endpoint7 --> HTTP
HTTP --> Response{Success?}
Response -->|Yes| Exit0[exit 0]
Response -->|No| Exit1[exit 1]
style HTTP fill:#10B981
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
Args[argv: HookType + optional tool] --> Wrap
Stdin[stdin JSON] --> Wrap[Envelope<br/>hook_type, data, tool?, run_id?]
Wrap --> Ports[resolveHookIngestPorts<br/>one port per database]
Ports --> P1[POST /api/hooks/event<br/>dashboard A]
Ports --> P2[POST /api/hooks/event<br/>dashboard B]
P1 --> Flushed[Request body sent]
P2 --> Flushed
Flushed --> Exit[exit 0]
Guard[2.5 s safety timer] --> Exit
style Exit fill:#10B981
```
### Implementation
### What it does
```javascript
#!/usr/bin/env node
// scripts/hook-handler.js
`scripts/hook-handler.js` (about 120 lines) is the whole forwarder:
const http = require('http');
const fs = require('fs');
- **Envelope.** It POSTs `{hook_type, data, tool?, run_id?}` to `/api/hooks/event`. `hook_type` is its first argument; `data` is the parsed stdin, or `{raw: "<stdin>"}` when stdin is not JSON. `tool` is `codex` or `antigravity` when those tools' own hook scripts pass it as the second argument (Claude Code passes none). `run_id` is `AGENTHUB_RUN_ID`, set in every pane the dashboard starts.
- **Fire-and-forget.** It exits once the request body is sent, without waiting for the dashboard's reply, so a busy or wedged dashboard never holds Claude Code at "running hooks".
- **Never fails.** A refused connection is skipped, a request that takes over 2 s is dropped, and a 2.5 s timer exits regardless. The exit code is always 0.
const HOOK_TYPE = process.argv[2];
const SERVER_URL = 'http://localhost:4820';
const TIMEOUT = 5000; // 5s timeout
// Read JSON from stdin
let inputData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => inputData += chunk);
process.stdin.on('end', () => {
try {
const payload = JSON.parse(inputData);
sendToServer(HOOK_TYPE, payload);
} catch (err) {
console.error('[hook-handler] JSON parse error:', err);
process.exit(1);
}
});
function sendToServer(hookType, payload) {
const postData = JSON.stringify(payload);
const options = {
hostname: 'localhost',
port: 4820,
path: `/hooks/${hookType}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: TIMEOUT
};
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => responseData += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
process.exit(0);
} else {
console.error(`[hook-handler] Server error: ${res.statusCode}`);
process.exit(1);
}
});
});
req.on('error', (err) => {
console.error('[hook-handler] Request error:', err);
process.exit(1);
});
req.on('timeout', () => {
console.error('[hook-handler] Request timeout');
req.destroy();
process.exit(1);
});
req.write(postData);
req.end();
}
```
> **Port resolution & fan-out.** The snippet above shows a single fixed `4820`
> for clarity. The real `scripts/hook-handler.js` resolves hook targets at
> runtime via `server/lib/server-info.js`:
> **Port resolution & fan-out.** The handler resolves its targets at runtime
> via `server/lib/server-info.js`:
>
> 1. If `CLAUDE_DASHBOARD_PORT` is set in the environment, the handler treats
> it as an explicit operator override and POSTs to that single port —
@@ -606,38 +505,13 @@ function sendToServer(hookType, payload) {
### Server-Side Hook Processing
```javascript
// server/routes/hooks.js
`POST /api/hooks/event` (`server/routes/hooks.js`, `ingestHook`):
router.post('/session-start', (req, res) => {
try {
const { sessionId, model, agentId, agentType } = req.body;
// Upsert session
let session = stmts.findSession.get(sessionId);
if (!session) {
stmts.createSession.run(sessionId, model);
session = stmts.findSession.get(sessionId);
broadcast({ type: 'session.created', data: session });
}
// Create main agent
if (!stmts.findAgent.get(agentId)) {
stmts.createAgent.run(agentId, sessionId, agentType);
const agent = stmts.findAgent.get(agentId);
broadcast({ type: 'agent.created', data: agent });
}
// Touch session (update updated_at)
stmts.touchSession.run(sessionId);
res.json({ success: true });
} catch (err) {
console.error('session-start error:', err);
res.json({ success: false, error: err.message });
}
});
```
1. **Checks the body** (400 when it is not a hook), maps a Codex or Antigravity payload onto Claude's shape, and answers `{ok: true, ignored: true}` for a hook from an ignored folder and `{ok: true, duplicate: true}` for the same event posted twice within 10 s.
2. **Reads the transcript first** (incremental `TranscriptCache`), so the write transaction never waits on a large file.
3. **Runs `processEvent`** — session and agent upserts, the status transitions described under [Hook Types](#hook-types), the event row (redacted when Redact secrets is on) and the transcript-derived sync below — then binds the session to the workspace pane that ran it (`run_id`).
4. **Broadcasts** the changes over WebSocket: `session_created`, `session_updated`, `agent_created`, `agent_updated`, `new_event`, `workflow_upserted`.
5. **After the reply**, evaluates event-driven alert rules, and a `SubagentStop` starts the subagent JSONL scan.
### Event Processing Pipeline
@@ -691,64 +565,17 @@ Fail-safe guards: the probe reports "no answer" (nothing changes) on Windows, in
### Error Handling Strategy
```mermaid
graph TB
Error[Error Occurs] --> Type{Error Type?}
Type -->|Network Error| Retry[Retry Once]
Type -->|Timeout| Log1[Log + Exit 1]
Type -->|Parse Error| Log2[Log + Exit 1]
Type -->|Server Error| Log3[Log + Exit 1]
Retry --> Success{Success?}
Success -->|Yes| Exit0[Exit 0]
Success -->|No| Exit1[Exit 1]
Log1 --> Exit1
Log2 --> Exit1
Log3 --> Exit1
style Exit0 fill:#10B981
style Exit1 fill:#EF4444
```
The handler has no error path that reaches Claude Code:
### Hook Script Error Handling
| What goes wrong | What the handler does |
|-----------------|-----------------------|
| stdin is not JSON | Forwards it as `data: {raw: "<stdin>"}` |
| No dashboard listening (`ECONNREFUSED`) | Skips that target |
| A dashboard does not take the request within 2 s | Drops it and moves on |
| Anything hangs | The 2.5 s safety timer exits |
| The dashboard rejects or fails the event | Not seen — the handler never reads the reply |
```python
#!/usr/bin/env python3
# .githooks/session-start.py
import sys
import json
import subprocess
import logging
logging.basicConfig(
filename='.githooks/hooks.log',
level=logging.ERROR,
format='%(asctime)s - %(levelname)s - %(message)s'
)
try:
data = json.load(sys.stdin)
result = subprocess.run(
['node', 'scripts/hook-handler.js', 'session-start'],
input=json.dumps(data),
text=True,
timeout=5,
capture_output=True
)
if result.returncode != 0:
logging.error(f'Hook handler failed: {result.stderr}')
except Exception as e:
logging.error(f'Hook error: {str(e)}')
# Always exit 0 to avoid blocking Claude Code
sys.exit(0)
```
It always exits 0, so Claude Code never shows a hook error and never waits on AgentHub. Events sent while no dashboard was running are not replayed; the transcript sync and the [liveness reap](#missed-sessionend-dashboard-down--liveness-reap) bring the session up to date later.
---
@@ -756,55 +583,14 @@ sys.exit(0)
### Hook Execution Time
```mermaid
graph TB
subgraph "Hook Execution Breakdown"
Python[Python Script<br/>~10ms]
Handler[Node Handler<br/>~20ms]
HTTP[HTTP POST<br/>~30ms]
DB[Database Write<br/>~5ms]
WS[WebSocket Broadcast<br/>~5ms]
end
Total[Total: ~70ms]
Python --> Handler
Handler --> HTTP
HTTP --> DB
DB --> WS
WS --> Total
style Total fill:#10B981
```
Claude Code waits only for the handler process: Node start-up, reading stdin, sending the request. Measured on a 4-core desktop: **60–90 ms per hook**, almost all of it Node start-up. The dashboard's work happens after the handler has exited — ingesting a hook took ~2 ms (p50) against a 1.25 GB database at 10 hooks/s, and stayed under 40 ms (p99) at 100 hooks/s with 10 browsers open.
**Performance Targets:**
### What keeps it fast
| Phase | Target | Actual |
|-------|--------|--------|
| Hook script | < 20ms | ~10ms |
| Handler | < 30ms | ~20ms |
| HTTP POST | < 50ms | ~30ms |
| Database | < 10ms | ~5ms |
| **Total** | **< 100ms** | **~70ms** |
### Optimization Techniques
```mermaid
graph TB
subgraph "Optimizations"
Async[Async HTTP<br/>Don't wait for response]
Batch[Batch Updates<br/>Transaction batching]
Index[Database Indexes<br/>Fast lookups]
Pool[Connection Pooling<br/>Reuse connections]
end
Async --> Faster[Faster Hook Execution]
Batch --> Faster
Index --> Faster
Pool --> Faster
style Faster fill:#10B981
```
- **Fire-and-forget POST** — no wait for the dashboard's reply.
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
---
@@ -812,49 +598,27 @@ graph TB
### Manual Testing
Pipe a payload into the handler. It posts to every running dashboard, so use a session id you will recognise:
```bash
# Test session-start hook
echo '{"type":"sessionStart","sessionId":"test_001","model":"claude-sonnet-4","agentId":"agent_test","agentType":"general-purpose"}' | \
python3 .githooks/session-start.py
echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
| node scripts/hook-handler.js SessionStart
# Test pre-tool-use hook
echo '{"type":"preToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash"}' | \
python3 .githooks/pre-tool-use.py
echo '{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"tool_use_id":"toolu_test_1"}' \
| node scripts/hook-handler.js PreToolUse
```
# Test post-tool-use hook
echo '{"type":"postToolUse","sessionId":"test_001","agentId":"agent_test","toolName":"bash","durationMs":100,"success":true,"inputTokens":1000,"outputTokens":500}' | \
python3 .githooks/post-tool-use.py
The handler prints nothing. To see the dashboard's answer, POST the envelope yourself:
```bash
curl -s -X POST http://localhost:4820/api/hooks/event \
-H 'Content-Type: application/json' \
-d '{"hook_type":"Stop","data":{"session_id":"hook-test-001","cwd":"/tmp","hook_event_name":"Stop"}}'
```
### Integration Testing
```javascript
// server/__tests__/hooks.test.js
import { test } from 'node:test';
import assert from 'node:assert';
test('session-start hook creates session', async () => {
const payload = {
sessionId: 'test_session',
model: 'claude-sonnet-4',
agentId: 'test_agent',
agentType: 'general-purpose'
};
const response = await fetch('http://localhost:4820/hooks/session-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
assert.strictEqual(data.success, true);
// Verify session exists
const session = await fetch('http://localhost:4820/api/sessions/test_session');
assert.strictEqual(session.status, 200);
});
```
The server suite drives `POST /api/hooks/event` end to end — for example `server/__tests__/api.test.js` and `server/__tests__/awaiting-subagent-guard.test.js`; Codex and Antigravity hooks have `codex-hook-route.test.js` and `antigravity-hook-route.test.js`. Run them with `npm run test:server`, never a bare `node --test` from a workspace pane (some suites start and kill tmux sessions).
---
@@ -864,29 +628,23 @@ test('session-start hook creates session', async () => {
| Issue | Symptoms | Solution |
|-------|----------|----------|
| Hooks not executing | No data in dashboard | Check `.githooks/` exists and scripts are executable |
| Timeout errors | Hooks take >5s | Check server is running, reduce timeout |
| Parse errors | JSON parse failed | Validate hook payload format |
| Permission denied | Hook script won't run | `chmod +x .githooks/*.py` |
| Server connection refused | HTTP POST fails | Start dashboard server (`npm start`) |
| Hooks not installed | No data in dashboard | `grep hook-handler.js ~/.claude/settings.json` (or check the plugin is enabled), then `npm run install-hooks` or `/agenthub-doctor` |
| Hooks installed twice | Every event doubled | Plugin and settings.json both carry them — see [Installation Script](#installation-script) |
| Dashboard not running | Hooks run, nothing arrives | `agenthub start` (or `npm start` from a checkout); events sent meanwhile are not replayed, the transcript sync fills the session in |
| Dashboard on another port | Nothing arrives | The handler finds servers through `~/.claude/.agenthub.json`; set `CLAUDE_DASHBOARD_PORT` to force one |
| Hooks installed from a container | `MODULE_NOT_FOUND` in Claude Code | Install on the host — see the container note under [Installation Script](#installation-script) |
### Debug Mode
### Seeing what a hook sends
```bash
# Enable hook logging
export DASHBOARD_DEBUG=1
# Run hook manually with verbose output
python3 -u .githooks/session-start.py < test-payload.json
```
The handler logs nothing by design. POST the envelope yourself (see [Manual Testing](#manual-testing)) and read the reply.
### Health Check
```bash
# Check server is running
curl http://localhost:4820/api/sessions
curl http://localhost:4820/api/health
# Expected: {"sessions": [...]}
# Expected: {"status":"ok","version":"…","timestamp":"…"}
```
---
+6 -5
View File
@@ -391,9 +391,10 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **Chat view of a live pane** — with **Settings → Appearance → Chat UI** on (`ChatUiSettings.tsx`, one choice for every pane, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`, `client/src/lib/chatUi.ts`, so each user of a shared dashboard keeps their own on any browser), a Claude or Codex pane whose session is known shows a chat instead of its terminal (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls and thinking folded into one line named by what it did ("Ran 3 commands, edited a file ›", `workLabel`) that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. A permission prompt or a menu still needs the terminal — turn Chat UI off to answer it. The ended-session replay and the Analyst page's conversation use the same layout.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
@@ -407,9 +408,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
@@ -693,7 +694,7 @@ reads the workspace's git surface in five modes:
user presses Enter in the pane; with no pane of the workspace open the box
says so and keeps the text.
In the file editor, a text selection shows Cursor's **Add to Chat** chip
above it, which pastes `@<path>#L<from>-<to>` (Claude Code's line-range
at the end of its first line (inline, covering no code), which pastes `@<path>#L<from>-<to>` (Claude Code's line-range
mention; `#L<n>` for one line — without the `L` Claude Code attaches the
whole file) the same way. Other tools (Codex, …) get the same text as a
plain hint.
@@ -1809,7 +1810,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/<id>`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
+1 -1
View File
@@ -61,7 +61,7 @@ Internal build — all rights reserved.
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files (picked with + or dragged in from the OS)
Instructions, Memory, Context files (picked with + or dragged in from the OS; each is a copy taken at upload, so edit the original and add it again — a Folder is read live)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
+6
View File
@@ -432,6 +432,12 @@ page warns about it.
### Uninstalling
Run `agenthub uninstall --yes` (or Settings → System → **Uninstall AgentHub**):
it does everything below, and more, in the right order — see
[CLI.md](CLI.md) for the exact list and what it keeps. Without `--yes` it only
lists what it would remove. The rest of this section is what that command
automates, for a machine where the CLI no longer runs.
`claude plugin uninstall agenthub@agenthub` only removes
the plugin from the enabled list — verified against a real install/uninstall,
not assumed. It does **not**:
+42 -22
View File
@@ -13,7 +13,7 @@ Local mode does not change at all. The login code stays dormant until
`AGENTHUB_MODE=server` is set.
> **What server mode separates, and what it does not.** In the dashboard and
> its API, each member sees only their own workspaces and the sessions,
> its API, each developer sees only their own workspaces and the sessions,
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
> Every terminal pane, though, still runs as the one Linux account that runs
> the dashboard. An agent in a pane can therefore read every user's folders,
@@ -36,8 +36,8 @@ agenthub server setup --hostname agenthub.example.com --admin boss
systemctl --user restart agenthub # or: agenthub start
# 3. Add the other people
agenthub users add alice # member
agenthub users add carol --role analyst # read-only
agenthub users add alice # developer
agenthub users add carol --role analyst # reads everyone, works in their own
```
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
@@ -86,15 +86,35 @@ The login cookie gets `Secure` when the request came over HTTPS
| Role | Can |
| --- | --- |
| `admin` | Everything, including the host's own configuration and every user's data |
| `member` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
| `analyst` | Reads everything (usage, prompts, every user's sessions); no terminal, no write |
| `analyst` | Reads everything (usage, prompts, every user's sessions), and works in their OWN workspaces like a developer. Never changes another user's rows or the host's configuration |
| `developer` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
`developer` was called `member` before; a database with members is migrated on
start (`server/db.js`), and `member` is no longer accepted.
"The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
`/budgets`, `/webhooks`, `/remote-sources` and `/reports`
(`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A member who tries one gets
`403 EADMIN`. An analyst who tries any write gets `403 EREADONLY`.
`/budgets`, `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A developer
or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only an admin
can do this". An analyst's other writes are scoped like a developer's: one
naming another user's row gets `404 ENOTFOUND` (`memberId` in
`server/lib/scope.js` limits every non-GET request of an analyst to their own
rows).
The UI hides what a role cannot use (`usePermissions` in
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
no Data, Pricing, AI proxy or per-tool pages, no scheduled reports or git
credentials, no start-at-login/icon/Quit/Uninstall on System, no alert
rules or channels, no install/update/enable on AI tools, no pet upload or
delete, and no update check. An analyst's Workspace page and sidebar list only
their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
hidden for a session that is not theirs. An analyst's alert feed
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
only theirs.
## What each user sees
@@ -104,34 +124,34 @@ Workspaces made before server mode, or by the host account, have none.
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
snapshots and workflow runs belong to that session.
- A **member** sees only rows of their own workspaces, everywhere: lists,
- A **developer** sees only rows of their own workspaces, everywhere: lists,
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
WebSocket updates and terminals. A request naming someone else's row (in the
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
that does not exist (`scopeGuard`, `server/lib/scope.js`). Sessions with no
workspace, such as ones started in a terminal outside AgentHub, are not shown
to members.
to developers.
- An **admin** or **analyst** sees everything. Each time one of them opens a
session a member owns (its page, transcript, events or cost), the read is
session a developer owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
Users page in the sidebar shows it, with the audit log under it.
A member also cannot open the host's own configuration, or any view across all
A developer also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
imports, data export, alert rules and `/api/users`. Those return
`403 EADMIN`, and Settings hides them for members.
`403 EADMIN`, and Settings hides them.
### Keep members' folders apart
### Keep developers' folders apart
```bash
# in ~/.config/agenthub/settings.env
AGENTHUB_USERS_ROOT=/srv/agenthub/users
```
With this set, a member can create a workspace only inside
With this set, a developer or analyst can create a workspace only inside
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. A folder
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
@@ -151,10 +171,10 @@ apart, not agents.
`403 EBADORIGIN`. This is on top of the `SameSite=Strict` cookie, which a
sibling subdomain would still satisfy.
- WebSockets need a login, and an `Origin` that is the request's own allowed
Host. A terminal (`/ws-pty/*`) is refused to an analyst and to anyone but the
owner of the pane's workspace. Open sockets are re-checked: `/ws` every 30 s,
Host. A terminal (`/ws-pty/*`) is refused to anyone but an admin and the
owner of the pane's workspace (an analyst included). Open sockets are re-checked: `/ws` every 30 s,
terminals every 10 s (`AGENTHUB_PTY_RECHECK_MS`). A logout, a disable, a
password change, or a demotion to analyst closes them.
password change, or losing the pane's workspace closes them.
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
change and disabling the user each end it.
@@ -170,12 +190,12 @@ apart, not agents.
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
A workspace with no owner has no such user. Give old workspaces an owner with
`agenthub users assign <workspace id> <user>`.
- Named locks (`agenthub lock …`) are per user: a member's lock names live in
- Named locks (`agenthub lock …`) are per user: a developer's lock names live in
their own namespace, so one user's agents never see or take another's.
- Browser push subscriptions belong to the user who made them. A
notification goes only to that user's own browsers, never to the host's
desktop.
- A member cannot create a session by hand (`POST /api/sessions` → `403`), and
- A developer or analyst cannot create a session by hand (`POST /api/sessions` → `403`), and
cannot change a workspace's pane or session columns: `PATCH` accepts only
`title`, `pipeline`, `notes`, `links` and `branch` (`403 EFIELD`).
Pet uploads are admin-only.
@@ -196,13 +216,13 @@ The same from the host's shell:
```bash
agenthub users list
agenthub users add <name> [--role admin|analyst|member]
agenthub users add <name> [--role admin|analyst|developer]
agenthub users passwd <name> # ends that user's logins
agenthub users role <name> <role>
agenthub users disable <name> # ends that user's logins; users are never deleted
agenthub users enable <name>
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
agenthub users audit [--limit N] # who read which member's session
agenthub users audit [--limit N] # who read which developer's session
agenthub server status
```
+36
View File
@@ -0,0 +1,36 @@
# Roles: admin / analyst / developer — checklist
Decisions (user, 2026-10-08):
- `member` is renamed `developer` everywhere, stored value included (migration).
- developer: full control of OWN workspaces/sessions/git; nothing host-wide.
- analyst: sees every user's rows (as today) AND works on OWN rows like a
developer (was: read-only); nothing host-wide, never another user's rows.
- UI hides every control the role cannot use; refusals show a clear toast.
## Server
- [x] db.js: rebuild `users` with CHECK(admin, analyst, developer), member→developer
- [x] auth.js: ROLES, default role, STAFF_ONLY gate, analyst no longer EREADONLY
- [x] auth.js: `/alerts/rules` joins ADMIN_WRITE_PREFIXES (host-wide rules)
- [x] scope.js: `memberId` = developer always, analyst on writes only
- [x] websocket.js: analyst terminal only on own runs; broadcast member→developer
- [x] audit.js, users.js, workflow-stats.js, openapi-extra/auth.js, CLI users
- [x] tests: update member→developer, add analyst write-scope tests
## Client
- [x] one role helper (isAdmin / canSeeAll) from useAuth
- [x] hide admin-only controls (inventory from Explore)
- [x] analyst: hide write controls on other users' rows
- [x] errors: EADMIN / EREADONLY / ENOTFOUND toasts translated (en + vi)
- [x] role label "Developer" en/vi, Users page role select
## Finish
- [x] npm run test:server, npm run test:client
- [x] docs: SERVER-MODE.md, CLAUDE.md lines naming `member`
- [x] headers check
## Follow-ups (2026-10-08)
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)