Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 | |||
| 229002cc2b | |||
| 27923db4e0 | |||
| 5ab674f134 | |||
| 00a2aedb09 | |||
| 9128352d1b | |||
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 | |||
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 | |||
| b7e81af0b6 | |||
| c90dd7b147 | |||
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 | |||
| 3ed88062d6 | |||
| 27313d4413 | |||
| d4b949a6f4 | |||
| 8930865376 | |||
| 29858ddccb | |||
| 7000a9c5ce | |||
| 99e718ad91 | |||
| 67c948bbe2 | |||
| 8b8582f231 | |||
| b868a2fa85 | |||
| 30bc6debde | |||
| d4052ed235 | |||
| aca35dd4cd | |||
| cc85e35439 | |||
| eb63569324 | |||
| a146284087 | |||
| 61cc37ba32 |
@@ -1,7 +1,7 @@
|
||||
# Optional dev-container image for AgentHub.
|
||||
# Node 22 to match the production Dockerfile and the project's engines.
|
||||
# Node 22 to match the project's engines.
|
||||
# Adds the native-addon toolchain (better-sqlite3 builds via node-gyp) plus
|
||||
# Python 3 (statusline.py and helper scripts) and the sqlite3 CLI.
|
||||
# Python 3 (scripts/expand-ts-module-docs.py) and the sqlite3 CLI.
|
||||
#
|
||||
# This image is ONLY used by Dev Containers / Codespaces. Host-based development
|
||||
# (npm run dev / npm start) is unaffected.
|
||||
|
||||
@@ -22,13 +22,13 @@ dependencies (`npm run setup`).
|
||||
|
||||
| Component | Detail |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` |
|
||||
| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles |
|
||||
| Python | `python3` / `python` for `statusline.py` and helper scripts |
|
||||
| Python | `python3` / `python` for `scripts/expand-ts-module-docs.py` |
|
||||
| sqlite3 CLI | inspect the dashboard DB during development |
|
||||
| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) |
|
||||
| Features | GitHub CLI |
|
||||
| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, YAML, Tailwind |
|
||||
|
||||
## Everyday commands
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Used only when you choose "Dev Containers: Reopen in Container" (VS Code) or
|
||||
// open the repo in a GitHub Codespace. It changes nothing for host-based dev.
|
||||
//
|
||||
// Covers the full project: the Express server (4820), the React/Vite client
|
||||
// (5173) and the VS Code extension. The native `better-sqlite3`
|
||||
// addon builds here (build-essential + python3 are installed in the Dockerfile).
|
||||
// Covers the full project: the Express server (4820) and the React/Vite
|
||||
// client (5173). The native `better-sqlite3` addon builds here
|
||||
// (build-essential + python3 are installed in the Dockerfile).
|
||||
//
|
||||
// NOTE (issue #193): Claude Code hooks are a HOST-side concern. This container
|
||||
// intentionally does NOT bind-mount ~/.claude and does NOT install hooks — the
|
||||
@@ -20,10 +20,7 @@
|
||||
},
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
// Lets you build/run the project's own production Dockerfile + docker-compose
|
||||
// from inside the dev container (e.g. to reproduce issue #193 deliberately).
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
// Server (API + WebSocket) and the Vite client dev server. VS Code forwards
|
||||
@@ -40,7 +37,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Install root + client + vscode-extension deps.
|
||||
// Install root + client deps.
|
||||
// Never installs Claude Code hooks (host-only — see note above).
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"waitFor": "postCreateCommand",
|
||||
@@ -59,7 +56,6 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"vitest.explorer",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"redhat.vscode-yaml",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"ms-python.python",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…"
|
||||
echo "▶ Installing server + client dependencies (npm run setup)…"
|
||||
npm run setup
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
data/
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
!README.md
|
||||
images/
|
||||
wiki/
|
||||
index.html
|
||||
og-image.svg
|
||||
favicon.svg
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
@@ -13,13 +13,3 @@
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.db binary
|
||||
|
||||
# ── GitHub Linguist overrides — keep language stats honest ──────────────────
|
||||
# wiki/mermaid.min.js is a vendored, minified third-party library (Mermaid);
|
||||
# it is not project source code and must not count toward language stats.
|
||||
wiki/mermaid.min.js linguist-vendored
|
||||
|
||||
# wiki/i18n-content.js is machine-assembled translation data (see its header:
|
||||
# "AUTO-GENERATED wiki body-content translations ... Do not hand-edit").
|
||||
# It is a data bundle keyed by English innerHTML, not hand-written JavaScript.
|
||||
wiki/i18n-content.js linguist-generated
|
||||
|
||||
@@ -9,17 +9,5 @@ package-lock.json
|
||||
client/package-lock.json
|
||||
.vs/
|
||||
*.md
|
||||
index.html
|
||||
wiki/index.html
|
||||
*.yml
|
||||
*.yaml
|
||||
desktop/out/
|
||||
desktop/release/
|
||||
desktop/node_modules/
|
||||
desktop/package-lock.json
|
||||
desktop/assets/*.svg
|
||||
desktop/assets/*.png
|
||||
desktop/assets/*.icns
|
||||
desktop/assets/icon.iconset/
|
||||
wiki/mermaid.min.js
|
||||
fonts/*.woff2
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Superpowers Workspace
|
||||
|
||||
This directory contains project-specific configuration and artifacts for the Superpowers workflow.
|
||||
|
||||
It acts as the working memory layer for agent-driven development, enabling structured planning, execution, and iteration across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Superpowers transforms coding agents into structured, process-driven collaborators. Instead of jumping straight into code, the agent:
|
||||
|
||||
1. Clarifies intent
|
||||
2. Produces a design
|
||||
3. Breaks work into executable steps
|
||||
4. Executes via subagents
|
||||
5. Verifies and iterates
|
||||
|
||||
This directory stores the outputs of that workflow so progress is persistent, inspectable, and reproducible.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Typical contents may include:
|
||||
|
||||
```
|
||||
.superpowers/
|
||||
├── brainstorm/ # Design explorations and refined specs
|
||||
├── plans/ # Task breakdowns and execution plans
|
||||
├── reviews/ # Code review outputs and feedback
|
||||
├── runs/ # Execution logs or agent traces
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
> Exact structure may evolve depending on which skills are triggered.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
Superpowers operates through automatic skill activation:
|
||||
|
||||
### 1. Brainstorming
|
||||
|
||||
* Refines vague ideas into concrete specs
|
||||
* Explores alternatives
|
||||
* Produces structured, reviewable design docs
|
||||
|
||||
### 2. Planning
|
||||
|
||||
* Converts approved designs into granular tasks
|
||||
* Each task is:
|
||||
|
||||
* Small (2–5 min)
|
||||
* Explicit (exact file paths + code)
|
||||
* Verifiable
|
||||
|
||||
### 3. Execution
|
||||
|
||||
* Tasks are executed by subagents
|
||||
* Includes:
|
||||
|
||||
* Spec compliance checks
|
||||
* Code quality review
|
||||
* Can run sequentially or in parallel
|
||||
|
||||
### 4. Verification
|
||||
|
||||
* Enforces test-first development (TDD)
|
||||
* Ensures correctness before completion
|
||||
* Prevents silent regressions
|
||||
|
||||
### 5. Completion
|
||||
|
||||
* Validates final state
|
||||
* Offers merge / PR / discard options
|
||||
* Cleans up working branches
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
* **Test-first development** (RED → GREEN → REFACTOR)
|
||||
* **Small, deterministic tasks**
|
||||
* **Explicit over implicit**
|
||||
* **Process over intuition**
|
||||
* **Verification over assumption**
|
||||
|
||||
---
|
||||
|
||||
## How to Use
|
||||
|
||||
You don’t interact with this directory directly most of the time.
|
||||
|
||||
Instead:
|
||||
|
||||
* Start a task in your coding agent (Claude, Cursor, etc.)
|
||||
* Let Superpowers skills activate automatically
|
||||
* Review outputs when prompted (designs, plans, reviews)
|
||||
|
||||
Artifacts will be written here as the workflow progresses.
|
||||
|
||||
---
|
||||
|
||||
## When to Look Here
|
||||
|
||||
Check this directory when you want to:
|
||||
|
||||
* Review the current plan
|
||||
* Inspect prior design decisions
|
||||
* Debug agent behavior
|
||||
* Resume interrupted work
|
||||
* Audit what was executed
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
* Files here are **source-of-truth for agent state**
|
||||
* Safe to commit (recommended for team workflows)
|
||||
* Avoid manual edits unless you understand the workflow implications
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
* Project root `CLAUDE.md` → global agent context
|
||||
* `.claude/` → rules, skills, and subagents
|
||||
* Superpowers upstream docs →
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"reason":"idle timeout","timestamp":1774536800234}
|
||||
@@ -1 +0,0 @@
|
||||
1542
|
||||
+97
-228
@@ -39,8 +39,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
@@ -63,11 +61,10 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||
- [Browser Notification System](#browser-notification-system)
|
||||
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
||||
- [Sagi Companion Subsystem](#sagi-companion-subsystem)
|
||||
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Performance Characteristics](#performance-characteristics)
|
||||
- [Deployment Modes](#deployment-modes)
|
||||
- [Statusline Utility](#statusline-utility)
|
||||
- [Model Name Formatting](#model-name-formatting-client)
|
||||
- [Technology Choices](#technology-choices)
|
||||
- [Build & Run Targets](#build--run-targets)
|
||||
|
||||
@@ -347,7 +344,9 @@ graph TD
|
||||
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
||||
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
|
||||
@@ -355,7 +354,7 @@ graph TD
|
||||
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
|
||||
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. |
|
||||
@@ -473,57 +472,37 @@ graph TD
|
||||
|
||||
### Self-hosted assets (no external CDN)
|
||||
|
||||
Nothing the dashboard or docs render is fetched from a third-party CDN at runtime — all fonts and scripts are served locally, so every surface works fully offline and leaks nothing to external hosts.
|
||||
Nothing the dashboard renders is fetched from a third-party CDN at runtime — fonts are served locally, so the UI works fully offline and leaks nothing to external hosts.
|
||||
|
||||
- **React app fonts** — Inter + JetBrains Mono are imported from `@fontsource` (latin subset) in `client/src/main.tsx`. Vite bundles the per-weight WOFF2 into `client/dist/assets/` with content hashes at build time; there is no Google Fonts `<link>`. Importing the `latin-*` subset entry points keeps the emitted set to one WOFF2 per weight.
|
||||
- **Static pages (landing + wiki)** — load a self-hosted `@font-face` sheet at the repo-root `fonts/` directory (`fonts/fonts.css` + the `*.woff2` files). The root `index.html` references `fonts/fonts.css`; the wiki references `../fonts/fonts.css` (relative paths resolve under GitHub Pages).
|
||||
- **Wiki Mermaid** — vendored as `wiki/mermaid.min.js` (the genuine minified `mermaid@10.9.6` from npm, with a provenance banner; `.prettierignore`d) and loaded via a local `<script>` instead of `cdn.jsdelivr.net`.
|
||||
- **VS Code extension** — the inline `getErrorHtml()` error page dropped its Google Fonts loader for a system font stack (no bundler / local font path available in that webview).
|
||||
|
||||
Net effect: no `fonts.googleapis.com`, `fonts.gstatic.com`, or `cdn.jsdelivr.net` requests anywhere (verified by `git grep`).
|
||||
Net effect: no `fonts.googleapis.com` or `fonts.gstatic.com` requests anywhere (verified by `git grep`).
|
||||
|
||||
### PWA Architecture
|
||||
|
||||
The project ships three independent Progressive Web Apps. Each has its own Web App Manifest and Service Worker, so the browser treats them as separate installable applications with isolated caches.
|
||||
The dashboard ships as a Progressive Web App with its own Web App Manifest and
|
||||
Service Worker (`client/public/manifest.json` + `client/public/sw.js`), so the
|
||||
browser treats it as an installable application with its own cache.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PWA Surface Map │
|
||||
├──────────────────┬──────────────────┬───────────────────────────┤
|
||||
│ Dashboard │ Landing Page │ Wiki │
|
||||
│ (client/) │ (root) │ (wiki/) │
|
||||
├──────────────────┼──────────────────┼───────────────────────────┤
|
||||
│ manifest.json │ manifest.json │ manifest.json │
|
||||
│ sw.js │ sw.js │ sw.js │
|
||||
│ id: dashboard │ id: landing │ id: wiki │
|
||||
├──────────────────┼──────────────────┼───────────────────────────┤
|
||||
│ Precache: │ Precache: │ Precache: │
|
||||
│ /, manifest, │ index.html, │ index.html, style.css, │
|
||||
│ favicon.svg │ favicon, og-img, │ script.js, manifest, │
|
||||
│ │ manifest │ favicon │
|
||||
│ Runtime cache: │ Runtime cache: │ Runtime cache: │
|
||||
│ JS/CSS bundles │ screenshot PNGs │ (all precached) │
|
||||
│ (cache-first) │ (cache-first) │ │
|
||||
│ │ │ │
|
||||
│ Skip: /api/*, │ N/A │ N/A │
|
||||
│ /ws, __vite │ │ │
|
||||
│ │ │ │
|
||||
│ + Push notifs │ │ │
|
||||
│ (VAPID pipeline) │ │ │
|
||||
└──────────────────┴──────────────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
**Service Worker lifecycle (all three):**
|
||||
**Service Worker lifecycle:**
|
||||
|
||||
1. **Install** → `skipWaiting()` — new SW activates immediately, no waiting for tabs to close.
|
||||
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`: `dashboard-v1`, `landing-v1`, `wiki-v1`). Bump the version string to force a cache bust.
|
||||
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`, e.g. `dashboard-v2`). Bump the version string to force a cache bust.
|
||||
3. **Fetch** → Navigation requests are network-first with offline fallback to cached HTML. Static assets are cache-first with runtime caching on miss.
|
||||
|
||||
**Dashboard SW specifics:** The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data and development tooling are never cached. Only responses with `response.type === "basic"` (same-origin) are stored. The existing push notification handlers (`push`, `notificationclick`) are preserved alongside the caching logic.
|
||||
The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data
|
||||
and development tooling are never cached. Only responses with
|
||||
`response.type === "basic"` (same-origin) are stored. The push notification
|
||||
handlers (`push`, `notificationclick`) live alongside the caching logic — their
|
||||
payload icon is the same-origin `/favicon.svg` (see `server/lib/push.js`).
|
||||
|
||||
**Manifest icons:** All three manifests reference `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` — supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries per manifest: one with `purpose: "any"` and one with `purpose: "maskable"`.
|
||||
**Manifest icons:** `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` —
|
||||
supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries: one with
|
||||
`purpose: "any"` and one with `purpose: "maskable"`.
|
||||
|
||||
**iOS meta tags:** All HTML files include `<meta name="apple-mobile-web-app-capable" content="yes">` and `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">` for standalone home-screen mode on Safari.
|
||||
**iOS meta tags:** `client/index.html` includes
|
||||
`<meta name="apple-mobile-web-app-capable" content="yes">` and
|
||||
`<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`
|
||||
for standalone home-screen mode on Safari.
|
||||
|
||||
### Client Module Graph
|
||||
|
||||
@@ -1056,7 +1035,7 @@ flowchart TD
|
||||
- Uses Node.js `http` module directly — no dependencies.
|
||||
- Resolution order is **env override → discovery file → default**:
|
||||
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
|
||||
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when Docker and `npm run dev` share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
|
||||
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
|
||||
- If neither yields anything, the handler falls back to `4820`.
|
||||
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
|
||||
|
||||
@@ -1984,15 +1963,26 @@ These behaviors are reinforced by explicit `Cache-Control` headers from the prod
|
||||
|
||||
## Update Notifier Subsystem
|
||||
|
||||
The Update Notifier is a **detection-only** subsystem that tells the user when the dashboard's git checkout is behind its tracked upstream branch. It never mutates the checkout or restarts the server — those actions are intentionally left to the user in a terminal, because a process cannot reliably replace itself without an external supervisor.
|
||||
The Update Notifier tells the user when a newer **release** of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click.
|
||||
|
||||
The version signal is the forge's own release list, read over REST at `/api/v1/repos/<owner>/<repo>/releases/latest` (no hardcoded host, no credentials for a public repo). The owner and repo come from one of **two** sources, chosen by install kind: a checkout derives them from its `origin` remote; a plugin-cache install derives them from the marketplace manifest on disk (`pluginMarketplaceUrl`), because it has no remote to read — and often no `.git` at all, since a marketplace entry with `"source": "url"` (this project's own) makes Claude Code unpack an archive rather than clone. The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look".
|
||||
|
||||
Two states the release model makes visible that a commit count cannot:
|
||||
|
||||
- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date".
|
||||
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
|
||||
|
||||
A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used.
|
||||
### Module Layout
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Server
|
||||
LIB["update-check.js<br/>getUpdatesStatus"]
|
||||
LIB["update-check.js<br/>getUpdatesStatus + applyUpdate<br/>forge releases API"]
|
||||
SCHED["update-scheduler.js<br/>startUpdateScheduler"]
|
||||
ROUTE["routes/updates.js<br/>GET status, POST check"]
|
||||
ROUTE["routes/updates.js<br/>GET status, POST check, POST apply"]
|
||||
PLUG["lib/plugin-update.js<br/>schedulePluginUpdate"]
|
||||
HELPER["scripts/plugin-update-helper.js<br/>detached: claude plugin update<br/>then plugin-bootstrap --force"]
|
||||
WS["websocket.js<br/>broadcast update_status"]
|
||||
end
|
||||
|
||||
@@ -2005,6 +1995,9 @@ graph TD
|
||||
|
||||
SCHED -->|tick every 5 min| LIB
|
||||
ROUTE -->|on request| LIB
|
||||
ROUTE -->|apply, plugin install| PLUG
|
||||
PLUG -->|spawn detached| HELPER
|
||||
HELPER -->|verdict file| LIB
|
||||
SCHED -->|fingerprint changed| WS
|
||||
ROUTE -->|on POST check| WS
|
||||
WS -->|update_status frame| API
|
||||
@@ -2064,11 +2057,12 @@ sequenceDiagram
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`server/lib/update-check.js`** | Pure function `getUpdatesStatus(root?, { skipFetch? })`. Runs every git call via `execFile` (no shell, 10s–120s timeouts). **Branch- and fork-aware:** prefers `upstream` over `origin` when both exist (standard fork convention), resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation: `git pull --ff-only` only when the local branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks (local branch name matches canonical but tracks a different remote); `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. Adds `situation`, `situation_note`, `canonical_remote`, `current_branch`, `tracking_upstream`, and `tracks_canonical` to the response. |
|
||||
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
||||
| **`server/routes/updates.js`** | Two endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast). Mounted under `/api/*`, so they ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. There is **no** `POST /apply` route. |
|
||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps `dismissedSha` in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||
| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. |
|
||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||
| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
||||
|
||||
### Payload Shape
|
||||
|
||||
@@ -2083,6 +2077,8 @@ interface UpdateStatusPayload {
|
||||
tracking_upstream?: string | null; // e.g. "origin/feature/foo", null if no upstream
|
||||
tracks_canonical?: boolean; // true when branch upstream === remote_ref
|
||||
situation?: // categorical hint for the UI
|
||||
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
||||
| "release_available" // fast-forward onto the release tag
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
@@ -2094,34 +2090,73 @@ interface UpdateStatusPayload {
|
||||
manual_command?: string | null; // shaped for the user's situation
|
||||
message?: string | null;
|
||||
fetch_error?: string; // set when git fetch fails
|
||||
|
||||
// ── Release layer ──
|
||||
install_kind?: "plugin" | "checkout";
|
||||
plugin_marketplace?: string | null; // "sg" — parsed from the cache path
|
||||
plugin_name?: string | null; // "agenthub" — same source
|
||||
last_plugin_update?: { // verdict of the previous chain, if any
|
||||
ok: boolean;
|
||||
step: string; // "validate" | "resolve_claude" | ...
|
||||
error?: string;
|
||||
note?: string;
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
local_version?: string | null; // this install's package.json version
|
||||
shallow_clone?: boolean; // true => every commits_* below is null
|
||||
release_check?: string; // "ok" | "no_remote" | "remote_not_http"
|
||||
// | "no_releases" | "unreachable"
|
||||
// | "bad_payload" | "http_<status>"
|
||||
latest_release?: string | null; // tag, e.g. "v1.5.0"
|
||||
latest_release_name?: string | null;
|
||||
latest_release_url?: string | null;
|
||||
latest_release_published_at?: string | null;
|
||||
latest_release_notes?: string | null; // truncated server-side to 4000 chars
|
||||
release_sha?: string | null; // commit the tag resolves to locally
|
||||
commits_behind_release?: number | null;
|
||||
commits_ahead_of_release?: number | null;
|
||||
dev_build?: boolean; // ahead of the newest release, missing nothing
|
||||
}
|
||||
```
|
||||
|
||||
The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message.
|
||||
The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message. `POST /apply` returns it plus `applied: boolean`; in plugin mode it also carries `mode: "plugin"` and `runtime_dir`, and on a 409 a `reason` of `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. `update_available` and `dev_build` are never both true.
|
||||
|
||||
### Failure Mode Matrix
|
||||
|
||||
| Condition | Returned payload | User-visible effect |
|
||||
| --- | --- | --- |
|
||||
| Not a git clone | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
|
||||
| Not a git clone (and not a plugin cache) | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
|
||||
| Plugin cache with no `.git` (archive-sourced marketplace entry) | `{git_repo:false, install_kind:"plugin", release_check:"ok", latest_release:"vX.Y.Z", update_available:<version compare>}` | Checked normally: the repo URL comes from the marketplace manifest and `package.json` is the only local signal. The `notGit` note is suppressed and **Update now** is offered. |
|
||||
| No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. |
|
||||
| `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"}` | Sidebar button goes amber; modal stays suppressed until a successful check. |
|
||||
| Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."}` | Modal suppressed. |
|
||||
| Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Sidebar neutral, modal suppressed. |
|
||||
| Release lookup failed (offline, non-HTTP remote, no releases) | `{release_check:"unreachable"\|"remote_not_http"\|"no_releases"\|..., latest_release:null}` | Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked". |
|
||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
|
||||
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
|
||||
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
|
||||
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
||||
|
||||
### Why Detection-Only
|
||||
### Why Self-Apply Stays Narrow
|
||||
|
||||
The dashboard does not expose an apply/restart endpoint by design. A process cannot reliably replace itself without an external supervisor, and several real constraints make an in-process self-update path strictly worse than letting the user run two commands in a terminal:
|
||||
`POST /api/updates/apply` is the one path in this dashboard that mutates its own working tree and restarts the process. It is deliberately narrow: in checkout mode it acts only on `release_available`, `tracking_canonical`, and `fork_or_diverged_tracking`, always with `--ff-only`, and refuses outright on a feature branch or a detached HEAD. A `plugin_managed` install is not a git operation at all — it is delegated to `claude plugin update`, whose own tooling owns that directory. Everywhere else the dashboard prints a command and lets the user run it, because the constraints below are real and an automatic path cannot answer them:
|
||||
|
||||
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, `launchd`, and Docker each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
|
||||
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, and `launchd` each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
|
||||
- **Silent failures.** `npm install` / `npm run build` / port-release timing issues surface as a dead server with no user-facing feedback once the original process has exited.
|
||||
- **No rollback.** A partial pull + install leaves a broken checkout with no atomic recovery — the working tree is mutated mid-flight.
|
||||
- **Branch coverage.** Even with the situation-aware `manual_command` produced by the detection layer, an automatic apply would still need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon.
|
||||
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
||||
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
||||
|
||||
The detection layer carries all of the signal value: the dashboard tells the user *when* to update and *exactly what to run*; the user owns the *how* in their own shell.
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
|
||||
|
||||
---
|
||||
|
||||
@@ -2224,64 +2259,6 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
|
||||
|
||||
---
|
||||
|
||||
## VS Code Extension Architecture
|
||||
|
||||
The **AgentHub** VS Code extension provides an integrated monitoring experience directly within the editor. It communicates with the local dashboard server via standard HTTP APIs and renders the dashboard UI in a webview.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
### Extension Components
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **Extension Host** (`extension.js`) | Manages the extension lifecycle, registers commands, creates the status bar item, and coordinates the webview panel. |
|
||||
| **Sidebar Provider** (`sidebar.js`) | Implements the `TreeDataProvider` for the Activity Bar. It performs background polling of the dashboard APIs (`/api/stats`, `/api/analytics`, `/api/sessions`) every 5 seconds. |
|
||||
| **Status Bar Item** | Provides a persistent "Pulse" indicator in the VS Code status bar, showing active session and agent counts. |
|
||||
| **Webview Panel** | A native VS Code tab that renders the dashboard React application. It supports deep linking to specific sessions or sub-pages. |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "VS Code Process"
|
||||
SB[Status Bar]
|
||||
SIDE[Sidebar TreeView]
|
||||
WV[Webview Panel]
|
||||
end
|
||||
|
||||
subgraph "Extension Host"
|
||||
EH[extension.js]
|
||||
SP[sidebar.js]
|
||||
end
|
||||
|
||||
subgraph "Dashboard Server (localhost)"
|
||||
API["/api/stats<br/>/api/analytics<br/>/api/sessions"]
|
||||
DS[Express Server]
|
||||
end
|
||||
|
||||
SIDE -->|Poll 5s| SP
|
||||
SP -->|HTTP GET| API
|
||||
EH -->|HTTP GET| API
|
||||
SB -->|Pulse| EH
|
||||
WV -->|Iframe src| DS
|
||||
```
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
1. **Auto-Detection**: The extension checks both port `5173` (Vite dev server) and `4820` (Production server) on `localhost`. It prioritizes the production port for API data but can render the UI from either.
|
||||
2. **Real-time Status**: The `SidebarProvider` uses a background loop with `onDidChangeTreeData` to automatically toggle between **Online** and **Offline** states as the local server starts or stops.
|
||||
3. **Deep Linking**: Commands like `claude-code-agent-monitor.openDashboard` accept arguments (e.g., a session ID or page path like `analytics`) to route the webview to specific views within the React SPA.
|
||||
4. **Theme Awareness**: The Activity Bar icon (`icon.svg` or `apple-touch-icon.png`) and sidebar icons use VS Code's `ThemeIcon` and `ThemeColor` to ensure they adapt to Light, Dark, and High Contrast themes.
|
||||
|
||||
For the extension source code, refer to the [vscode-extension/](./vscode-extension/) directory.
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
| Area | Approach |
|
||||
@@ -2368,59 +2345,7 @@ graph LR
|
||||
| **File watching** | `node --watch` + Vite HMR | None |
|
||||
| **Source maps** | Inline | External files |
|
||||
|
||||
### Container (Docker / Podman)
|
||||
|
||||
A multi-stage `Dockerfile` builds the client and server into a single production image. Both Docker and Podman are fully supported — the image is OCI-compliant.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Multi-Stage Build"
|
||||
S1["Stage 1: server-deps\nnode:22-alpine\nnpm ci --omit=dev"]
|
||||
S2["Stage 2: client-build\nnode:22-alpine\nnpm ci + vite build"]
|
||||
S3["Stage 3: runtime\nnode:22-alpine\nCopies node_modules + client/dist"]
|
||||
S1 --> S3
|
||||
S2 --> S3
|
||||
end
|
||||
|
||||
subgraph "Container Runtime"
|
||||
VOL1["~/.claude (ro)\nlegacy session import"]
|
||||
VOL2["agent-monitor-data\nSQLite persistence"]
|
||||
S3 -->|"EXPOSE 4820"| SRV["node server/index.js\nport 4820"]
|
||||
VOL1 --> SRV
|
||||
VOL2 --> SRV
|
||||
end
|
||||
|
||||
style S3 fill:#339933,stroke:#5cb85c,color:#fff
|
||||
style SRV fill:#6366f1,stroke:#818cf8,color:#fff
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
|
||||
# Plain Docker / Podman (equivalent)
|
||||
docker build -t agent-monitor .
|
||||
docker run -d -p 4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
|
||||
|
||||
---
|
||||
|
||||
## Statusline Utility
|
||||
|
||||
The `statusline/` directory contains a standalone CLI statusline for Claude Code, separate from the web dashboard. It renders a color-coded bar at the bottom of the Claude Code terminal showing model, user, working directory, git branch, context window usage, per-direction token counts, and session cost in USD.
|
||||
|
||||
### Model Name Formatting (Client)
|
||||
## Model Name Formatting (Client)
|
||||
|
||||
The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Settings page, which shows raw patterns for pricing rule configuration).
|
||||
|
||||
@@ -2447,60 +2372,6 @@ The function handles:
|
||||
|
||||
Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, AgentCard, SessionDrillIn, and EventDetail.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CC as Claude Code
|
||||
participant SH as statusline-command.sh
|
||||
participant PY as statusline.py
|
||||
participant GIT as git CLI
|
||||
|
||||
CC->>SH: stdin (JSON payload)
|
||||
SH->>PY: Pipes stdin through
|
||||
PY->>PY: Parse JSON (model, cwd, context_window, cost)
|
||||
PY->>GIT: git symbolic-ref --short HEAD
|
||||
GIT-->>PY: Branch name
|
||||
PY->>PY: Build ANSI-colored segments (incl. tokens by direction, cost)
|
||||
PY-->>CC: stdout (formatted statusline)
|
||||
```
|
||||
|
||||
### Segments
|
||||
|
||||
| Segment | Source | Color Logic |
|
||||
| ------------ | ------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Model | `data.model.display_name` | Always cyan |
|
||||
| User | `$USERNAME` / `$USER` env var | Always green |
|
||||
| Working Dir | `data.workspace.current_dir` | Always yellow, `~` prefix for home |
|
||||
| Git Branch | `git symbolic-ref --short HEAD` | Always magenta, hidden outside git repos |
|
||||
| Context Bar | `data.context_window.used_percentage` | Green < 50%, Yellow 50–79%, Red >= 80% |
|
||||
| Token Counts | `data.context_window.current_usage` | Green `↑` input, cyan `↓` output, dim `c` cache reads |
|
||||
| Session Cost | `data.cost.total_cost_usd` | Green < $5, Yellow $5–$20, Red >= $20 (shown on API and subscription plans) |
|
||||
|
||||
### Integration
|
||||
|
||||
The statusline is configured in `~/.claude/settings.json` via the `statusLine` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash \"/path/to/.claude/statusline-command.sh\""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code invokes this command on each update, piping a JSON payload to stdin. The script reads the JSON, extracts fields, runs `git` for branch info, and prints ANSI-formatted output to stdout.
|
||||
|
||||
**Design decisions:**
|
||||
|
||||
- **Python 3.6+** -- available on virtually all systems, handles ANSI and JSON natively
|
||||
- **No dependencies** -- uses only stdlib (`sys`, `json`, `os`, `subprocess`)
|
||||
- **Shell wrapper** -- `statusline-command.sh` sets `PYTHONUTF8=1` for Windows Unicode support and resolves the absolute path to the Python script
|
||||
- **Fail-safe** -- exits silently on empty input or JSON parse errors, never blocks Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Technology Choices
|
||||
|
||||
| Technology | Why This Over Alternatives |
|
||||
@@ -2528,8 +2399,6 @@ make build Build the React client for production
|
||||
make start Start the production server
|
||||
make test Run all tests (server + client)
|
||||
make format Format all files with Prettier
|
||||
make docker-up Start via docker-compose
|
||||
make docker-down Stop docker-compose stack
|
||||
```
|
||||
|
||||
See `Makefile` for the complete set of 25 targets covering setup, dev, testing, formatting, data management, Codex extensions, and Docker/Podman workflows.
|
||||
See `Makefile` for the complete set of 17 targets covering setup, dev, testing, formatting, and data management.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: 58774f2 (2026-09-04) + per-node run command (`agenthub lanes node`) and its slash commands._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
|
||||
|
||||
## Repo map
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: it tails Codex's own rollout journals because Codex emits no hooks at all).
|
||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: it tails Codex's own rollout journals because Codex emits no hooks at all).
|
||||
- `client/`: React + Vite UI.
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `agenthub update-check`.)
|
||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users; `agenthub skills install` remains a fallback that copies `ship-feature-lane` into `~/.claude/skills/` for plain checkouts. - `agents/`: the same story for subagents — plugin root, so all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`) ship as `agenthub:<name>`. This is also the source `agenthub lanes agents install` copies the two pipeline agents from into a lane's `.claude/agents/`, which is what keeps the bare `subagent_type: qc-local` working there. One file per agent, never a second copy.
|
||||
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||
- **Reporting a required plugin is derived; installing one is allowlisted.** `server/lib/skill-plugins.js` reads the `/plugin:skill` strings out of the loaded pipeline templates — right for the `/agenthub-doctor` row, wrong for an install: a template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`, so installing whatever name it contains would make "edit a JSON file" mean "run this vendor's install command next session". The bootstrap installs only what `AUTO_INSTALLABLE` (a constant in that file, `superpowers` today) lists, in its DETACHED worker, never in the 10 s hook, recorded once as `state.skillPluginsSettled` (also gating `fastPathOk`), never throwing, and off under `AGENTHUB_AUTO_INSTALL_PLUGINS=0|false|off`. Do not derive an install target from a template.
|
||||
@@ -36,6 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||||
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
|
||||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||||
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/<name>.config.toml` — the layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
|
||||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
@@ -43,6 +44,10 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
||||
|
||||
## Non-negotiable engineering rules
|
||||
- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port.
|
||||
- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps.
|
||||
- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the 2 newest survivors as a rollback cushion, and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here.
|
||||
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
|
||||
- Preserve existing behavior unless explicitly asked to change it.
|
||||
- Prefer minimal, reversible diffs.
|
||||
- Never silently weaken safety controls around destructive actions.
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
# Multi-stage Dockerfile for AgentHub - a Node.js server with a React client.
|
||||
# This setup optimizes the final image size by separating the build and runtime stages.
|
||||
# The first stage installs only the production dependencies for the server, while the second stage builds the React client.
|
||||
# The final stage combines the necessary files and dependencies to run the application in production.
|
||||
# Compatibility: This setup is compatible with both Podman and Docker. Runnable on any platform that supports Node.js and Alpine Linux.
|
||||
#
|
||||
# Author: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
|
||||
# ── Stage 1: Install server production deps ───────────────────────────
|
||||
FROM node:22-alpine AS server-deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# The root `postinstall` hook (scripts/postinstall.js) fires during `npm ci`, so
|
||||
# the file must exist here or npm aborts with MODULE_NOT_FOUND before installing
|
||||
# anything. It self-skips when client/ is absent (as it is in this stage), so
|
||||
# copying just the one script keeps this deps-cache layer from busting on
|
||||
# unrelated scripts/ edits. Do NOT use --ignore-scripts: that would also skip
|
||||
# better-sqlite3's prebuild fetch and silently drop the native SQLite driver.
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ── Stage 2: Build React client ───────────────────────────────────────
|
||||
FROM node:22-alpine AS client-build
|
||||
WORKDIR /app/client
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
# vite.config.ts stamps the UI version from the repo-root package.json (one level
|
||||
# up from the client dir). Provide it here so the built client shows the real
|
||||
# release version; the config falls back gracefully if it is ever absent.
|
||||
COPY package.json /app/package.json
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Production runtime ───────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||
RUN apk add --no-cache tmux
|
||||
|
||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||
COPY package.json ./
|
||||
COPY server/ ./server/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY statusline/ ./statusline/
|
||||
COPY --from=client-build /app/client/dist ./client/dist/
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
EXPOSE 4820
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# In a container the app MUST bind all interfaces: the server binds loopback by
|
||||
# default (GHSA-gr74-4xfh-6jw9), but a container's loopback is a separate
|
||||
# namespace the published port cannot reach, so a loopback bind makes the port
|
||||
# unreachable. Inside a container the trust boundary is the *host* port publish
|
||||
# (keep it on 127.0.0.1 — see docker-compose.yml / INSTALL.md), not this bind.
|
||||
ENV DASHBOARD_HOST=0.0.0.0
|
||||
|
||||
# The recommended ~/.claude bind mount is read-only, so the server cannot write
|
||||
# its default data dir (~/.claude/agenthub). Persist to the mounted
|
||||
# volume at /app/data instead.
|
||||
ENV DASHBOARD_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
+76
-88
@@ -30,12 +30,64 @@ dashboard, run it from source, or stay on Node 20.
|
||||
| Node.js | 20+ (22+ recommended) | Required for server and client |
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Python | 3.6+ | Optional — statusline utility only |
|
||||
| Git | Any | For cloning the repository |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
|
||||
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
|
||||
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
|
||||
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
|
||||
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — System packages
|
||||
|
||||
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
|
||||
backs the run console**. The dashboard shells out to the `tmux` binary and has
|
||||
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
|
||||
fail while everything else keeps working.
|
||||
|
||||
```bash
|
||||
sudo apt install git tmux # Debian / Ubuntu
|
||||
sudo dnf install git tmux # Fedora / RHEL
|
||||
brew install git tmux # macOS
|
||||
sudo pacman -S git tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
|
||||
Check it:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
git --version
|
||||
node -v # v20.x / v22.x / v24.x
|
||||
```
|
||||
|
||||
Once the server is up you can ask it directly — this is the same probe the
|
||||
dashboard uses:
|
||||
|
||||
```bash
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
**No configuration is needed.** AgentHub creates its own session per lane,
|
||||
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
|
||||
depends on (mouse mode is set on the session explicitly). You can attach to the
|
||||
exact same pane from a normal terminal:
|
||||
|
||||
```bash
|
||||
agenthub lanes shell # from inside the lane's directory
|
||||
tmux attach -t agenthub-lane-7 # or by name
|
||||
tmux ls # every dashboard session is prefixed agenthub-lane-
|
||||
```
|
||||
|
||||
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
|
||||
```bash
|
||||
@@ -85,7 +137,7 @@ This starts two processes concurrently:
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard in Docker or Podman, install hooks from the host with `npm run install-hooks` after the container is up.
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,8 +172,6 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
|
||||
2. Once installed, the dashboard launches in its own window with no browser chrome
|
||||
3. Offline support: previously visited pages and assets are served from the Service Worker cache when the network is unavailable
|
||||
|
||||
The landing page and wiki are also installable PWAs with their own manifests and service workers — visit each in a browser to install independently.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — (Optional) Import existing Claude Code history
|
||||
@@ -223,89 +273,6 @@ See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
---
|
||||
|
||||
## Optional: VS Code extension
|
||||
|
||||
**AgentHub** is also available as a dedicated VS Code extension for seamless, integrated monitoring.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
### Features
|
||||
- **Real-time Sidebar**: Monitor agent status, health, and usage stats in the Activity Bar.
|
||||
- **Pulse Status Bar**: High-level session and agent counts in the bottom status bar.
|
||||
- **Direct Navigation**: Jump to specific dashboard pages or recent sessions.
|
||||
- **Embedded Dashboard**: Full dashboard interface within a native VS Code tab.
|
||||
|
||||
### Installation
|
||||
1. Open the [vscode-extension](./vscode-extension) folder in VS Code.
|
||||
2. Install via the Marketplace or package it manually:
|
||||
```bash
|
||||
cd vscode-extension
|
||||
npm install
|
||||
# Generate .vsix for local install
|
||||
npm run package
|
||||
```
|
||||
3. After installation, ensure the main dashboard server is running (`npm run dev`).
|
||||
4. Look for the **Radar icon** in your VS Code Activity Bar.
|
||||
|
||||
For advanced configuration, refer to the [.vscode](./.vscode) and [vscode-extension](./vscode-extension) directories.
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
---
|
||||
|
||||
## Container mode (Docker / Podman)
|
||||
|
||||
The repository includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. Docker and Podman are both supported.
|
||||
|
||||
### Compose
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
```
|
||||
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
### Plain Docker / Podman
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
### Container notes
|
||||
|
||||
| Mount | Purpose |
|
||||
|---|---|
|
||||
| `~/.claude:/root/.claude:ro` | Lets the server import legacy Claude session history |
|
||||
| `~/.claude/agenthub:/app/data` | **Canonical SQLite database** (shared with native installs) |
|
||||
|
||||
> [!NOTE]
|
||||
> The image sets `DASHBOARD_HOST=0.0.0.0` and `DASHBOARD_DATA_DIR=/app/data` internally (baked into the `Dockerfile`). The `0.0.0.0` bind is required because a container's loopback is a separate namespace the published port cannot reach; `/app/data` is bind-mounted to `~/.claude/agenthub` on the host so Docker uses the same database as `npm start`. The trust boundary is the **host** port publish — the examples use `-p 127.0.0.1:4820:4820`, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Claude Code hooks run on the host, not inside the container. After the container is healthy on `http://localhost:4820`, run `npm run install-hooks` on the host so Claude Code posts hook events back to the containerized server. The installer refuses to run inside a container (issue #193) to avoid writing a container-internal handler path into a bind-mounted `~/.claude`; use `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only if you run Claude Code inside the same container.
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `npm run setup` shows `better-sqlite3` errors
|
||||
@@ -346,6 +313,27 @@ npm run setup
|
||||
|
||||
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
|
||||
|
||||
### "Start Run" fails, or the terminal never attaches
|
||||
|
||||
The run console is tmux-backed. Check, in this order:
|
||||
|
||||
```bash
|
||||
tmux -V # not installed → see Step 0
|
||||
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
|
||||
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the server runs with
|
||||
a different `PATH` — most often a systemd `--user` service, which does not read
|
||||
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
|
||||
rather than into a shell-managed prefix, then `agenthub service uninstall &&
|
||||
agenthub service install`.
|
||||
|
||||
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
|
||||
name, so a `claude` installed only through an interactive shell's profile is
|
||||
invisible to a service-managed server. `{"found":false}` for the lane's tool is
|
||||
that case.
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||
|
||||
@@ -94,30 +94,6 @@ import-history: ## Import sessions from ~/.claude/ history
|
||||
clear-data: ## Delete all data rows (preserves schema)
|
||||
node scripts/clear-data.js
|
||||
|
||||
# ─── Docker / Podman ───────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: ## Build dashboard Docker image
|
||||
docker build -t claude-agent-monitor:local .
|
||||
|
||||
.PHONY: docker-up
|
||||
docker-up: ## Start via docker-compose
|
||||
docker compose up -d
|
||||
|
||||
.PHONY: docker-down
|
||||
docker-down: ## Stop docker-compose stack
|
||||
docker compose down
|
||||
|
||||
.PHONY: docker-logs
|
||||
docker-logs: ## Tail docker-compose logs
|
||||
docker compose logs -f
|
||||
|
||||
|
||||
.PHONY: podman-build
|
||||
podman-build: ## Build dashboard Podman image
|
||||
podman build -t localhost/claude-agent-monitor:local .
|
||||
|
||||
|
||||
# ─── Help ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: help
|
||||
|
||||
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
|
||||
## What it does
|
||||
## Main features
|
||||
|
||||
**Observe**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||
targets that fire on them.
|
||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||
startup; archives from another machine come in through Settings → Import
|
||||
History (idempotent, never double-counts tokens).
|
||||
- **Remote sources.** Pull session history from other machines over SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Drive**
|
||||
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. A lane
|
||||
moves through pipeline stages and the dashboard shows where it is.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
@@ -26,15 +40,72 @@ Internal build — all rights reserved.
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
|
||||
setting.
|
||||
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
|
||||
feature from intake to PR, declaring every stage to the dashboard as it goes.
|
||||
|
||||
## Requirements
|
||||
**Configure**
|
||||
|
||||
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
|
||||
are verified on — node 25 currently breaks 6 server tests through a
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Need | Version | Required for |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||
change.
|
||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||
v26.8.1).
|
||||
|
||||
### Installing tmux
|
||||
|
||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||
**Start Run** and `agenthub lanes shell` fail without it. Everything else
|
||||
(sessions, analytics, lanes, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
### The superpowers plugin
|
||||
|
||||
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
|
||||
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
|
||||
@@ -83,7 +154,7 @@ Details, including what to delete on uninstall:
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root, client and vscode-extension dependencies
|
||||
npm run setup # root and client dependencies
|
||||
npm run build # builds the client into client/dist
|
||||
npm start # serves the built client and the API on :4820
|
||||
```
|
||||
|
||||
+94
-8
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
|
||||
|
||||
## Làm được gì
|
||||
## Chức năng chính
|
||||
|
||||
**Quan sát**
|
||||
|
||||
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
|
||||
được: tool call, token usage, cost, cây subagent, transcript.
|
||||
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
|
||||
pricing sửa được; top tool, agent type và workflow pattern.
|
||||
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
|
||||
target bắn khi rule khớp.
|
||||
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
|
||||
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
|
||||
(idempotent, không bao giờ đếm trùng token).
|
||||
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Điều khiển**
|
||||
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
|
||||
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
|
||||
@@ -26,14 +40,86 @@ Internal build — all rights reserved.
|
||||
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
|
||||
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
|
||||
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
|
||||
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
|
||||
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
|
||||
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
|
||||
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
|
||||
|
||||
## Yêu cầu
|
||||
**Cấu hình**
|
||||
|
||||
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
|
||||
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
|
||||
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
|
||||
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
|
||||
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
||||
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
|
||||
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
|
||||
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
|
||||
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
|
||||
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
|
||||
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
|
||||
tiếng Anh, Việt, Trung và Hàn.
|
||||
|
||||
## Yêu cầu hệ thống
|
||||
|
||||
| Cần | Phiên bản | Dùng cho |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
|
||||
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
|
||||
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
|
||||
|
||||
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
|
||||
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
|
||||
|
||||
### Cài tmux
|
||||
|
||||
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
|
||||
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
|
||||
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
tmux -V # ví dụ: tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
|
||||
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
|
||||
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
|
||||
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
|
||||
|
||||
### Plugin superpowers
|
||||
|
||||
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
|
||||
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
|
||||
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
|
||||
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
|
||||
|
||||
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
|
||||
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
|
||||
thì skill mới resolve được (session sau tự nhận). Cài tay:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
|
||||
|
||||
## Cài như Claude Code plugin
|
||||
|
||||
@@ -55,7 +141,7 @@ cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
## Cài từ checkout
|
||||
|
||||
```bash
|
||||
npm run setup # cài dependency cho root, client và vscode-extension
|
||||
npm run setup # cài dependency cho root và client
|
||||
npm run build # build client vào client/dist
|
||||
npm start # serve client đã build + API trên :4820
|
||||
```
|
||||
|
||||
@@ -48,48 +48,11 @@ npm run install-hooks
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Container note: do not rely on hook auto-install from inside Docker or Podman. The hook path written by a container would point at the container filesystem, not the host. Start the container first, then run `npm run install-hooks` on the host. As a safeguard (issue #193), the installer now **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`; the containerized server logs the same guidance instead of silently writing a bad path. If you genuinely run Claude Code inside the same container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
|
||||
> [!NOTE]
|
||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||
|
||||
### Container runtime (Docker / Podman)
|
||||
|
||||
The repo includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. The container image serves the built client and API on port `4820`, stores SQLite data under `/app/data`, and can import legacy Claude history from a read-only `~/.claude` mount.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
|
||||
# Plain Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Plain Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
Container-specific behavior:
|
||||
|
||||
- The dashboard is available at `http://localhost:4820`
|
||||
- The image sets `DASHBOARD_HOST=0.0.0.0` (bind inside the container — its loopback is a separate namespace the published port cannot reach) and `DASHBOARD_DATA_DIR=/app/data` internally; both are baked into the `Dockerfile`
|
||||
- The examples publish on `127.0.0.1` only, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`
|
||||
- `~/.claude:/root/.claude:ro` is used for history import only
|
||||
- `~/.claude/agenthub:/app/data` is the **canonical** SQLite database (shared with native installs)
|
||||
- Claude Code hooks still execute on the host, so install them from the host with `npm run install-hooks`
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -136,39 +99,18 @@ This repository ships extension files for both agent ecosystems:
|
||||
|
||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
### VS Code extension setup
|
||||
|
||||
**AgentHub** is available as an integrated VS Code extension for seamless monitoring within your editor.
|
||||
|
||||
- **Activity Bar View**: Adds a custom "Radar" icon to the activity bar providing real-time agent health, token counts, and session stats.
|
||||
- **Status Bar Integration**: Displays live session and agent pulse counts in the bottom bar.
|
||||
- **Embedded Dashboard**: Renders the full web dashboard directly in a VS Code editor tab.
|
||||
- **Automated Detection**: Automatically finds your dashboard server on ports `5173` or `4820`.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
To install or develop the extension:
|
||||
1. Open the [vscode-extension](./vscode-extension) directory in VS Code.
|
||||
2. Run `npm install` and `npm run package` to generate a local `.vsix` installer.
|
||||
3. For developer details, see [vscode-extension/README.md](./vscode-extension/README.md).
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
### PWA configuration (optional)
|
||||
|
||||
The dashboard, landing page, and wiki each ship as independent Progressive Web Apps. No configuration is required — manifests and service workers are included out of the box.
|
||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||
|
||||
**Customising the manifest:** Edit the `manifest.json` in the relevant directory (`client/public/` for dashboard, root for landing, `wiki/` for wiki). Common fields to change:
|
||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||
|
||||
- `name` / `short_name` — displayed on the home screen / dock
|
||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||
- `background_color` — splash screen background
|
||||
- `start_url` — entry point when launched from home screen
|
||||
|
||||
**Updating the service worker cache:** Each SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
|
||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||
|
||||
@@ -349,16 +291,6 @@ Commonly used targets:
|
||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||
| `make seed` | `npm run seed` | Load demo data |
|
||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||
| `make docker-up` | `docker compose up -d` | Start via docker-compose |
|
||||
| `make docker-down` | `docker compose down` | Stop docker-compose stack |
|
||||
|
||||
---
|
||||
|
||||
## Statusline (optional)
|
||||
|
||||
The `statusline/` directory contains a standalone terminal statusline for Claude Code showing model, working directory, git branch, context window usage, and token counts. It is independent of the web dashboard.
|
||||
|
||||
See [statusline/README.md](./statusline/README.md) for installation instructions.
|
||||
|
||||
---
|
||||
|
||||
@@ -392,6 +324,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
|
||||
|
||||
---
|
||||
|
||||
### The run console won't start a run / the terminal never attaches
|
||||
|
||||
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
|
||||
|
||||
```bash
|
||||
tmux -V # missing → sudo apt install tmux
|
||||
curl -s http://localhost:4820/api/run/tmux # {"available":true}
|
||||
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the **server's**
|
||||
`PATH` differs from yours — typically a systemd `--user` service, which never
|
||||
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
|
||||
re-run `agenthub service uninstall && agenthub service install`. The same
|
||||
applies to `claude` / `codex`: a run launches them by name.
|
||||
|
||||
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
|
||||
|
||||
---
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
**Check 1 — Is the server running?**
|
||||
@@ -464,36 +416,3 @@ And make sure Claude Code posts hooks to the new port:
|
||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||
# or edit scripts/hook-handler.js and change the default port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Docker / Podman container starts but no sessions appear
|
||||
|
||||
**Check 1 — Is the container healthy?**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
**Check 2 — Did you install hooks on the host?**
|
||||
|
||||
Hooks run on the host machine, not inside the container. After the container is up:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
**Check 3 — Are hooks pointing to the right port?**
|
||||
|
||||
Open `~/.claude/settings.json` and verify the hook commands reference `localhost:4820` (or whatever port the container is mapped to). If you changed the port mapping, update hooks accordingly.
|
||||
|
||||
---
|
||||
|
||||
### Docker build fails during `npm ci`
|
||||
|
||||
If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and should not block the build — `better-sqlite3` is an optional dependency. If the build still fails:
|
||||
|
||||
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
||||
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
||||
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
||||
|
||||
+33
-5
@@ -1190,12 +1190,15 @@ async function cmdPricing(flags, positional) {
|
||||
// ── Updates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function cmdUpdateCheck() {
|
||||
console.log(c.dim("Checking the canonical remote — this can take a few seconds…"));
|
||||
console.log(c.dim("Checking the repository's latest release — this can take a few seconds…"));
|
||||
// POST /check (rather than GET /status) so a dashboard open in the browser
|
||||
// sees the same fresh result via the update_status websocket broadcast.
|
||||
const s = await post("/api/updates/check");
|
||||
heading("Dashboard updates", s.repo_root || baseUrl());
|
||||
if (!s.git_repo) {
|
||||
// A plugin-cache install legitimately has no clone (an archive-sourced
|
||||
// marketplace entry unpacks one), yet it IS checkable and updatable via the
|
||||
// release API — so only a genuine non-git checkout bails out here.
|
||||
if (!s.git_repo && s.install_kind !== "plugin") {
|
||||
console.log(`${c.yellow("○")} ${s.message}`);
|
||||
return;
|
||||
}
|
||||
@@ -1203,6 +1206,13 @@ async function cmdUpdateCheck() {
|
||||
console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`);
|
||||
return;
|
||||
}
|
||||
// Say WHY the release lookup produced nothing, so a green "up to date" is
|
||||
// never mistaken for "the release list was actually read".
|
||||
if (s.release_check && s.release_check !== "ok") {
|
||||
console.log(
|
||||
c.dim(` could not read the latest release (${s.release_check}) — compared commits instead`)
|
||||
);
|
||||
}
|
||||
if (s.update_available) {
|
||||
console.log(`${c.yellow("⬆")} ${s.message}`);
|
||||
if (s.situation_note) console.log(c.dim(` ${s.situation_note}`));
|
||||
@@ -1269,6 +1279,23 @@ async function cmdDoctor() {
|
||||
);
|
||||
console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`);
|
||||
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available } = await get("/api/run/tmux");
|
||||
console.log(
|
||||
available
|
||||
? `${c.green("✔")} tmux available to the server`
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
|
||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||
);
|
||||
} catch {
|
||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { sources = [] } = await get("/api/remote-sources");
|
||||
if (sources.length === 0) {
|
||||
@@ -2985,12 +3012,12 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"Administration",
|
||||
[
|
||||
["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
|
||||
["doctor", "", "Connectivity, hooks, database, tmux, and remote sources diagnosis"],
|
||||
["info", "", "Raw system info JSON"],
|
||||
["export", "[file.json]", "Export all data as JSON"],
|
||||
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
|
||||
["reinstall-hooks", "", "Reinstall Claude Code hooks"],
|
||||
["update-check", "", "Check whether the dashboard checkout is behind upstream"],
|
||||
["update-check", "", "Check whether a newer release of the dashboard was published"],
|
||||
["clear-data", "--yes", "Delete ALL data (requires --yes)"],
|
||||
["open", "", "Open the dashboard in your browser"],
|
||||
["version", "", "Print the agenthub version (also: --version, -v)"],
|
||||
@@ -3254,7 +3281,8 @@ const SERVER_ONLY_REASONS = {
|
||||
cleanup: "cleanup is a server-side mutation",
|
||||
"clear-data": "data wipes must go through the server",
|
||||
"reinstall-hooks": "hook installation is performed by the server",
|
||||
"update-check": "the update check runs server-side (git fetch against the canonical remote)",
|
||||
"update-check":
|
||||
"the update check runs server-side (reads the repo's latest release, git fetch as fallback)",
|
||||
info: "system info (uptime, memory, WS connections) only exists on a running server",
|
||||
health: "health is, by definition, a check against the running server",
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - Claude Code Monitor</title>
|
||||
<title>Agent Dashboard - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
@@ -62,7 +62,7 @@
|
||||
"url": "https://smartgift.vn"
|
||||
},
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -353,15 +353,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
};
|
||||
|
||||
const updateAvailable = Boolean(updateStatus?.update_available);
|
||||
// Four resting states, not two: a release is out, this build is ahead of the
|
||||
// newest release (dev build), it matches the newest release, or no release
|
||||
// could be read at all. Collapsing the middle two into "Up to date" is what
|
||||
// made a checkout carrying unreleased commits look shipped.
|
||||
const checkTitle = checking
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? t("nav:updateAvailable")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* @file UpdateNotifier.tsx
|
||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||
* fast-forwardable branch — an "Update now" button that calls
|
||||
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||
* then polls until it's back and reloads the page.
|
||||
* @description Modal surfaced when a newer RELEASE of the dashboard has been
|
||||
* published (or, when no release can be read, when the checkout is behind its
|
||||
* remote tracking branch). Shows the release version against the running one
|
||||
* with a link to its notes, the exact terminal command with copy-to-clipboard,
|
||||
* and — when the install is fast-forwardable — an "Update now" button that
|
||||
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
|
||||
* restart the server itself, then polls until it's back and reloads the page.
|
||||
*
|
||||
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
|
||||
* the same button, wired to a different mechanism: the server hands it to a
|
||||
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
|
||||
* which pulls the new cache directory, reinstalls the runtime and restarts the
|
||||
* dashboard. It takes minutes rather than seconds, so the progress text differs
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -14,8 +25,9 @@
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
||||
* commit re-opens the prompt. Settings can reset dismissal via the
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -66,21 +78,29 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** How often to silently re-check for updates in the background. */
|
||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
||||
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
* `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`),
|
||||
* which is why it belongs here even though `applyUpdate` itself refuses it. */
|
||||
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
||||
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
||||
return (
|
||||
situation === "plugin_managed" ||
|
||||
situation === "release_available" ||
|
||||
situation === "tracking_canonical" ||
|
||||
situation === "fork_or_diverged_tracking"
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
@@ -88,7 +108,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
}
|
||||
|
||||
/** Read the last dismissed upstream SHA from `localStorage`, or null. */
|
||||
/** Read the last dismissed release tag / upstream SHA from `localStorage`. */
|
||||
function loadDismissedSha(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY);
|
||||
@@ -99,7 +119,7 @@ function loadDismissedSha(): string | null {
|
||||
|
||||
/**
|
||||
* Git update availability modal — mounted once in {@link Layout}.
|
||||
* @returns `null` when no update is available or the current SHA was dismissed.
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
@@ -161,19 +181,22 @@ export function UpdateNotifier() {
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
|
||||
const show = Boolean(
|
||||
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
|
||||
);
|
||||
// Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't
|
||||
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
|
||||
// commit the user already has. Falls back to the upstream SHA for the
|
||||
// commit-count path (no release readable).
|
||||
const dismissKey = status?.latest_release || status?.remote_sha || null;
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (restarting || !status?.remote_sha) return;
|
||||
if (restarting || !dismissKey) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||
localStorage.setItem(DISMISS_KEY, dismissKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(status.remote_sha);
|
||||
}, [restarting, status?.remote_sha]);
|
||||
setDismissedSha(dismissKey);
|
||||
}, [restarting, dismissKey]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
@@ -213,12 +236,27 @@ export function UpdateNotifier() {
|
||||
// Polls `status()` until the restarted server answers again, then reloads
|
||||
// so the tab picks up the new client bundle too — the server can't push
|
||||
// this over its own WebSocket since it's mid-restart.
|
||||
const pollUntilBack = useCallback(() => {
|
||||
/**
|
||||
* @param requireDownFirst wait for the server to actually disappear before
|
||||
* treating a success as "it's back". The git path rebuilds and restarts
|
||||
* within seconds, but the plugin chain pulls a new cache directory and
|
||||
* reinstalls dependencies first — the OLD server stays up for that whole
|
||||
* stretch, so polling naively would get a 200 from the process we are
|
||||
* waiting to replace and reload onto the same old bundle.
|
||||
*/
|
||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(() => window.location.reload())
|
||||
.catch(() => setTimeout(attempt, 1500));
|
||||
.then(() => {
|
||||
if (sawDown) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
}, []);
|
||||
@@ -232,11 +270,17 @@ export function UpdateNotifier() {
|
||||
if (result.applied) {
|
||||
setApplying(false);
|
||||
setRestarting(true);
|
||||
pollUntilBack();
|
||||
// mode "plugin": the old server lingers while the chain installs, so
|
||||
// only treat a reachable server as "back" after it has gone away once.
|
||||
pollUntilBack(result.mode === "plugin");
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
||||
result.reason === "plugin_managed"
|
||||
? t("reasonPluginManaged")
|
||||
: result.reason === "not_fast_forwardable"
|
||||
? t("reasonNotFastForwardable")
|
||||
: t("applyError")
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("applyError"));
|
||||
@@ -249,6 +293,8 @@ export function UpdateNotifier() {
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
const behind = status.commits_behind ?? 0;
|
||||
const release = status.latest_release || null;
|
||||
const pluginManaged = status.situation === "plugin_managed";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -260,7 +306,9 @@ export function UpdateNotifier() {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -272,10 +320,14 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{t("title")}
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
{/* Never print a commit count on a shallow clone — the number
|
||||
is a graft artefact, not a distance. */}
|
||||
{release && !status.shallow_clone && status.local_version
|
||||
? t("releaseSubtitle", { current: status.local_version })
|
||||
: t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +343,30 @@ export function UpdateNotifier() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
{status.latest_release_url ? (
|
||||
<a
|
||||
href={status.latest_release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("viewRelease")}
|
||||
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Release lookup failed and detection fell back to commit counting:
|
||||
say so, so "up to date" is never read as "release checked". */}
|
||||
{status.release_check && status.release_check !== "ok" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("releaseUnavailable", { reason: status.release_check })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.fetch_error ? (
|
||||
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
|
||||
@@ -299,7 +374,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!status.git_repo ? (
|
||||
{!status.git_repo && status.install_kind !== "plugin" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("notGit")}
|
||||
</div>
|
||||
@@ -323,7 +398,8 @@ export function UpdateNotifier() {
|
||||
{/* The restart hint only applies when the printed command actually
|
||||
* rewrites the working tree. Feature-branch / detached-HEAD commands
|
||||
* are fetch-only - restarting the dashboard would change nothing. */}
|
||||
{status.situation === "tracking_canonical" ||
|
||||
{status.situation === "release_available" ||
|
||||
status.situation === "tracking_canonical" ||
|
||||
status.situation === "fork_or_diverged_tracking" ? (
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||
) : null}
|
||||
@@ -331,7 +407,22 @@ export function UpdateNotifier() {
|
||||
{restarting ? (
|
||||
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||
{t("restarting")}
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* A previous chain that failed: the only place the user would ever
|
||||
learn why, since the server it would have reported from is the one
|
||||
that never came back. */}
|
||||
{status.last_plugin_update && status.last_plugin_update.ok === false ? (
|
||||
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||
{t("lastPluginUpdateFailed", {
|
||||
step: status.last_plugin_update.step,
|
||||
error: status.last_plugin_update.error || "",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -343,7 +434,10 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
|
||||
own single line; `flex-wrap` then breaks the ROW instead of the
|
||||
words if a translation still outgrows the modal. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
@@ -384,7 +478,10 @@ export function UpdateNotifier() {
|
||||
) : (
|
||||
<Zap className="w-4 h-4" aria-hidden />
|
||||
)}
|
||||
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||
{/* `restartingShort`, not `restarting`: the full sentence is
|
||||
already in the banner above, and a whole sentence on a button
|
||||
is what forced this footer onto two lines. */}
|
||||
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument();
|
||||
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
|
||||
@@ -90,6 +90,95 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => {
|
||||
// The button works here via the detached `claude plugin update` chain — but
|
||||
// it moves only the dashboard server, so the note about /reload-plugins is
|
||||
// part of the promise, not decoration.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers the button when a plugin install has no git clone at all", async () => {
|
||||
// The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so
|
||||
// git_repo is legitimately false. Gating the UI on that reported "not a git
|
||||
// clone; check manually" for an install the dashboard can check AND update.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
git_repo: false,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.1",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
last_plugin_update: {
|
||||
ok: false,
|
||||
step: "resolve_claude",
|
||||
error: "the `claude` CLI is not on this server's PATH",
|
||||
finished_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(/resolve_claude/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("titles the modal with the release version when one was read", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "release_available",
|
||||
release_check: "ok",
|
||||
local_version: "1.5.0",
|
||||
latest_release: "v1.6.0",
|
||||
commits_behind_release: 3,
|
||||
commits_ahead_of_release: 0,
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
// A release IS fast-forwardable on a checkout, so the button stays.
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||
applyMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||||
* An optional "Create/update readme file" checkbox starts the lane's terminal
|
||||
* run right after creation with `/sg-all:sg-md-writing` as its first message.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,7 +26,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool } from "../../lib/types";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
@@ -94,6 +96,11 @@ export function AddLaneModal({
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [writeDocs, setWriteDocs] = useState(false);
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
@@ -104,6 +111,7 @@ export function AddLaneModal({
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
@@ -114,6 +122,9 @@ export function AddLaneModal({
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setWriteDocs(false);
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
@@ -138,6 +149,26 @@ export function AddLaneModal({
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
});
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setRouters(r.items);
|
||||
// A profile deleted in another tab while this modal sat open would
|
||||
// otherwise stay selected in state while the select showed no matching
|
||||
// option — and submit would send a name the server no longer knows.
|
||||
setRouter((current) =>
|
||||
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: with no list the radio simply offers Native, and the lane is
|
||||
// still created — a router is an optional extra, never a precondition.
|
||||
if (!cancelled) setRouters([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRoutersLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -177,6 +208,18 @@ export function AddLaneModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
// The checkbox below runs the docs skill by opening the lane's own terminal
|
||||
// run with the slash command as its first message — a skill only exists
|
||||
// inside a CLI session, so there is nothing server-side to call instead.
|
||||
// Claude-only: codex has no slash commands (same reason LaneConsolePane
|
||||
// prefills prose for a codex lane).
|
||||
const startDocsRun = (laneId: number, cwd: string) =>
|
||||
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
|
||||
|
||||
// Native is the built-in profile, so this is one call either way — there is
|
||||
// no "no router" branch to keep in step with the real one.
|
||||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
const name = title.trim();
|
||||
@@ -192,7 +235,29 @@ export function AddLaneModal({
|
||||
pipeline,
|
||||
tool,
|
||||
});
|
||||
// Report the lane the INSTANT it exists server-side. Everything after
|
||||
// this is post-creation setup that can fail on its own, and letting one
|
||||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||||
// the list until some unrelated refetch happened to pick it up.
|
||||
// Worktree mode below already works this way.
|
||||
onAdded(result.lane);
|
||||
|
||||
// Router first: a session that booted on the vendor's API would keep
|
||||
// talking to it for its whole life.
|
||||
const [routerOutcome, docsOutcome] = await Promise.allSettled([
|
||||
applyRouter(result.lane.id),
|
||||
writeDocs && tool === "claude"
|
||||
? startDocsRun(result.lane.id, result.lane.cwd)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected");
|
||||
if (failed && failed.status === "rejected") {
|
||||
// The lane exists and is already listed — name what did not finish
|
||||
// instead of closing on a half-set-up lane.
|
||||
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -226,10 +291,11 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
setSetupResult({
|
||||
profile:
|
||||
@@ -240,14 +306,23 @@ export function AddLaneModal({
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
});
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
router: routerOutcome.status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,6 +465,117 @@ export function AddLaneModal({
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active = option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
// The input only goes visually hidden, so keyboard and screen
|
||||
// readers still get a real radio group, and the card carries
|
||||
// its focus ring.
|
||||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||
: active
|
||||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{tool === "claude" && (
|
||||
<label
|
||||
// No tick box: the card itself lights up when selected, the same
|
||||
// treatment LaneStripCard uses, which makes a second indicator of
|
||||
// the same state redundant. The input stays and only goes visually
|
||||
// hidden — it is still what a keyboard toggles and what a screen
|
||||
// reader announces — so the card carries the focus ring for it.
|
||||
className={`block cursor-pointer rounded-lg border p-3 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
writeDocs
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
// The wrapping label also holds the hint, so name the input
|
||||
// itself — otherwise its accessible name is label + hint.
|
||||
aria-label={t("addLaneWriteDocsLabel")}
|
||||
checked={writeDocs}
|
||||
onChange={(e) => setWriteDocs(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
|
||||
>
|
||||
{t("addLaneWriteDocsLabel")}
|
||||
</span>
|
||||
<span className="mt-1 block text-[10px] text-fg-muted">
|
||||
{t("addLaneWriteDocsHint")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
@@ -445,6 +631,11 @@ export function AddLaneModal({
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
ok: setupResult.router !== "failed",
|
||||
skipped: setupResult.router === "skipped",
|
||||
},
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
* NOT repeated here — this card only ever renders inside the Workspace
|
||||
* lane-detail section, whose header above it already shows them. The repo
|
||||
* lane-detail section, whose header above it already shows them. Neither is
|
||||
* progress: the pipeline map directly below this card draws the same number
|
||||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
@@ -20,6 +25,7 @@ import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
@@ -134,38 +140,98 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||
/**
|
||||
* Which AI Router this lane runs against, and the one place it can be changed
|
||||
* after the lane exists — the Add-lane modal is the only other one, and a lane
|
||||
* outlives the moment it was created.
|
||||
*
|
||||
* Applying writes the lane's own config on disk as well as the DB column, so it
|
||||
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
|
||||
* the select back: the row must not claim a router the lane did not get.
|
||||
*
|
||||
* Rendered only when there is something to choose. A user who has never opened
|
||||
* the AI Router tab has exactly the built-in `native` profile, and a select
|
||||
* with one option is a control that cannot do anything.
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
function useLaneIntegrations(
|
||||
laneId: number,
|
||||
available: boolean
|
||||
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
||||
// was reset to native by a profile delete, say) has to land here too.
|
||||
useEffect(() => {
|
||||
setCurrent(lane.router_profile || "native");
|
||||
setNote(null);
|
||||
}, [lane.id, lane.router_profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||
.then((results) => {
|
||||
if (!alive) return;
|
||||
const next: Record<string, boolean> = {};
|
||||
INTEGRATION_NAMES.forEach((name, i) => {
|
||||
next[name] = results[i]?.enabled ?? false;
|
||||
});
|
||||
setState(next);
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setState(null);
|
||||
// Silent, same contract as the git/runtime pollers above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [laneId, available]);
|
||||
}, []);
|
||||
|
||||
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||
if (!routers || routers.length <= 1) return null;
|
||||
|
||||
const change = async (name: string) => {
|
||||
const previous = current;
|
||||
setCurrent(name);
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
await api.lanes.routerApply(lane.id, name);
|
||||
setNote({ ok: true, text: t("router.applied") });
|
||||
} catch (err) {
|
||||
setCurrent(previous);
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
data-testid={`lane-router-${lane.id}`}
|
||||
>
|
||||
{t("router.label")}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy}
|
||||
onChange={(e) => void change(e.target.value)}
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* The applied-note is a tooltip, not a line of its own: it fires on a
|
||||
click the user just made, and a wrapped sentence under the control
|
||||
would push every row below it down for the life of the selection.
|
||||
A failure still gets visible colour, because that one is not
|
||||
self-evident from the select. */}
|
||||
{note && (
|
||||
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
|
||||
{note.ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
@@ -336,7 +402,6 @@ export default function LaneCard({
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||
|
||||
/**
|
||||
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||
@@ -365,23 +430,6 @@ export default function LaneCard({
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
>
|
||||
{/* Progress line: how far through the pipeline. The stage's own name
|
||||
and how long it's been running are already in the Workspace
|
||||
header above; not repeated here. Omitted at 0% — an empty bar
|
||||
is not information, just a blank row above a dead/idle lane. */}
|
||||
{lane.progress > 0 && (
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
@@ -477,30 +525,46 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
<div className="truncate">
|
||||
⑂ {git.branch}
|
||||
<span className="ml-2 text-fg-muted">{git.head}</span>
|
||||
</div>
|
||||
<div className="truncate text-fg-muted" title={git.subject}>
|
||||
{git.subject}
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<div className="text-status-warning/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||
<div className="truncate text-fg-muted" title={lane.cwd}>
|
||||
{lane.cwd}
|
||||
{/* One line, because every part of it is short and the panel is
|
||||
narrow: branch, head, subject, and the uncommitted counts read as a
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="mb-2 flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<span
|
||||
className="shrink-0 cursor-help text-status-warning/80"
|
||||
/* Native title: the counts' own file list, capped server-side.
|
||||
A hover card would need a portal to escape this truncating
|
||||
row — the browser's tooltip already escapes it for free. */
|
||||
title={
|
||||
git.files?.length
|
||||
? git.files.join("\n") +
|
||||
(git.dirty + git.untracked > git.files.length
|
||||
? `\n… +${git.dirty + git.untracked - git.files.length}`
|
||||
: "")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && (
|
||||
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
|
||||
⑂ {lane.branch}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
@@ -527,24 +591,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
|
||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
data-testid={`lane-integration-${name}`}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
integrations[name]
|
||||
? "bg-status-success/10 text-status-success"
|
||||
: "bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`integrations.${name}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||
in one grid row still line their buttons up. Repo tooling and the
|
||||
remove button live in the Workspace page header/pipeline row now —
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
||||
* It carries only what you need to pick a lane — which lane, is it alive,
|
||||
* what stage, how far — because the full card, its controls and its
|
||||
* working-copy facts live in the detail panel below it. Keeping the tile
|
||||
* small and fixed-width is what lets many lanes stay scannable in one
|
||||
* scrolling row.
|
||||
* what stage — because the full card, its controls and its working-copy facts
|
||||
* live in the detail panel below it. Stage badges ride on the id row rather
|
||||
* than a line of their own, and progress (bar and percent) is deliberately
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -66,23 +68,11 @@ export default function LaneStripCard({
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
@@ -100,16 +90,16 @@ export default function LaneStripCard({
|
||||
{lane.detected_stage}
|
||||
</span>
|
||||
)}
|
||||
{lane.progress > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.progress > 0 && (
|
||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -131,8 +134,49 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [nativeRouter()] });
|
||||
});
|
||||
|
||||
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved router profile, for the tests that need the radio to have a target. */
|
||||
function savedRouter(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...nativeRouter(),
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
hasKey: true,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
renderModal();
|
||||
@@ -398,6 +442,173 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — create/update readme", () => {
|
||||
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
|
||||
vi.mocked(api.run.start)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByLabelText("Create/update readme file"));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 9,
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("is hidden for a codex lane, which has no slash commands", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Codex" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — AI Router", () => {
|
||||
it("applies native without asking, so a lane always has a declared endpoint", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
|
||||
});
|
||||
|
||||
it("sends the picked profile once AI Router is chosen", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
|
||||
});
|
||||
|
||||
it("keeps the radio on Native when no profile is saved, and says why", async () => {
|
||||
// Choosing "AI Router" with nothing to point at would create a lane whose
|
||||
// declared endpoint does not exist.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates the lane anyway when the profile list cannot be fetched", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
|
||||
it("reports the adopted lane even when applying the router fails", async () => {
|
||||
// `ensure` already created it server-side; skipping onAdded would hide a
|
||||
// real lane from the list until some unrelated refetch found it.
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
// …and the failure is still surfaced, on a modal left open.
|
||||
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
|
||||
// Deleted in another tab while this modal sat open: the select would show
|
||||
// no matching option while state still held the dead name.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const { rerender } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
|
||||
|
||||
// Re-open with the profile gone from the server's answer.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
// Clicking it early resolved back to Native with no feedback, which reads
|
||||
// as a dead control.
|
||||
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
|
||||
vi.mocked(api.aiRouters.list).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
}) as never
|
||||
);
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
resolveList({ items: [nativeRouter(), savedRouter()] });
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
@@ -23,14 +23,17 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
integration: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,8 +44,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.integration).mockReset();
|
||||
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
@@ -150,14 +155,12 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage", () => {
|
||||
// The pipeline map below this card draws the same progress as named nodes,
|
||||
// so the card no longer repeats it as a bar and a percent.
|
||||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
@@ -204,6 +207,7 @@ describe("LaneCard git block", () => {
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"],
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
@@ -215,6 +219,10 @@ describe("LaneCard git block", () => {
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// Hovering the counts names the files behind them.
|
||||
expect(screen.getByText(/modified/).title).toBe(
|
||||
" M server/lib/alerts.js\n M client/src/lib/api.ts\n?? scratch.txt"
|
||||
);
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
@@ -340,40 +348,6 @@ describe("LaneCard — named locks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — integration badges", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
// touches here.
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
up: false,
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.agenthub/profile",
|
||||
ports: {},
|
||||
} as unknown as LaneRuntime);
|
||||
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||
Promise.resolve({ enabled: name === "tracker" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows integration badges reflecting each toggle's state", async () => {
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||
expect(tracker.className).toContain("status-success");
|
||||
expect(devQc.className).not.toContain("status-success");
|
||||
});
|
||||
|
||||
it("hides integration badges when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneToolsMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
@@ -440,3 +414,63 @@ describe("LaneToolsMenu", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
|
||||
|
||||
it("stays out of the card when native is the only profile", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-router-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the lane's current profile selected", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the selection back when the apply fails", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,13 +426,17 @@ export function LaneConsolePane({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||
description of the panel, not a message, so it does not earn the
|
||||
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||
where the width would wrap it again. */}
|
||||
<header className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<h1 className="text-base font-semibold text-fg-primary">
|
||||
{t("title", { tool: toolLabel })}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
@@ -447,7 +451,9 @@ export function LaneConsolePane({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
||||
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||
{t("subtitle", { tool: toolLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
|
||||
@@ -521,14 +521,17 @@ function UnifiedRunRowView({
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const startedDate = new Date(row.startedAt);
|
||||
const startedLabel = isNaN(startedDate.getTime())
|
||||
? "-"
|
||||
: startedDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
|
||||
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
|
||||
const startedLabel =
|
||||
!row.startedAt || isNaN(startedDate.getTime())
|
||||
? "-"
|
||||
: startedDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// Without mode distinction, offer resume for any finished run with a session.
|
||||
// An external session is live but has no attachable tmux session, so Resume
|
||||
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||
|
||||
@@ -147,9 +147,83 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||
settings left, source right. They used to be two rows with a divider
|
||||
between them, which spent a band of height on four labelled selects
|
||||
that are each one line tall. The resume picker below is the only part
|
||||
that still gets a row of its own, and only while resume is chosen. */}
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
@@ -166,8 +240,11 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
{props.tool === "claude" ? (
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
@@ -192,8 +269,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -214,78 +291,6 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
|
||||
const LANE: Lane = {
|
||||
id: 1,
|
||||
router_profile: null,
|
||||
title: "demo",
|
||||
cwd: "/workspace/a",
|
||||
branch: null,
|
||||
|
||||
@@ -242,6 +242,14 @@ describe("ActiveRunsSwitcher", () => {
|
||||
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a dash, not the epoch, for a row with no start time", () => {
|
||||
// A run whose persisted row is missing arrives with startedAt 0. Rendering
|
||||
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
|
||||
renderModal([row("no-start", { startedAt: 0 })]);
|
||||
expect(screen.getByText("-")).toBeTruthy();
|
||||
expect(screen.queryByText(/Jan 1,/)).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
|
||||
@@ -305,6 +305,21 @@
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"updateAll": {
|
||||
"title": "Update everything",
|
||||
"help": "Refreshes every marketplace, then updates all installed plugins, then tells the Claude Code sessions this dashboard has open to pick up the changes. AgentHub itself goes last, because updating it restarts this dashboard.",
|
||||
"button": "Update all",
|
||||
"working": "Updating…",
|
||||
"started": "Update-all started for {{count}} plugin(s).",
|
||||
"failed": "Could not start the update.",
|
||||
"reloadButton": "Reload sessions",
|
||||
"reloaded": "Reload sent to {{count}} open session(s).",
|
||||
"reloadedNone": "No open Claude Code session to reload.",
|
||||
"reloadedCount": "— {{count}} session(s)",
|
||||
"restartWarning": "Keep this tab open. If AgentHub itself is updated, the dashboard restarts at the end and this page reconnects on its own.",
|
||||
"abandoned": "The previous batch stopped without finishing — most likely the machine restarted. Run it again.",
|
||||
"bootstrapFailed": "The dashboard was stopped but did not come back: {{error}}. Run /agenthub-update to recover."
|
||||
},
|
||||
"manage": {
|
||||
"installTitle": "Install a plugin",
|
||||
"installPlaceholder": "owner/repo, https://… or plugin@marketplace",
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
|
||||
"addLaneWriteDocsLabel": "Create/update readme file",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
@@ -96,7 +98,7 @@
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||
"laneHeader": "Lane {{id}} · {{title}}",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"moreActions": "More actions",
|
||||
@@ -114,9 +116,6 @@
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "not declared",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
@@ -132,5 +131,15 @@
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "Agent Dashboard",
|
||||
"brandSub": "Claude Code Monitor",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Claude Config",
|
||||
"checkFailed": "Update check failed",
|
||||
"checkForUpdates": "Check for updates",
|
||||
@@ -65,5 +65,7 @@
|
||||
"website": "Website",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Workspace",
|
||||
"wsEndpoint": "WebSocket"
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "{{version}} available",
|
||||
"devBuild": "Dev build · {{count}} ahead"
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
|
||||
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
||||
"title": "Run {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -445,6 +445,49 @@
|
||||
"label": "Tools",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the lane form. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Small / fast",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"loading": "Loading profiles…",
|
||||
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}",
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tagline": "Nothing your agents do stays in the dark.",
|
||||
"sub1": "Real-time observability for every Claude Code session.",
|
||||
"sub2": "Tokens, tools, costs, and workflows, all live.",
|
||||
"brand": "Claude Code Monitor",
|
||||
"brand": "SmartGift Solution",
|
||||
"taglines": [
|
||||
"Nothing your agents do stays in the dark.",
|
||||
"See every move your agents make.",
|
||||
|
||||
@@ -16,5 +16,15 @@
|
||||
"updating": "Updating...",
|
||||
"restarting": "Rebuilt — restarting the dashboard...",
|
||||
"applyError": "Could not apply the update",
|
||||
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
|
||||
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually.",
|
||||
"releaseTitle": "Release {{version}} available",
|
||||
"releaseSubtitle": "You are running {{current}}",
|
||||
"releaseLead": "A new release of the dashboard has been published. \"Update now\" fast-forwards this install onto the release tag, reinstalls dependencies, rebuilds, and restarts the server.",
|
||||
"viewRelease": "Release notes",
|
||||
"reasonPluginManaged": "This install is managed by the Claude Code plugin cache — run /agenthub-update instead.",
|
||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…"
|
||||
}
|
||||
|
||||
@@ -300,6 +300,21 @@
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
},
|
||||
"updateAll": {
|
||||
"title": "Cập nhật toàn bộ",
|
||||
"help": "Làm mới mọi marketplace, rồi cập nhật tất cả plugin đã cài, sau đó yêu cầu các session Claude Code mà dashboard đang mở nạp lại thay đổi. AgentHub đứng cuối, vì cập nhật nó sẽ khởi động lại dashboard này.",
|
||||
"button": "Cập nhật tất cả",
|
||||
"working": "Đang cập nhật…",
|
||||
"started": "Đã bắt đầu cập nhật {{count}} plugin.",
|
||||
"failed": "Không khởi động được tiến trình cập nhật.",
|
||||
"reloadButton": "Nạp lại session",
|
||||
"reloaded": "Đã gửi lệnh nạp lại tới {{count}} session đang mở.",
|
||||
"reloadedNone": "Không có session Claude Code nào đang mở để nạp lại.",
|
||||
"reloadedCount": "— {{count}} session",
|
||||
"restartWarning": "Giữ tab này mở. Nếu chính AgentHub được cập nhật, dashboard sẽ khởi động lại ở bước cuối và trang tự kết nối lại.",
|
||||
"abandoned": "Đợt cập nhật trước dừng giữa đường — rất có thể do máy khởi động lại. Hãy chạy lại.",
|
||||
"bootstrapFailed": "Dashboard đã bị dừng nhưng không sống lại: {{error}}. Chạy /agenthub-update để khôi phục."
|
||||
},
|
||||
"manage": {
|
||||
"installTitle": "Cài plugin",
|
||||
"installPlaceholder": "owner/repo, https://… hoặc plugin@marketplace",
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
|
||||
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
@@ -96,7 +98,7 @@
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}}",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"moreActions": "Thêm hành động",
|
||||
@@ -114,9 +116,6 @@
|
||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "chưa khai báo",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
@@ -132,5 +131,15 @@
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
"brand": "Bảng điều khiển Agent",
|
||||
"brandSub": "Giám sát Claude Code",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Cấu hình Claude",
|
||||
"checkFailed": "Kiểm tra cập nhật thất bại",
|
||||
"checkForUpdates": "Kiểm tra cập nhật",
|
||||
@@ -65,5 +65,7 @@
|
||||
"website": "Website",
|
||||
"workflows": "Quy trình",
|
||||
"workspace": "Không gian làm việc",
|
||||
"wsEndpoint": "WebSocket"
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
"devBuild": "Bản dev · trước {{count}} commit"
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
|
||||
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
|
||||
"title": "Chạy {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -445,6 +445,49 @@
|
||||
"label": "Công cụ",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
|
||||
"add": "Thêm profile",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
|
||||
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Đã lưu",
|
||||
"keyMissing": "Chưa có",
|
||||
"fieldLabel": "Tên",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Nhỏ / nhanh",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
|
||||
"loading": "Đang tải danh sách profile…",
|
||||
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tagline": "Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
|
||||
"sub1": "Khả năng quan sát thời gian thực cho mọi phiên Claude Code.",
|
||||
"sub2": "Token, công cụ, chi phí và workflow, tất cả trực tiếp.",
|
||||
"brand": "Giám sát Claude Code",
|
||||
"brand": "SmartGift Solution",
|
||||
"taglines": [
|
||||
"Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
|
||||
"Thấy rõ từng bước đi của agent.",
|
||||
|
||||
@@ -16,5 +16,15 @@
|
||||
"updating": "Đang cập nhật...",
|
||||
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
|
||||
"applyError": "Không thể áp dụng cập nhật",
|
||||
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
|
||||
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công.",
|
||||
"releaseTitle": "Đã có bản phát hành {{version}}",
|
||||
"releaseSubtitle": "Bạn đang chạy {{current}}",
|
||||
"releaseLead": "Repo vừa phát hành bản mới. Bấm \"Cập nhật ngay\" để fast-forward install này lên tag của bản phát hành, cài lại dependency, build lại và khởi động lại server.",
|
||||
"viewRelease": "Ghi chú phát hành",
|
||||
"reasonPluginManaged": "Bản cài này do plugin cache của Claude Code quản lý — hãy chạy /agenthub-update.",
|
||||
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…"
|
||||
}
|
||||
|
||||
+224
-5
@@ -1609,11 +1609,45 @@ export const api = {
|
||||
/**
|
||||
* POST /api/cc-config/plugins/action - enable/disable/update/uninstall an
|
||||
* installed plugin by its `<name>@<marketplace>` key.
|
||||
*
|
||||
* `reload` additionally types `/reload-plugins` + `/reload-skills` into the
|
||||
* live Claude Code lane panes, but only when the action itself succeeded —
|
||||
* "update one, reload that one", as opposed to the batch below which
|
||||
* reloads once after everything.
|
||||
*/
|
||||
pluginAction: (key: string, action: CcPluginAction) =>
|
||||
request<CcCliResult>("/cc-config/plugins/action", {
|
||||
pluginAction: (key: string, action: CcPluginAction, reload?: boolean) =>
|
||||
request<CcCliResult & { reloaded?: CcReloadResult }>("/cc-config/plugins/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, action }),
|
||||
body: JSON.stringify({ key, action, reload }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/reload - tell the Claude Code sessions the dashboard
|
||||
* has open to re-read plugins and/or skills from disk.
|
||||
*
|
||||
* Both slash commands are scoped to the session that runs them, so this
|
||||
* types them into the live lane panes over tmux. Sessions running outside
|
||||
* the dashboard cannot be reached and must run the command themselves.
|
||||
*/
|
||||
reload: (kinds?: CcReloadKind[]) =>
|
||||
request<{ ok: true } & CcReloadResult>("/cc-config/reload", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kinds }),
|
||||
}),
|
||||
/** GET /api/cc-config/plugins/update-all - progress of the current or last batch. */
|
||||
updateAllStatus: () =>
|
||||
request<{ batch: CcUpdateAllBatch | null }>("/cc-config/plugins/update-all"),
|
||||
/**
|
||||
* POST /api/cc-config/plugins/update-all - update every marketplace, then
|
||||
* every installed plugin, then reload the open sessions.
|
||||
*
|
||||
* Returns as soon as the detached worker is spawned: the batch ends with
|
||||
* AgentHub itself when the dashboard runs from the plugin cache, and that
|
||||
* step restarts this server. Follow it with {@link updateAllStatus}.
|
||||
*/
|
||||
updateAll: () =>
|
||||
request<CcUpdateAllStarted>("/cc-config/plugins/update-all", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/marketplaces/add - register a marketplace from a
|
||||
@@ -1834,6 +1868,49 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also resets every lane using it back to
|
||||
* native, on disk as well as in the DB.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string; lanes: AiRouterLaneSync }>(
|
||||
`/ai-routers/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||
/** Spawn/manage headless or conversational `claude` CLI child processes
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
@@ -2400,6 +2477,16 @@ export const api = {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/**
|
||||
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
|
||||
* or back at `native`. Writes the lane's own config on disk; only the
|
||||
* profile NAME comes from here.
|
||||
*/
|
||||
routerApply: (id: number, profile: string) =>
|
||||
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile }),
|
||||
}),
|
||||
mcpSync: (id: number) =>
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
||||
method: "POST",
|
||||
@@ -2410,8 +2497,6 @@ export const api = {
|
||||
`/lanes/${id}/profile/init`,
|
||||
{ method: "POST", body: JSON.stringify({ force }) }
|
||||
),
|
||||
integration: (id: number, name: string) =>
|
||||
request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`),
|
||||
syncBaseCheck: (id: number, branch?: string) =>
|
||||
request<{
|
||||
code: number;
|
||||
@@ -2575,6 +2660,73 @@ export interface CcProfile extends CcAccountIdentity {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
smallFast: string;
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** How an edit or a delete landed on the lanes already using that profile.
|
||||
* Attaching copies a profile, so the store alone is not the whole truth: a
|
||||
* lane listed in `failed` still carries the OLD key and endpoint. */
|
||||
export interface AiRouterLaneSync {
|
||||
updated: number;
|
||||
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
* as summarized by the `ccConfig` list endpoints. The list endpoints return
|
||||
* a lightweight summary (frontmatter + a preview) rather than full contents;
|
||||
@@ -2761,6 +2913,73 @@ export type CcPluginAction = "enable" | "disable" | "update" | "uninstall";
|
||||
/** What POST /api/cc-config/marketplaces/action can do to a marketplace. */
|
||||
export type CcMarketplaceAction = "update" | "remove";
|
||||
|
||||
/** Which reload slash command to send. The command strings themselves are
|
||||
* fixed server-side — a pane's stdin is not a place to forward client input. */
|
||||
export type CcReloadKind = "plugins" | "skills";
|
||||
|
||||
/** What POST /api/cc-config/reload reached. `lanes_total` counts only panes
|
||||
* that were running a live `claude`; a lane sitting at a shell prompt has no
|
||||
* REPL to reload and is skipped rather than reported as failed. */
|
||||
export interface CcReloadResult {
|
||||
commands: string[];
|
||||
lanes_total: number;
|
||||
lanes: {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
/** Commands actually delivered — empty when the pane died mid-send. */
|
||||
sent: string[];
|
||||
error?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** One step of the update-all batch. `key` is stable (`marketplaces`,
|
||||
* `plugin:<id>`, `reload`) so the UI can keep a row's identity across polls. */
|
||||
export interface CcUpdateAllStep {
|
||||
key: string;
|
||||
label: string;
|
||||
status: "running" | "ok" | "failed" | "skipped";
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
error?: string;
|
||||
note?: string;
|
||||
/** Set on the AgentHub step that triggers the dashboard restart. */
|
||||
restarted?: boolean;
|
||||
/** Set on the reload step — the lane sessions that were typed into. */
|
||||
lanes?: string[];
|
||||
lanes_total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cc-config/plugins/update-all - the batch worker's on-disk journal.
|
||||
*
|
||||
* `abandoned` is derived, not written: the worker is detached, so a host reboot
|
||||
* leaves a `running` journal with nobody to finish it. `partial` means the
|
||||
* batch ran to the end with at least one failed step.
|
||||
*/
|
||||
export interface CcUpdateAllBatch {
|
||||
status: "running" | "done" | "partial" | "failed" | "abandoned";
|
||||
steps: CcUpdateAllStep[];
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
error?: string;
|
||||
/** Set when the final `plugin-bootstrap --force` failed, i.e. the old server
|
||||
* was stopped and no replacement came up. */
|
||||
bootstrap_error?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** POST /api/cc-config/plugins/update-all - acknowledgement, not a result. */
|
||||
export interface CcUpdateAllStarted {
|
||||
ok: true;
|
||||
spawned: true;
|
||||
runtime_dir: string;
|
||||
/** How many installed plugins the worker will walk. */
|
||||
planned: number;
|
||||
/** The plugin this dashboard itself runs from, updated LAST because its
|
||||
* bootstrap restarts the server. Null for a plain git checkout. */
|
||||
self_plugin: { marketplace: string; plugin: string } | null;
|
||||
}
|
||||
|
||||
/** Result of a `claude plugin …` run the dashboard triggered. A non-zero CLI
|
||||
* exit arrives as an HTTP 422 error instead of one of these. */
|
||||
export interface CcCliResult {
|
||||
|
||||
+104
-5
@@ -1255,6 +1255,8 @@ export interface UpdateStatusPayload {
|
||||
* shape (e.g. show "Restart after running" only when the command
|
||||
* actually rewrites the working tree). */
|
||||
situation?:
|
||||
| "plugin_managed"
|
||||
| "release_available"
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
@@ -1285,9 +1287,79 @@ export interface UpdateStatusPayload {
|
||||
* restart itself). Absent from `status`/`check` responses. */
|
||||
applied?: boolean;
|
||||
/** Set alongside `applied: false` on an `/apply` response that declined to
|
||||
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
|
||||
* detached HEAD, where there's no safe automatic move). */
|
||||
reason?: "up_to_date" | "not_fast_forwardable";
|
||||
* act - `"up_to_date"`, `"not_fast_forwardable"` (feature branch /
|
||||
* detached HEAD, where there's no safe automatic move), or
|
||||
* `"plugin_managed"` (the install lives in the Claude Code plugin cache,
|
||||
* which only `/reload-plugins` can swap - the server must not touch it). */
|
||||
reason?: "up_to_date" | "not_fast_forwardable" | "plugin_managed";
|
||||
|
||||
// ── Release layer ──
|
||||
/** How this copy was installed. `"plugin"` means it runs out of
|
||||
* `~/.claude/plugins/cache/`, where self-update is refused. */
|
||||
install_kind?: "plugin" | "checkout";
|
||||
/** Marketplace the plugin came from (e.g. `"sg"`), parsed from the cache
|
||||
* path. null for a checkout install. Used to print the exact
|
||||
* `claude plugin update <plugin>@<marketplace>` command. */
|
||||
plugin_marketplace?: string | null;
|
||||
/** Version this install declares in its own package.json, e.g. `"1.5.0"`.
|
||||
* Compared against `latest_release` to decide `update_available`. */
|
||||
local_version?: string | null;
|
||||
/** True for a `--depth` clone, where every commit-distance field is
|
||||
* meaningless and therefore null - the UI must not render an
|
||||
* "N commits behind" badge. The Claude Code plugin cache is one. */
|
||||
shallow_clone?: boolean;
|
||||
/** `"ok"`, or why the release lookup produced nothing: `"no_remote"`,
|
||||
* `"remote_not_http"`, `"no_releases"`, `"unreachable"`, `"bad_payload"`,
|
||||
* or `"http_<status>"`. Anything but `"ok"` means detection fell back to
|
||||
* the commit-count comparison. */
|
||||
release_check?: string;
|
||||
/** Tag of the newest published release, e.g. `"v1.5.0"`. null when
|
||||
* `release_check !== "ok"`. */
|
||||
latest_release?: string | null;
|
||||
/** Human title of that release, when the forge has one. */
|
||||
latest_release_name?: string | null;
|
||||
/** Browser URL of the release page, for a "view release notes" link. */
|
||||
latest_release_url?: string | null;
|
||||
/** ISO timestamp the release was published. */
|
||||
latest_release_published_at?: string | null;
|
||||
/** Release body/changelog, truncated server-side to 4000 chars. */
|
||||
latest_release_notes?: string | null;
|
||||
/** Commit the release tag resolves to locally; null when the tag hasn't
|
||||
* been fetched yet. */
|
||||
release_sha?: string | null;
|
||||
/** Commits in the release that this install lacks. null on a shallow
|
||||
* clone (see `shallow_clone`). */
|
||||
commits_behind_release?: number | null;
|
||||
/** Commits this install carries that the release does not. null on a
|
||||
* shallow clone. Drives the "dev build" label. */
|
||||
commits_ahead_of_release?: number | null;
|
||||
/** True when this install is strictly ahead of the newest release and
|
||||
* missing nothing from it - a build off the branch, not a shipped
|
||||
* version. Never true at the same time as `update_available`. */
|
||||
dev_build?: boolean;
|
||||
/** Plugin name parsed from the cache path (e.g. `"agenthub"`). null for a
|
||||
* checkout install. Combined with `plugin_marketplace` it forms the
|
||||
* `claude plugin update <plugin>@<marketplace>` target. */
|
||||
plugin_name?: string | null;
|
||||
/** Present only on an `/apply` response. `"plugin"` means the detached
|
||||
* plugin chain took the job (minutes, and the old server stays up while it
|
||||
* installs); absent means an in-place git fast-forward + restart. */
|
||||
mode?: "plugin";
|
||||
/** Runtime directory the plugin chain writes its log/verdict into. Present
|
||||
* only on an `/apply` response with `mode: "plugin"`. */
|
||||
runtime_dir?: string;
|
||||
/** Verdict of the most recent plugin-update chain, or null when there has
|
||||
* never been one. `ok: false` is the ONLY way a failed chain becomes
|
||||
* visible - the server that would have reported it is the one that never
|
||||
* came back. Read on every status poll for a plugin install. */
|
||||
last_plugin_update?: {
|
||||
ok: boolean;
|
||||
step: string;
|
||||
error?: string;
|
||||
note?: string;
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
@@ -1316,8 +1388,28 @@ export interface CcConfigChangedPayload {
|
||||
/** Whether the dashboard itself made the write, or an fs watcher observed
|
||||
* an external change (e.g. the user editing a file directly). */
|
||||
source: "dashboard" | "fs";
|
||||
/** Mutation kind; absent for some fs-watcher events that only report scope. */
|
||||
action?: "write" | "delete";
|
||||
/** Mutation kind; absent for some fs-watcher events that only report scope.
|
||||
* Covers the file editor (`write`/`delete`), settings (`patch`), the
|
||||
* `claude plugin …` routes
|
||||
* (`install`/`enable`/`disable`/`update`/`uninstall` for a plugin,
|
||||
* `add`/`remove`/`update` for a marketplace) and the auth profiles
|
||||
* (`save`/`rename`/`activate`/`delete`). Keep in step with the `emitChanged`
|
||||
* call sites in `server/routes/cc-config.js` — the union was previously just
|
||||
* `write | delete`, which every plugin route already contradicted. */
|
||||
action?:
|
||||
| "write"
|
||||
| "delete"
|
||||
| "patch"
|
||||
| "install"
|
||||
| "enable"
|
||||
| "disable"
|
||||
| "update"
|
||||
| "uninstall"
|
||||
| "add"
|
||||
| "remove"
|
||||
| "save"
|
||||
| "rename"
|
||||
| "activate";
|
||||
/** "user" (~/.claude) or "project" (repo's .claude) config root affected.
|
||||
* Lets a listening tab decide whether the change is relevant to its view. */
|
||||
scope?: "user" | "project";
|
||||
@@ -2328,6 +2420,11 @@ export interface Lane {
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
tool: LaneTool;
|
||||
/** Name of the AI Router profile this lane runs against, or null for the
|
||||
* vendor's own API. Unlike `tool` this IS changeable after creation, but
|
||||
* only through `POST /api/lanes/:id/router/apply` — the column and the
|
||||
* lane's own config on disk must move together. */
|
||||
router_profile: string | null;
|
||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||
source_repo: string | null;
|
||||
@@ -2495,6 +2592,8 @@ export type LaneGitFacts =
|
||||
dirty: number;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** Raw `git status --porcelain` lines, capped; the counts stay exact. */
|
||||
files: string[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @file AiRouterConfig.tsx
|
||||
* @description The **AI Router** tab of Settings — CRUD for the named router
|
||||
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
|
||||
* at instead of the vendor's own API.
|
||||
*
|
||||
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one
|
||||
* copies it into that lane's own config, so editing or deleting a profile here
|
||||
* does reach every lane already using it — the server re-applies it, or resets
|
||||
* those lanes to native, and reports any lane it could not touch. That report
|
||||
* is surfaced: a rotated key that silently missed two lanes would leave the
|
||||
* user believing a revoked key is out of use when it is not.
|
||||
*
|
||||
* The built-in `native` row is rendered read-only: it is the vendor's own API,
|
||||
* and it exists so that "go back to native" is the same operation as "use a
|
||||
* router" rather than a special case.
|
||||
*
|
||||
* An API key is write-only here by design — the server never returns one, so
|
||||
* the form shows whether a key is saved, never the key.
|
||||
*
|
||||
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
* ever discovered inside a lane's session otherwise, where the error surfaces
|
||||
* as a broken CLI rather than a bad URL.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
|
||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
apiKey: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
label: "",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<AiRouter[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Not an error — the profile itself saved. It is the lanes that did not all
|
||||
// follow, which the user must be told about rather than left to assume.
|
||||
const [laneWarning, setLaneWarning] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiRouter | null>(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The router's own verdict on the form as it currently stands. Cleared on
|
||||
// every field change: a green "works" left sitting above an edited base URL
|
||||
// is a claim about a configuration that no longer exists.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||
// distinction for the same reason.
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
|
||||
// stable `t`, but nothing in this component's contract guarantees that, and
|
||||
// the moment it changes identity per render this callback does too — which
|
||||
// re-fires the effect below, which calls load(), which renders again. That
|
||||
// loop also clears `error` on every pass, so a failed save would flash and
|
||||
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.aiRouters.list()).items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Any edit to the form invalidates the last verdict — see `testResult`.
|
||||
useEffect(() => setTestResult(null), [form]);
|
||||
|
||||
const startAdd = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: AiRouter) => {
|
||||
setEditing(item);
|
||||
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
||||
setForm({
|
||||
label: item.label,
|
||||
baseUrl: item.baseUrl,
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/** Report any lane the server could not re-point, or clear a stale report. */
|
||||
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
|
||||
setLaneWarning(
|
||||
lanes && lanes.failed.length
|
||||
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
|
||||
* the form cannot show is still testable — the server only honours it while
|
||||
* the base URL is unchanged, so a retyped endpoint needs a retyped key.
|
||||
*/
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.aiRouters.test({
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
||||
...(editing ? { name: editing.name } : {}),
|
||||
});
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
text: result.ok
|
||||
? t("aiRouter.testOk", { model: result.message })
|
||||
: t("aiRouter.testFailed", {
|
||||
status: result.status ?? "—",
|
||||
message: result.message,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
// Send only the tiers that differ from the headline model, so the server
|
||||
// keeps filling the rest from it — that is what makes one symbolic name
|
||||
// enough for a router that wants the same value everywhere.
|
||||
const models: Partial<AiRouterModels> = {};
|
||||
for (const tier of TIERS) {
|
||||
const value = (form.models[tier] || "").trim();
|
||||
if (value && value !== form.model.trim()) models[tier] = value;
|
||||
}
|
||||
const body: AiRouterInput = {
|
||||
label: form.label.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes);
|
||||
else await api.aiRouters.create(body);
|
||||
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: AiRouter) => {
|
||||
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
reportLanes((await api.aiRouters.remove(item.name)).lanes);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<RouterIcon className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiRouter.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiRouter.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{laneWarning && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
|
||||
{laneWarning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
{t("aiRouter.builtinHint")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
||||
{item.baseUrl || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{item.builtin
|
||||
? "—"
|
||||
: item.hasKey
|
||||
? t("aiRouter.keySet")
|
||||
: t("aiRouter.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.edit")} ${item.label}`}
|
||||
onClick={() => startEdit(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.remove")} ${item.label}`}
|
||||
onClick={() => remove(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(loading || items.length <= 1) && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
||||
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
|
||||
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field
|
||||
id="router-label"
|
||||
label={t("aiRouter.fieldLabel")}
|
||||
hint={t("aiRouter.fieldLabelHint")}
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-base-url"
|
||||
label={t("aiRouter.fieldBaseUrl")}
|
||||
hint={t("aiRouter.fieldBaseUrlHint")}
|
||||
value={form.baseUrl}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-api-key"
|
||||
label={t("aiRouter.fieldApiKey")}
|
||||
hint={
|
||||
editing && editing.hasKey
|
||||
? t("aiRouter.fieldApiKeyKeep")
|
||||
: t("aiRouter.fieldApiKeyHint")
|
||||
}
|
||||
value={form.apiKey}
|
||||
type="password"
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-model"
|
||||
label={t("aiRouter.fieldModel")}
|
||||
hint={t("aiRouter.fieldModelHint")}
|
||||
value={form.model}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
{t("aiRouter.perTier")}
|
||||
</summary>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{TIERS.map((tier) => (
|
||||
<Field
|
||||
key={tier}
|
||||
id={`router-model-${tier}`}
|
||||
label={t(`aiRouter.tier.${tier}`)}
|
||||
value={form.models[tier] || ""}
|
||||
mono
|
||||
placeholder={form.model}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
data-testid="router-test-result"
|
||||
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
|
||||
testResult.ok
|
||||
? "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{testResult.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="router-test"
|
||||
onClick={test}
|
||||
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
|
||||
>
|
||||
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
||||
>
|
||||
{t("aiRouter.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{t("aiRouter.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
mono,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
|
||||
mono ? "font-mono" : ""
|
||||
}`}
|
||||
/>
|
||||
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,11 @@ import {
|
||||
CircleSlash,
|
||||
ChevronDown,
|
||||
UserCog,
|
||||
ArrowUpCircle,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FileViewer } from "../components/config/FileViewer";
|
||||
@@ -133,6 +138,7 @@ import type {
|
||||
CcPlugin,
|
||||
CcPluginItem,
|
||||
CcPluginsResponse,
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcScope,
|
||||
@@ -1301,6 +1307,172 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon + colour per batch-step state. */
|
||||
type StepLook = { Icon: typeof CheckCircle2; className: string };
|
||||
const STEP_FALLBACK: StepLook = { Icon: MinusCircle, className: "text-fg-muted" };
|
||||
const STEP_LOOK: Record<string, StepLook> = {
|
||||
running: { Icon: RefreshCw, className: "text-accent animate-spin" },
|
||||
ok: { Icon: CheckCircle2, className: "text-status-success" },
|
||||
failed: { Icon: XCircle, className: "text-status-error" },
|
||||
skipped: { Icon: MinusCircle, className: "text-fg-muted" },
|
||||
};
|
||||
|
||||
/**
|
||||
* "Update all" + "Reload sessions" for the whole Claude Code install.
|
||||
*
|
||||
* The batch runs in a detached server-side worker and is followed by polling
|
||||
* rather than by the POST's response, because the last step restarts the
|
||||
* dashboard when AgentHub is itself a plugin install. Polling therefore has to
|
||||
* tolerate the server disappearing mid-batch: a failed poll is ignored and
|
||||
* retried, never rendered as an error, because the most likely cause is the
|
||||
* successful restart this button asked for.
|
||||
*/
|
||||
function UpdateAllCard({
|
||||
onToast,
|
||||
onChanged,
|
||||
}: {
|
||||
onToast: (t: NonNullable<Toast>) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [batch, setBatch] = useState<CcUpdateAllBatch | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const running = batch?.status === "running";
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.ccConfig.updateAllStatus();
|
||||
setBatch(res.batch);
|
||||
return res.batch;
|
||||
} catch {
|
||||
// Server is down (very likely the AgentHub restart we triggered) — keep
|
||||
// the last known steps on screen and try again on the next tick.
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Pick up a batch already in flight, including one started by the server we
|
||||
// were talking to BEFORE it restarted itself.
|
||||
useEffect(() => {
|
||||
void poll();
|
||||
}, [poll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(() => void poll(), 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [running, poll]);
|
||||
|
||||
// Refresh the surrounding inventory once the batch stops moving, so version
|
||||
// numbers and install paths catch up without a manual reload.
|
||||
const prevStatus = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (prevStatus.current === "running" && batch?.status !== "running") onChanged();
|
||||
prevStatus.current = batch?.status;
|
||||
}, [batch?.status, onChanged]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const res = await api.ccConfig.updateAll();
|
||||
onToast({ kind: "success", message: t("updateAll.started", { count: res.planned }) });
|
||||
await poll();
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : t("updateAll.failed"),
|
||||
});
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [onToast, poll, t]);
|
||||
|
||||
const reloadOnly = useCallback(async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const res = await api.ccConfig.reload();
|
||||
onToast({
|
||||
kind: "success",
|
||||
message:
|
||||
res.lanes_total > 0
|
||||
? t("updateAll.reloaded", { count: res.lanes_total })
|
||||
: t("updateAll.reloadedNone"),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : t("updateAll.failed"),
|
||||
});
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [onToast, t]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-[11px] font-semibold text-fg-secondary">{t("updateAll.title")}</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reloadOnly()}
|
||||
disabled={reloading || running}
|
||||
className="h-8 text-[11px] font-medium px-3 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<RotateCw className={`w-3 h-3 ${reloading ? "animate-spin" : ""}`} />
|
||||
{t("updateAll.reloadButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={starting || running}
|
||||
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
{running || starting ? t("updateAll.working") : t("updateAll.button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">{t("updateAll.help")}</p>
|
||||
|
||||
{batch && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{batch.steps.map((step) => {
|
||||
const look = STEP_LOOK[step.status] ?? STEP_FALLBACK;
|
||||
return (
|
||||
<div key={step.key} className="flex items-start gap-1.5 text-[11px]">
|
||||
<look.Icon className={`w-3 h-3 mt-0.5 shrink-0 ${look.className}`} />
|
||||
<span className="font-mono text-fg-secondary truncate">{step.label}</span>
|
||||
{step.note && <span className="text-fg-muted truncate">— {step.note}</span>}
|
||||
{step.error && (
|
||||
<span className="text-status-error truncate" title={step.error}>
|
||||
— {step.error}
|
||||
</span>
|
||||
)}
|
||||
{step.key === "reload" && step.lanes_total !== undefined && (
|
||||
<span className="text-fg-muted">
|
||||
{t("updateAll.reloadedCount", { count: step.lanes_total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{batch.status === "abandoned" && (
|
||||
<p className="text-[10px] text-status-warning">{t("updateAll.abandoned")}</p>
|
||||
)}
|
||||
{batch.bootstrap_error && (
|
||||
<p className="text-[10px] text-status-error">
|
||||
{t("updateAll.bootstrapFailed", { error: batch.bootstrap_error })}
|
||||
</p>
|
||||
)}
|
||||
{running && <p className="text-[10px] text-fg-muted">{t("updateAll.restartWarning")}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginsPanel({
|
||||
data,
|
||||
search,
|
||||
@@ -1373,6 +1545,8 @@ function PluginsPanel({
|
||||
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
||||
/>
|
||||
|
||||
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
|
||||
|
||||
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
||||
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
||||
{t("manage.installTitle")}
|
||||
@@ -1717,7 +1891,9 @@ function PluginCard({
|
||||
onClick={() =>
|
||||
void run(
|
||||
p.key,
|
||||
() => api.ccConfig.pluginAction(p.key, "update"),
|
||||
// reload=true: updating one plugin reloads the open sessions
|
||||
// right away. The batch reloads once at the end instead.
|
||||
() => api.ccConfig.pluginAction(p.key, "update", true),
|
||||
t("manage.updated", { name: p.key })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -31,6 +32,7 @@ const TOOL_TABS: ToolTab[] = [
|
||||
DASHBOARD_TAB,
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+162
-170
@@ -2,8 +2,8 @@
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -42,23 +42,20 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
Lane,
|
||||
LaneFeature,
|
||||
LaneCounts,
|
||||
ProofFeature,
|
||||
Session,
|
||||
WSMessage,
|
||||
LaneTool,
|
||||
} from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
import LaneCard, {
|
||||
since,
|
||||
LIVENESS_DOT,
|
||||
LaneToolsMenu,
|
||||
LaneRouterRow,
|
||||
} from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
@@ -67,6 +64,14 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||
* each, so the toggle reads at a glance without spending three words on it. */
|
||||
const LAYOUT_CHOICES = [
|
||||
{ n: 1 as const, Icon: Square },
|
||||
{ n: 2 as const, Icon: Columns2 },
|
||||
{ n: 4 as const, Icon: Grid2x2 },
|
||||
];
|
||||
|
||||
function SplitLayoutToggle({
|
||||
splitView,
|
||||
setLayout,
|
||||
@@ -78,21 +83,29 @@ function SplitLayoutToggle({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([1, 2, 4] as const).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{tLanes("splitView.paneCount", { count: n })}
|
||||
</button>
|
||||
))}
|
||||
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
||||
// The count still reaches assistive tech and the tooltip — only the
|
||||
// rendered word is gone, and the icon says the same thing in the
|
||||
// shape every other app uses for it.
|
||||
const label = tLanes("splitView.paneCount", { count: n });
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border p-1.5 ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +195,6 @@ export function Workspace() {
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
@@ -242,7 +254,6 @@ export function Workspace() {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
setCounts(keepIfSame(r.counts));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -494,60 +505,25 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Page header: what this screen is, and the four numbers that say
|
||||
whether anything needs a human right now. */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
||||
{tLanes("title")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
data-testid="count-total"
|
||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
>
|
||||
{counts.total} {tLanes("countTotal")}
|
||||
</span>
|
||||
<span
|
||||
data-testid="count-running"
|
||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
>
|
||||
{counts.running} {tLanes("countRunning")}
|
||||
</span>
|
||||
{counts.needs_you > 0 && (
|
||||
<span
|
||||
data-testid="count-needs-you"
|
||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
||||
>
|
||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
||||
</span>
|
||||
)}
|
||||
{counts.dead > 0 && (
|
||||
<span
|
||||
data-testid="count-dead"
|
||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
||||
>
|
||||
{counts.dead} {tLanes("countDead")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
||||
<button
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
title={tLanes("addLane")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
@@ -576,27 +552,102 @@ export function Workspace() {
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
||||
>
|
||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
||||
the feature picker and the router select used to sit on a second
|
||||
row below this one; every one of them is a per-lane control that
|
||||
belongs beside the lane's own name, and stacking them cost a whole
|
||||
row of height on a panel whose point is the pipeline map further
|
||||
down. The pipeline's NAME is no longer interpolated into
|
||||
`laneHeader` either — the picker right next to it already says it.
|
||||
They stay gated on `showInfo`, same as before the merge. */}
|
||||
{currentLane && (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
pipeline: currentLane.pipeline_name,
|
||||
})}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
title={currentLane.cwd}
|
||||
>
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
})}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
{showInfo && (
|
||||
<>
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `agenthub stage` — that string collides with
|
||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||
actively running but has never declared a stage read as if it
|
||||
were sitting idle. Show a distinct label instead of the raw
|
||||
sentinel whenever it doesn't match any node this pipeline
|
||||
actually has. */}
|
||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||
? currentLane.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||
>
|
||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||
</span>
|
||||
)}
|
||||
{features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
@@ -607,82 +658,23 @@ export function Workspace() {
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `agenthub stage` — that string collides with
|
||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||
actively running but has never declared a stage read as if it
|
||||
were sitting idle. Show a distinct label instead of the raw
|
||||
sentinel whenever it doesn't match any node this pipeline
|
||||
actually has. */}
|
||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||
? currentLane.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||
>
|
||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||
</span>
|
||||
)}
|
||||
{features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file Tests for the AI Router settings tab — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AiRouterConfig } from "../AiRouterConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
|
||||
const nineRouter = {
|
||||
...native,
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
hasKey: true,
|
||||
model: "Highest",
|
||||
models: {
|
||||
opus: "Highest",
|
||||
sonnet: "Highest",
|
||||
haiku: "Highest",
|
||||
smallFast: "Highest",
|
||||
subagent: "Highest",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiRouters.update)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
|
||||
vi.mocked(api.aiRouters.remove)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
|
||||
vi.mocked(api.aiRouters.test)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
|
||||
});
|
||||
|
||||
describe("AiRouterConfig", () => {
|
||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a new profile with the typed endpoint, key and model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ models: { opus: "PremiumCombo" } })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
|
||||
// The API cannot return a key, so an edit that sends `apiKey: ""` would
|
||||
// silently erase the one on disk.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
|
||||
|
||||
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
|
||||
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
|
||||
expect(call?.[1]).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("warns when a rotated key did not reach every lane using the profile", async () => {
|
||||
// The whole point of pushing an edit out to the lanes is that a revoked key
|
||||
// stops being used. A lane that silently kept the old one must be named,
|
||||
// or the user believes a rotation is complete when it is not.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.aiRouters.update).mockResolvedValue({
|
||||
...nineRouter,
|
||||
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
|
||||
});
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a server error instead of closing the form", async () => {
|
||||
vi.mocked(api.aiRouters.create).mockRejectedValue(
|
||||
new Error("baseUrl must be http:// or https://")
|
||||
);
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiRouterConfig — testing an endpoint before saving it", () => {
|
||||
it("sends the typed endpoint, key and model, and reports success", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.test).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
|
||||
});
|
||||
|
||||
it("names the profile being edited so the server can reuse its stored key", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.test).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
name: "9router",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the router's own refusal rather than claiming it works", async () => {
|
||||
vi.mocked(api.aiRouters.test).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: "invalid api key",
|
||||
});
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
|
||||
"aiRouter.testFailed"
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a stale verdict as soon as a field changes", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
||||
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file Covers the "Update all" card on the Claude Code config panel's plugins
|
||||
* tab: it starts the detached batch, renders the worker's journal as it polls,
|
||||
* survives the dashboard restart the batch's last step causes, and keeps the
|
||||
* per-plugin update button reloading only that one plugin.
|
||||
*
|
||||
* The restart case is the one worth a test: while AgentHub replaces itself the
|
||||
* status endpoint stops answering, and a naive implementation would render that
|
||||
* as a failure right at the moment everything is going to plan.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 1,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({
|
||||
manifestPath: "/home/.claude/plugins/installed_plugins.json",
|
||||
manifestExists: true,
|
||||
plugins: [{ key: "ponytail@ponytail", enabled: true, version: "4.9.0" }],
|
||||
}),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcp: r({ mcp: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
profiles: r({ profiles: [], active: null }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
updateAll: r({
|
||||
ok: true,
|
||||
spawned: true,
|
||||
runtime_dir: "/home/.claude/agenthub/runtime",
|
||||
planned: 7,
|
||||
self_plugin: { marketplace: "sg", plugin: "agenthub" },
|
||||
}),
|
||||
reload: r({ ok: true, commands: ["/reload-plugins"], lanes: [], lanes_total: 2 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the plugins tab, where the Update all card lives. */
|
||||
async function openPlugins() {
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: null }) as never;
|
||||
ccConfigMock.reload = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
commands: ["/reload-plugins"],
|
||||
lanes: [],
|
||||
lanes_total: 2,
|
||||
}) as never;
|
||||
});
|
||||
|
||||
describe("CcConfig — Update all", () => {
|
||||
it("starts the batch and reports how many plugins it will walk", async () => {
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /update all/i }));
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.updateAll).toHaveBeenCalledTimes(1);
|
||||
// The count comes from the server's plan, not from the rendered list.
|
||||
expect(await screen.findByText(/7 plugin/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders each step of the worker's journal, including a failure", async () => {
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "partial",
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{ key: "plugin:ponytail@ponytail", label: "ponytail@ponytail", status: "ok" },
|
||||
{
|
||||
key: "plugin:vercel@claude-plugins-official",
|
||||
label: "vercel@claude-plugins-official",
|
||||
status: "failed",
|
||||
error: "marketplace unreachable",
|
||||
},
|
||||
{
|
||||
key: "reload",
|
||||
label: "Reload open Claude Code sessions",
|
||||
status: "ok",
|
||||
lanes_total: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument();
|
||||
expect(screen.getByText("ponytail@ponytail")).toBeInTheDocument();
|
||||
expect(screen.getByText(/marketplace unreachable/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Reload open Claude Code sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the last known steps on screen when the status endpoint stops answering", async () => {
|
||||
// This is what a successful AgentHub self-update looks like from the tab:
|
||||
// the server it was polling is gone. Rendering an error here would be
|
||||
// wrong — the restart IS the batch working.
|
||||
const runningBatch = {
|
||||
batch: {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "ok" }],
|
||||
},
|
||||
};
|
||||
ccConfigMock.updateAllStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(runningBatch)
|
||||
.mockRejectedValue(new Error("Failed to fetch")) as never;
|
||||
|
||||
await openPlugins();
|
||||
expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument();
|
||||
|
||||
// Let the polling interval fire into the rejection.
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 2200));
|
||||
});
|
||||
|
||||
expect(screen.getByText("Update all marketplaces")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Failed to fetch/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables both buttons while a batch is running, so two can't overlap", async () => {
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /updating/i })).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /reload sessions/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("re-enables the button for an abandoned batch instead of spinning forever", async () => {
|
||||
// A detached worker killed by a reboot leaves a `running` journal; the
|
||||
// server reports it as abandoned so the button is usable again.
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "abandoned",
|
||||
started_at: new Date(Date.now() - 7200_000).toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /update all/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("reload-only sends the slash commands without starting a batch", async () => {
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reload sessions/i }));
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.reload).toHaveBeenCalledTimes(1);
|
||||
expect(ccConfigMock.updateAll).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/2 open session/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so plainly when there is no open session to reload", async () => {
|
||||
ccConfigMock.reload = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, commands: [], lanes: [], lanes_total: 0 }) as never;
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reload sessions/i }));
|
||||
await settle();
|
||||
|
||||
expect(await screen.findByText(/no open claude code session/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updating one plugin asks for a reload of just that change", async () => {
|
||||
// The batch reloads once at the end; a single update reloads immediately.
|
||||
await openPlugins();
|
||||
|
||||
const [firstUpdate] = screen.getAllByRole("button", { name: /^update$/i });
|
||||
await userEvent.click(firstUpdate!);
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "update", true);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
},
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,6 +140,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
// The lane card's router picker. Only the built-in profile, which is the
|
||||
// shape that renders no picker — nothing here asserts on it.
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
@@ -320,19 +328,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists lanes and counters match the API response", async () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify lanes are rendered
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
|
||||
// Verify counters are rendered with correct values
|
||||
const counterTexts = screen.getAllByText((_, element) => {
|
||||
if (!element) return false;
|
||||
return element.textContent?.includes("2 lanes") || false;
|
||||
});
|
||||
expect(counterTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -489,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("shows the four counters with the values the API reported", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
||||
});
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
|
||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="space-y-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<h2
|
||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
||||
>
|
||||
Lanes
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
data-testid="count-total"
|
||||
>
|
||||
0
|
||||
|
||||
lanes
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
data-testid="count-running"
|
||||
>
|
||||
0
|
||||
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ml-auto flex items-center gap-2"
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
data-testid="lane-tile-add"
|
||||
title="Create a lane from a working directory"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-3.5 w-3.5"
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
@@ -5182,25 +5151,97 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
aria-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
1 pane
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
2 pane
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
4 pane
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -5208,13 +5249,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
data-testid="console-body"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5231,13 +5272,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-lg font-semibold text-fg-primary"
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
@@ -5251,9 +5292,9 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted max-w-3xl"
|
||||
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Spin up a Claude session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -5298,10 +5339,172 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
@@ -5341,168 +5544,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
@@ -9537,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.5.0 (rebuild client to match)
|
||||
UI build v1.6.7 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
services:
|
||||
agent-monitor:
|
||||
build: .
|
||||
container_name: agent-monitor
|
||||
ports:
|
||||
# Publish on the host loopback only, so the dashboard is not reachable
|
||||
# from the LAN out of the box (matches the server's default trust
|
||||
# boundary — GHSA-gr74-4xfh-6jw9). To expose it on a LAN, change this to
|
||||
# "${DASHBOARD_PORT:-4820}:4820" and set DASHBOARD_TOKEN below.
|
||||
- "127.0.0.1:${DASHBOARD_PORT:-4820}:4820"
|
||||
volumes:
|
||||
# Same SQLite file as `npm start` / `npm run dev` on the host.
|
||||
- ${CLAUDE_HOME:-~/.claude}/agenthub:/app/data
|
||||
- ${CLAUDE_HOME:-~/.claude}:/root/.claude:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DASHBOARD_PORT=4820
|
||||
# Optional — required when a Prometheus-in-Docker scraper reaches this
|
||||
# container as anything other than loopback (e.g. host.docker.internal).
|
||||
- DASHBOARD_ALLOWED_HOSTS=${DASHBOARD_ALLOWED_HOSTS:-}
|
||||
# DASHBOARD_HOST=0.0.0.0 and DASHBOARD_DATA_DIR=/app/data are baked into
|
||||
# the image (see Dockerfile) so both `docker run` and Compose work as-is.
|
||||
restart: unless-stopped
|
||||
+56
-2
@@ -245,7 +245,9 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
|
||||
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
||||
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action.
|
||||
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane'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 lane action
|
||||
|
||||
@@ -1605,7 +1607,10 @@ POST /api/cc-config/project-memory Body: { dir, content }
|
||||
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
|
||||
GET /api/cc-config/plugins/available
|
||||
POST /api/cc-config/plugins/install Body: { spec, scope? }
|
||||
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall }
|
||||
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall, reload? }
|
||||
GET /api/cc-config/plugins/update-all
|
||||
POST /api/cc-config/plugins/update-all Body: { reload? }
|
||||
POST /api/cc-config/reload Body: { kinds?: ["plugins"|"skills"] }
|
||||
POST /api/cc-config/marketplaces/add Body: { source, scope? }
|
||||
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
||||
POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? }
|
||||
@@ -1616,6 +1621,18 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
|
||||
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
|
||||
|
||||
### Updating everything, and reloading sessions
|
||||
|
||||
`POST /api/cc-config/plugins/update-all` refreshes every marketplace, then updates every installed plugin, then tells the open Claude Code sessions to pick the changes up. It **returns as soon as a detached worker is spawned** — the response is an acknowledgement, not a result — and progress is polled from `GET` on the same path, which reads the worker's journal at `~/.claude/agenthub/runtime/cc-update-all.json`.
|
||||
|
||||
That indirection is structural, not stylistic: the plugin this dashboard *itself* runs from is updated **last**, followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement. A request handler doing the work would die partway through with the remaining plugins unrun and nothing able to report why, so the journal lives outside the plugin cache the update replaces and whichever server is alive serves it. Plugin ids come from `claude plugin list --json`, never from the request body, and the "self" plugin is derived from the server's own path — no caller can nominate a different plugin for the restart. A second concurrent batch is refused with 409 `EBUSY`.
|
||||
|
||||
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
|
||||
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
|
||||
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
|
||||
|
||||
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
|
||||
|
||||
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
|
||||
@@ -1701,6 +1718,43 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
|
||||
|
||||
---
|
||||
|
||||
### AI Router profiles
|
||||
|
||||
```
|
||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||||
DELETE /api/ai-routers/:name Delete one saved profile
|
||||
```
|
||||
|
||||
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
|
||||
|
||||
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a lane happens in the "+ Add lane" modal, or later from the lane's card in Workspace.
|
||||
|
||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||||
|
||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||
|
||||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude lane's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
||||
|
||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||
|
||||
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
||||
|
||||
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
|
||||
|
||||
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
|
||||
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
|
||||
|
||||
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
|
||||
|
||||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
|
||||
|
||||
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
### Connection
|
||||
|
||||
+2
-2
@@ -272,12 +272,12 @@ Because a lane's services are fully detached, restarting the dashboard (or `agen
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections |
|
||||
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
|
||||
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
|
||||
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
|
||||
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
|
||||
| `agenthub reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
|
||||
| `agenthub update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. When the checkout is fast-forwardable, the dashboard UI can also self-apply (`POST /api/updates/apply`: pull, rebuild, self-restart) instead of running the command by hand. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
||||
| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (a checkout derives the repo from its `origin` remote; a plugin-cache install, which has no clone and often no `.git` at all, derives it from the marketplace manifest on disk); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
|
||||
| `agenthub clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
|
||||
| `agenthub open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
|
||||
| `agenthub version` | Print the agenthub version (also `--version` / `-v`) |
|
||||
|
||||
+11
-5
@@ -64,7 +64,7 @@ graph TB
|
||||
```
|
||||
|
||||
**Database Location:**
|
||||
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, Docker (bind mount), and the desktop app when it uses the same data dir
|
||||
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, and the desktop app when it uses the same data dir
|
||||
- **Override:** set `DASHBOARD_DATA_DIR` (directory) or `DASHBOARD_DB_PATH` (file path) for tests or custom deployments
|
||||
- **Legacy:** repo-local `./data/dashboard.db` is migrated into the canonical location on first launch (see `server/db.js`)
|
||||
- **Pre-rebrand:** an install that predates AgentHub keeps its data in `~/.claude/agent-dashboard/`. Server startup renames that directory to `~/.claude/agenthub/` once, before anything opens the database (`migrateLegacyDataDir()` in `server/lib/claude-home.js`). It is a same-filesystem rename, so it is instant even at multiple gigabytes and never leaves two copies. If the rename fails, or `DASHBOARD_DATA_DIR` is set, nothing moves and the old directory keeps being used.
|
||||
@@ -430,11 +430,17 @@ CREATE TABLE model_pricing (
|
||||
|
||||
Standard rates and intro rates are edited independently: the pricing update path writes intro columns only when the caller sends intro fields, so a standard-rate edit never disturbs a promo (and vice versa). Clearing `intro_until` also zeroes the intro rates.
|
||||
|
||||
**Example default rule (Claude Sonnet 5, with its launch promo):**
|
||||
**Example default rules:**
|
||||
|
||||
| Pattern | Input | Output | Intro Input | Intro Output | Intro Until |
|
||||
|---------|-------|--------|-------------|--------------|-------------|
|
||||
| `claude-sonnet-5%` | $3.00 | $15.00 | $2.00 | $10.00 | `2026-08-31` |
|
||||
| Pattern | Input | Output | Cache read | Fast in/out |
|
||||
|---------|-------|--------|------------|-------------|
|
||||
| `claude-opus-5%` | $5.00 | $25.00 | $0.50 | $10 / $50 |
|
||||
| `claude-sonnet-5%` | $2.00 | $10.00 | $0.20 | — |
|
||||
| `claude-fable-5-1%` | $10.00 | $50.00 | $0.25 | — |
|
||||
|
||||
No rule ships an intro rate today: Sonnet 5's launch promo ran through `2026-08-31` and its discounted rate became the standard one, so `DEFAULT_INTRO_PRICING` is empty. `claude-fable-5-1%` exists alongside `claude-fable-5%` only because 5.1 caches cheaper — that overlap is what the longest-pattern-first match rule above resolves.
|
||||
|
||||
**Retired models.** Claude 3.x, Opus 4/4.1 and Sonnet 4 were dropped from the defaults. Because the startup top-up only *adds* missing rows, those rows are also deleted once from existing databases, guarded by `PRAGMA user_version` (0 → 1) so a model you deliberately re-add in Settings survives later restarts. The same one-shot migration corrects the pre-existing Sonnet 5 row to its post-promo $2 / $10 standard rate. Deleting a rule never touches `token_usage` — only the rates used to price it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-117
@@ -10,7 +10,6 @@ Enterprise deployment strategies for Agent Dashboard across development, staging
|
||||
- [Deployment Architecture](#deployment-architecture)
|
||||
- [Local Development](#local-development)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Cloud Deployment](#cloud-deployment)
|
||||
- [Process Management](#process-management)
|
||||
- [Monitoring & Logging](#monitoring--logging)
|
||||
@@ -26,7 +25,6 @@ Enterprise deployment strategies for Agent Dashboard across development, staging
|
||||
Agent Dashboard supports multiple deployment modes:
|
||||
|
||||
- **Local Development** - Hot reload for rapid iteration
|
||||
- **Docker** - Containerized deployment with Docker/Podman
|
||||
- **PM2** - Process management for production
|
||||
- **Systemd** - System service on Linux
|
||||
- **Cloud** - Deploy to AWS, Azure, GCP, or other cloud providers
|
||||
@@ -35,7 +33,6 @@ Agent Dashboard supports multiple deployment modes:
|
||||
graph TB
|
||||
subgraph "Deployment Modes"
|
||||
Dev[Local Development<br/>npm run dev]
|
||||
Docker[Docker Container<br/>docker compose up]
|
||||
PM2[PM2 Process Manager<br/>pm2 start]
|
||||
Systemd[Systemd Service<br/>systemctl start]
|
||||
Cloud[Cloud Platform<br/>Kubernetes, ECS, etc.]
|
||||
@@ -48,8 +45,6 @@ graph TB
|
||||
end
|
||||
|
||||
Dev --> DevEnv
|
||||
Docker --> DevEnv
|
||||
Docker --> StagingEnv
|
||||
PM2 --> ProdEnv
|
||||
Systemd --> ProdEnv
|
||||
Cloud --> ProdEnv
|
||||
@@ -144,6 +139,11 @@ graph TB
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- npm >= 9.0.0
|
||||
- git — every lane operation shells out to it
|
||||
- tmux — only for the browser run console and `agenthub lanes shell`; both
|
||||
execute the binary directly and have no fallback. Per-OS install commands and
|
||||
the two-sided check (your PATH vs the server's) are in
|
||||
[INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages).
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -260,118 +260,14 @@ CODEX_HOME= # where Codex keeps sessions/ (default ~
|
||||
LOG_LEVEL=info # debug | info | warn | error
|
||||
```
|
||||
|
||||
> **Reverse-proxy / Docker exposure:** the app binds loopback, so publish it to a
|
||||
> **Reverse-proxy / container exposure:** the app binds loopback, so publish it to a
|
||||
> network only through a proxy you control that adds TLS + auth, or set
|
||||
> `DASHBOARD_HOST=0.0.0.0` **with** `DASHBOARD_TOKEN`. A `-p 4820:4820` Docker
|
||||
> mapping assumes a trusted host network — do not expose it publicly without a
|
||||
> `DASHBOARD_HOST=0.0.0.0` **with** `DASHBOARD_TOKEN`. A published `4820` port
|
||||
> assumes a trusted host network — do not expose it publicly without a
|
||||
> token and a proxy.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
agenthub:
|
||||
build: .
|
||||
ports:
|
||||
- "4820:4820"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=4820
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4820/api/sessions"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker compose build
|
||||
|
||||
# Start container
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop container
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Multi-Stage Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
COPY client/package*.json ./client/
|
||||
RUN npm ci && cd client && npm ci
|
||||
|
||||
# Build client
|
||||
COPY client ./client
|
||||
RUN cd client && npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY server ./server
|
||||
COPY package.json ./
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 4820
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:4820/api/sessions', (res) => process.exit(res.statusCode === 200 ? 0 : 1))"
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
|
||||
### Container Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Docker Host"
|
||||
subgraph "Container"
|
||||
App[Node.js App<br/>:4820]
|
||||
Volume[Volume Mount<br/>/app/data]
|
||||
end
|
||||
end
|
||||
|
||||
Host[Host Filesystem<br/>./data] -->|Bind Mount| Volume
|
||||
App --> Volume
|
||||
|
||||
Client[External Clients] -->|Port 4820| App
|
||||
|
||||
style App fill:#2496ED,color:#fff
|
||||
style Volume fill:#FFA500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloud Deployment
|
||||
|
||||
### AWS (Elastic Beanstalk)
|
||||
@@ -395,6 +291,9 @@ eb open
|
||||
|
||||
### Kubernetes
|
||||
|
||||
The repo ships no container image or `Dockerfile` — build your own from
|
||||
`npm ci && npm run build` and push it to your registry, then reference it here.
|
||||
|
||||
```yaml
|
||||
# k8s/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
@@ -557,7 +456,10 @@ Environment=NODE_ENV=production
|
||||
Environment=PORT=4820
|
||||
Environment=DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
|
||||
ExecStart=/usr/bin/node server/index.js
|
||||
Restart=on-failure
|
||||
# `always`, not `on-failure`: a graceful SIGTERM exits 0, which `on-failure`
|
||||
# reads as "meant to stop" — one stray kill and the dashboard stays down.
|
||||
# `systemctl stop` still stops it. Same reasoning as the plugin's own unit.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -603,19 +505,16 @@ graph TB
|
||||
subgraph "Log Aggregation"
|
||||
Stdout --> PM2[PM2 Logs]
|
||||
Stdout --> Systemd[Systemd Journal]
|
||||
Stdout --> Docker[Docker Logs]
|
||||
end
|
||||
|
||||
subgraph "Monitoring Tools"
|
||||
PM2 --> File[Log Files]
|
||||
Systemd --> Journalctl[journalctl]
|
||||
Docker --> DockerLogs[docker logs]
|
||||
end
|
||||
|
||||
subgraph "Analysis"
|
||||
File --> Splunk[Splunk/ELK]
|
||||
Journalctl --> Splunk
|
||||
DockerLogs --> Splunk
|
||||
end
|
||||
|
||||
style App fill:#000000,color:#fff
|
||||
@@ -912,7 +811,7 @@ NODE_ENV=development node server/index.js
|
||||
|
||||
This deployment guide covers:
|
||||
|
||||
- ✅ **Multiple deployment modes** - Local, Docker, PM2, Systemd, Cloud
|
||||
- ✅ **Multiple deployment modes** - Local, PM2, Systemd, Cloud
|
||||
- ✅ **Production best practices** - Environment variables, health checks, logging
|
||||
- ✅ **Process management** - PM2, systemd service files
|
||||
- ✅ **Monitoring & logging** - Metrics endpoint, log aggregation
|
||||
|
||||
+2
-2
@@ -578,8 +578,8 @@ function sendToServer(hookType, payload) {
|
||||
> Each server appends its `{port, pid, startedAt, dataDir}` entry on
|
||||
> startup and removes it on a clean shutdown. The handler **prunes any
|
||||
> entry whose PID is no longer alive** and POSTs the hook payload to one
|
||||
> port per **unique SQLite data directory** (lowest port wins when Docker
|
||||
> and `npm run dev` share `~/.claude/agenthub`).
|
||||
> port per **unique SQLite data directory** (lowest port wins when two
|
||||
> instances share `~/.claude/agenthub`).
|
||||
> 3. If neither yields a target, the handler falls back to `4820`.
|
||||
>
|
||||
> Dashboards with **different** databases (e.g. the packaged desktop app using
|
||||
|
||||
+18
-6
@@ -43,6 +43,10 @@ The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
|
||||
|
||||
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
|
||||
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
## Lane tool: Claude or Codex
|
||||
@@ -366,11 +370,13 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name, time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar 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`, the needs-you banner, and the action row.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane 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`, the needs-you banner, and the action row.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
@@ -438,7 +444,8 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1
|
||||
"untracked": 1,
|
||||
"files": [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -447,6 +454,11 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
is a normal state, not a fault, and the card simply renders without a git row.
|
||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||
prettier invention.
|
||||
- `files` holds the raw `git status --porcelain` lines the two counts came from,
|
||||
**capped at 20** — the card hovers them as a tooltip on the
|
||||
`N modified · N untracked` text, and a lane mid-`npm install` would otherwise
|
||||
ship thousands of paths through a polled endpoint. The counts are never
|
||||
capped, so the tooltip appends `… +N` when it is showing less than all of it.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
@@ -613,7 +625,7 @@ agenthub stage implement --evidence "Schema created, migrations run"
|
||||
This command:
|
||||
- **Declares the stage** the work is now at
|
||||
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
||||
- **Moves the progress bar** forward in the pipeline
|
||||
- **Advances the pipeline map** to that node
|
||||
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
||||
|
||||
### Full command signature
|
||||
@@ -1512,7 +1524,7 @@ A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED
|
||||
agenthub lanes integration tracker # exit 0 = on, 1 = off
|
||||
```
|
||||
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real.
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
|
||||
|
||||
### Post-merge CI status: agenthub lanes ci status
|
||||
|
||||
|
||||
+134
-12
@@ -1,6 +1,6 @@
|
||||
# AgentHub — Plugin Marketplace
|
||||
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server, `agenthub` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
|
||||
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
|
||||
|
||||
@@ -129,13 +129,14 @@ milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
2. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
3. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Legacy hook cleanup** — see above.
|
||||
6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
7. **UI build** — `client/` is copied into the runtime dir and built there (`npm install && npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (API and MCP still work, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed.
|
||||
8. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
|
||||
of a bare spawn, so the server is supervised and comes back after a reboot
|
||||
with no Claude Code session involved. Everywhere else (macOS, Windows, or
|
||||
@@ -143,7 +144,11 @@ waits on an install:
|
||||
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
|
||||
`client-dist` — scoped to this session, same as before this fell back to
|
||||
systemd.
|
||||
9. **Record state** — `runtime/state.json`.
|
||||
10. **Record state** — `runtime/state.json`.
|
||||
11. **Prune retired cache copies** — delete the plugin-cache directories earlier updates left behind (see [Pruning the plugin cache](#pruning-the-plugin-cache)). After step 10, so a prune that goes wrong cannot also cost the record of a bootstrap that went right.
|
||||
12. **Hand over to systemd** — only when a unit of ours already owns the server
|
||||
(see [Who may stop the server](#who-may-stop-the-server)). Deliberately
|
||||
after step 10.
|
||||
|
||||
The first run takes a few minutes — most of it is the UI build, which is what
|
||||
makes client-only routes (`http://localhost:4820/run`, and every other page)
|
||||
@@ -152,6 +157,66 @@ goes to `~/.claude/agenthub/runtime/bootstrap.log`; `npm run build`'s own
|
||||
output goes to `client-build.log` next to it; the server's own output goes to
|
||||
`server.log`.
|
||||
|
||||
### A retired build never acts
|
||||
|
||||
`claude plugin update` unpacks the new version into a **new** cache directory
|
||||
and prints "Restart to apply changes". A Claude Code process that was already
|
||||
running does not: it keeps loading the OLD directory — skills, commands and
|
||||
hooks included — until it is restarted.
|
||||
|
||||
That used to be self-inflicting. Every session such a process opened ran the
|
||||
OLD `plugin-bootstrap.js`, which saw a `state.json` naming a different root,
|
||||
concluded the server had to be replaced, repointed `agenthub.service` at itself
|
||||
and restarted the dashboard onto the version the user had just upgraded away
|
||||
from. The dashboard then reported the old version and offered the update again
|
||||
— once per session, forever, while `installed_plugins.json` and the cache both
|
||||
said the new version was installed. A `/agenthub-update` from that session
|
||||
"succeeded" and changed nothing.
|
||||
|
||||
So the bootstrap now asks first (`supersededBy`): if Claude Code's
|
||||
`~/.claude/plugins/installed_plugins.json` records a **sibling** directory as
|
||||
the installed build of this plugin, this build logs one line and returns
|
||||
`superseded` without touching dependencies, the unit, or the server. `--force`
|
||||
does not override it — the self-update chain runs `--force` from the NEW
|
||||
directory, which is never superseded, so the only `--force` this can refuse is
|
||||
one that would deliberately downgrade.
|
||||
|
||||
It cannot hand over to the new build either: the process it would have to
|
||||
replace is the one loading it. Restarting Claude Code is the fix, and the log
|
||||
line says so.
|
||||
|
||||
Matching is by sibling directory rather than by the `<plugin>@<marketplace>`
|
||||
key, which holds without parsing a cache path or trusting a name. An entry
|
||||
pointing at a directory that no longer exists is ignored — a phantom must never
|
||||
wedge a bootstrap shut.
|
||||
|
||||
### Pruning the plugin cache
|
||||
|
||||
Claude Code unpacks every update beside the last one and does not reliably
|
||||
remove the old. A machine that tracks releases closely accumulates a full copy
|
||||
of this repo — `node_modules` and all — per release; a dozen of them is
|
||||
gigabytes.
|
||||
|
||||
Step 11 of the bootstrap (`pruneRetiredPluginRoots`) deletes them. This is the
|
||||
only place anything in the plugin touches the cache, and the rule it appears to
|
||||
break ("never write into the plugin cache") is about the *runtime* living
|
||||
outside it, not about keeping retired copies forever.
|
||||
|
||||
Four independent readings of "still in use" are protected, and nothing matching
|
||||
any of them is ever deleted:
|
||||
|
||||
| Protected | Why |
|
||||
|---|---|
|
||||
| This build | It is running. |
|
||||
| Every `installPath` in `installed_plugins.json`, at any scope | Claude Code says it is installed. |
|
||||
| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. |
|
||||
| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. |
|
||||
|
||||
Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback
|
||||
cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
|
||||
a git checkout the parent directory holds the user's own work. Best effort
|
||||
throughout: a directory that will not delete is left alone, never an error.
|
||||
|
||||
### Auto-start on boot
|
||||
|
||||
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
|
||||
@@ -172,11 +237,68 @@ On macOS, Windows, or a Linux box without systemd, nothing changes: the
|
||||
server is a detached spawn started fresh on the next `SessionStart` after a
|
||||
reboot, same as always.
|
||||
|
||||
### Who may stop the server
|
||||
|
||||
**When a `agenthub.service` unit of ours exists, nothing else may stop the
|
||||
dashboard.** The bootstrap that performs a self-update is spawned by the
|
||||
running server, so it lives inside that server's systemd cgroup. SIGTERM-ing
|
||||
the server from there breaks two ways at once:
|
||||
|
||||
- the server exits **0**, and the unit's old `Restart=on-failure` read a clean
|
||||
exit as "meant to stop" — so systemd did not bring it back;
|
||||
- `KillMode=control-group` (systemd's default) reaps **every** process in the
|
||||
cgroup when the main process exits, including the bootstrap itself — mid-run,
|
||||
before it could repoint the unit at the new plugin root or write
|
||||
`runtime/state.json`.
|
||||
|
||||
The result was a dashboard that went down on every self-update and stayed down
|
||||
until some later Claude Code session happened to bootstrap again, leaving a
|
||||
stale `.bootstrap.lock` and a `state.json` still naming the previous version.
|
||||
|
||||
So the order is fixed, and `scripts/systemd-service.js` splits its API to
|
||||
enforce it:
|
||||
|
||||
| Situation | What the bootstrap does |
|
||||
|---|---|
|
||||
| A unit of ours owns the server (`unitIsOurs`) | Never stops it. `writeUserService` repoints the unit at the new plugin root **without starting anything**, the rest of the bootstrap runs, state is written, the lock is released — and only then `restartUserService` asks systemd to restart, with `--no-block` so being killed by its own request costs nothing. |
|
||||
| No unit, or a foreign one | Unchanged: stop the old server, then `installUserService` (or a detached spawn where there is no systemd). Nothing here is inside a unit's cgroup, so the stop is safe. |
|
||||
|
||||
The unit is written with **`Restart=always`**, not `on-failure`: a graceful
|
||||
SIGTERM from anything at all would otherwise leave the dashboard down for good.
|
||||
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
|
||||
the Settings panel do — still stops it.
|
||||
|
||||
`agenthub service install` (the checkout path) keeps using `installUserService`:
|
||||
it runs from your own shell, outside the unit's cgroup, so it may block and
|
||||
start in one step.
|
||||
|
||||
**A unit pointing at a retired plugin root repairs itself on the next session.**
|
||||
This is the residue an interrupted update leaves behind, and it is invisible
|
||||
while the dashboard is up: the server runs fine, every doctor row reads OK, and
|
||||
then a reboot starts a cache directory Claude Code deleted during the update —
|
||||
so the dashboard never comes back, for a reason nothing recorded. The fast path
|
||||
therefore checks it (`unitIsStale`, pure fs — it may not spawn a process on
|
||||
every `SessionStart`) and refuses to short-circuit, and the bootstrap rewrites
|
||||
the unit.
|
||||
|
||||
Repairing the *unit* is not restarting the *server*: when the running server is
|
||||
current and healthy, the corrected unit is simply left to take effect at the
|
||||
next start (`pendingSystemdRestart = systemdOwned && needsServer`). Restarting
|
||||
a working dashboard to fix a file that only matters at boot would be churn the
|
||||
user feels for nothing. For the same reason the detached-spawn fallback is
|
||||
guarded by `needsServer` — entering that branch for a stale unit alone would
|
||||
start a second server onto a port the live one already holds.
|
||||
|
||||
`systemdOwned` requires the unit to be **active**, not merely present: an
|
||||
installed-but-stopped unit owns nothing, and treating it as the owner would
|
||||
skip the stop and then ask systemd to bind an occupied port — a crash loop,
|
||||
courtesy of `Restart=always`.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **skill plugins** (see below), MCP build freshness, UI bundle |
|
||||
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle |
|
||||
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
|
||||
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes |
|
||||
| `/ship-full <requirement>` | Put the lane on the 16-node `ship-feature` template, then run `/agenthub:ship-feature-lane` **in this session**. The in-session twin of `agenthub ship-full` (which spawns its own `claude` from a terminal) |
|
||||
@@ -268,8 +390,8 @@ not assumed. It does **not**:
|
||||
For a clean machine, do all of this yourself:
|
||||
|
||||
```bash
|
||||
# 1. Stop the server. On Linux, check for the systemd unit first — a bare
|
||||
# `kill` just gets restarted by it (Restart=on-failure).
|
||||
# 1. Stop the server. On Linux, disable the systemd unit FIRST — the unit is
|
||||
# Restart=always, so a bare `kill` is always undone by systemd.
|
||||
systemctl --user disable --now agenthub.service 2>/dev/null
|
||||
rm -f ~/.config/systemd/user/agenthub.service && systemctl --user daemon-reload 2>/dev/null
|
||||
# Fallback for a detached (non-systemd) server, or if the unit above was absent:
|
||||
|
||||
+7
-5
@@ -70,10 +70,12 @@ graph LR
|
||||
|
||||
**Quick Start:**
|
||||
|
||||
1. Read [SETUP.md](../SETUP.md)
|
||||
2. Run `npm run setup`
|
||||
3. Run `npm run dev`
|
||||
4. Open browser to `http://localhost:5173`
|
||||
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
|
||||
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
|
||||
2. Read [SETUP.md](../SETUP.md)
|
||||
3. Run `npm run setup`
|
||||
4. Run `npm run dev`
|
||||
5. Open browser to `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
@@ -429,7 +431,7 @@ This documentation covers:
|
||||
|
||||
- ✅ **Complete architecture** - Frontend, backend, database, integrations
|
||||
- ✅ **API reference** - REST endpoints, WebSocket events
|
||||
- ✅ **Deployment guides** - Docker, PM2, systemd, cloud
|
||||
- ✅ **Deployment guides** - PM2, systemd, cloud
|
||||
- ✅ **Performance tuning** - Database, server, client optimizations
|
||||
- ✅ **Integration guides** - Hooks, MCP, custom clients
|
||||
- ✅ **Internationalization** - Language resources, switching flow, locale formatting, rollout checklist
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# AI Router profiles — design
|
||||
|
||||
## Goal
|
||||
|
||||
Configure named AI Router endpoints (9Router, OpenRouter, …) once in Settings,
|
||||
then pick one when creating a lane so that lane's Claude Code or Codex session
|
||||
talks to the router instead of the vendor's own API — without hand-editing a
|
||||
settings file per project, and without a second copy of the whole settings file
|
||||
drifting out of sync with the first.
|
||||
|
||||
## What the reference setup showed
|
||||
|
||||
The user today keeps two whole files, `~/.claude/settings.json` (native) and
|
||||
`~/.claude/settings2.json` (9Router), and swaps them. Diffing the two:
|
||||
|
||||
- They differ in exactly one place that matters — the `env` block, 8 keys:
|
||||
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`,
|
||||
`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
|
||||
`ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,
|
||||
`CLAUDE_CODE_SUBAGENT_MODEL`.
|
||||
- Every other difference (`hooks`, `enabledPlugins`, `statusLine`,
|
||||
`effortLevel`, …) is drift between two hand-maintained copies. Removing that
|
||||
drift is half the point of this feature.
|
||||
- "Native" is **not** the absence of the connection variables: the native file
|
||||
sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` to empty strings and
|
||||
carries real `claude-*` model names.
|
||||
- The 9Router file sets all six model slots to one symbolic value (`Highest`),
|
||||
which the router resolves. Model values are therefore free text, and "one
|
||||
value for every tier" is the common case, not the exception.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **The Settings tab only stores profiles.** It does not attach anything.
|
||||
Attaching happens when a lane is created.
|
||||
2. **Native is a built-in profile, not a code branch.** Its `baseUrl`/`apiKey`
|
||||
are empty and its six model slots hold the native `claude-*` names. It cannot
|
||||
be edited or deleted. Switching native ↔ router is therefore one write path
|
||||
with no special case.
|
||||
3. **A profile carries six model slots plus an "apply to all" convenience
|
||||
field**, mirroring exactly the env keys Claude Code reads.
|
||||
4. **The API key never leaves the server.** Profiles are stored one JSON file
|
||||
each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, file `0600`),
|
||||
following `cc-profiles.js`. `GET` returns metadata plus `hasKey: true`,
|
||||
never the key.
|
||||
5. **A lane's router is fixed at creation**, like `lanes.tool`: a new nullable
|
||||
`lanes.router_profile` column, deliberately absent from `PATCHABLE`.
|
||||
6. **AgentHub never reads `~/.9router/db.json`.** That is another application's
|
||||
private store; parsing it would couple this dashboard to a format its owner
|
||||
is free to change. The user pastes a base URL and a key, as they do today.
|
||||
|
||||
## Attaching, per tool
|
||||
|
||||
The two tools differ because the tools themselves differ, not by choice.
|
||||
|
||||
**Claude lane** — write the profile's eight env keys into
|
||||
`<lane.cwd>/.claude/settings.local.json`, merging into any `env` already there
|
||||
and leaving every other key in the file untouched. `settings.local.json` rather
|
||||
than `settings.json` because the value written includes an API key and that file
|
||||
is the one Claude Code's own convention keeps out of git.
|
||||
|
||||
**Codex lane** — Codex has no project-level config: `codex doctor` reports one
|
||||
loaded file, `~/.codex/config.toml`. It does have
|
||||
`-p, --profile <name>`, documented as *"Layer `$CODEX_HOME/<name>.config.toml`
|
||||
on top of the base user config"*. So each router profile is written once to
|
||||
`~/.codex/<name>.config.toml` (`model_provider` plus a `[model_providers.<id>]`
|
||||
table with `base_url`, `env_key`, `wire_api`), and the lane's spawn adds
|
||||
`-p <name>`. The base `~/.codex/config.toml` is never modified. Codex reads the
|
||||
key from the environment variable named by `env_key`, so the spawn also injects
|
||||
that variable into the tmux pane — `pty-run.js` already passes an `env` map to
|
||||
`tmux new-session -e`.
|
||||
|
||||
## Surface
|
||||
|
||||
- `server/lib/ai-routers.js` — store: list/get/create/update/remove, name
|
||||
validation, `0700`/`0600`, key never returned by the list shape.
|
||||
- `server/lib/router-apply.js` — the two writers above, taking a lane row and a
|
||||
profile. Target directories come from the lane row in the DB, never from a
|
||||
request body — the same narrowing every other lane write path uses.
|
||||
- `server/routes/ai-routers.js` → `/api/ai-routers`: `GET /`, `POST /`,
|
||||
`PATCH /:name`, `DELETE /:name`, reusing `cc-profiles.js`'s error-code→status
|
||||
table.
|
||||
- `POST /api/lanes/:id/router/apply` — a fourth member of the auto-setup set the
|
||||
Add-lane modal already fires (`profileInit`, `agentsInstall`, `mcpSync`).
|
||||
- `client/src/pages/AiRouterConfig.tsx` + one line in `SettingsHub.tsx`'s
|
||||
`TOOL_TABS`, placed after Codex.
|
||||
- `AddLaneModal`: a Native / AI Router radio, and a profile select shown only
|
||||
when AI Router is chosen.
|
||||
- DB: `ALTER TABLE lanes ADD COLUMN router_profile TEXT`, nullable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Changing an existing lane's router (create-time only, exactly like `tool`),
|
||||
attaching at user/global scope, editing Codex's base `config.toml`, and reading
|
||||
any router vendor's own configuration store.
|
||||
|
||||
## Verification
|
||||
|
||||
Server tests against a temporary `CLAUDE_HOME`/`CODEX_HOME`: store CRUD and its
|
||||
permission bits, the key never appearing in a list response, the
|
||||
`settings.local.json` merge preserving unrelated keys, and the generated
|
||||
`<name>.config.toml`. Client tests: the tab renders and creates a profile, and
|
||||
the Add-lane radio sends the chosen profile only when AI Router is selected.
|
||||
|
||||
The load-bearing assumption — a project-level `settings.local.json` `env` block
|
||||
overrides the user-level one per key — is confirmed by the user from their own
|
||||
earlier testing.
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Hexagon background -->
|
||||
<polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="url(#bg)" rx="2"/>
|
||||
<!-- Center node -->
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95"/>
|
||||
<!-- Three connector lines radiating outward -->
|
||||
<line x1="16" y1="13" x2="16" y2="7" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="18.6" y1="17.5" x2="24" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="13.4" y1="17.5" x2="8" y2="20.5" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="16" cy="6" r="1.8" fill="url(#glow)"/>
|
||||
<circle cx="24.5" cy="21" r="1.8" fill="url(#glow)"/>
|
||||
<circle cx="7.5" cy="21" r="1.8" fill="url(#glow)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
-109
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* @file fonts.css
|
||||
* @description Self-hosted Inter + JetBrains Mono @font-face declarations — no Google Fonts dependency.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/* Inter */
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-400-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-500-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-600-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-700-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-800-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("inter-latin-900-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* JetBrains Mono */
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("jetbrains-mono-latin-400-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("jetbrains-mono-latin-500-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("jetbrains-mono-latin-600-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("jetbrains-mono-latin-700-normal.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "claude-code-agent-monitor-landing",
|
||||
"name": "AgentHub",
|
||||
"short_name": "AgentHub",
|
||||
"description": "Local-first monitoring dashboard for Claude Code sessions.",
|
||||
"author": "Nguyễn Ngọc Trí Vĩ",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#6366f1",
|
||||
"background_color": "#07070b",
|
||||
"orientation": "any",
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0d0d16"/>
|
||||
<stop offset="100%" stop-color="#12121e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hexGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="nodeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="titleGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#e2e2f0"/>
|
||||
<stop offset="55%" stop-color="#a5b4fc"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#10b981" stop-opacity="0.10"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="blur1">
|
||||
<feGaussianBlur stdDeviation="40"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Ambient glow blobs -->
|
||||
<ellipse cx="200" cy="200" rx="340" ry="280" fill="url(#glow1)" filter="url(#blur1)"/>
|
||||
<ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow2)" filter="url(#blur1)"/>
|
||||
|
||||
<!-- Grid dots (subtle background texture) -->
|
||||
<g opacity="0.07" fill="#6366f1">
|
||||
<circle cx="80" cy="80" r="1.5"/><circle cx="160" cy="80" r="1.5"/><circle cx="240" cy="80" r="1.5"/><circle cx="320" cy="80" r="1.5"/>
|
||||
<circle cx="80" cy="160" r="1.5"/><circle cx="160" cy="160" r="1.5"/><circle cx="240" cy="160" r="1.5"/><circle cx="320" cy="160" r="1.5"/>
|
||||
<circle cx="80" cy="240" r="1.5"/><circle cx="160" cy="240" r="1.5"/><circle cx="240" cy="240" r="1.5"/><circle cx="320" cy="240" r="1.5"/>
|
||||
<circle cx="900" cy="400" r="1.5"/><circle cx="980" cy="400" r="1.5"/><circle cx="1060" cy="400" r="1.5"/><circle cx="1140" cy="400" r="1.5"/>
|
||||
<circle cx="900" cy="480" r="1.5"/><circle cx="980" cy="480" r="1.5"/><circle cx="1060" cy="480" r="1.5"/><circle cx="1140" cy="480" r="1.5"/>
|
||||
<circle cx="900" cy="560" r="1.5"/><circle cx="980" cy="560" r="1.5"/><circle cx="1060" cy="560" r="1.5"/><circle cx="1140" cy="560" r="1.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Large background hex (decorative) -->
|
||||
<polygon points="980,120 1120,200 1120,360 980,440 840,360 840,200"
|
||||
fill="none" stroke="#6366f1" stroke-width="1" opacity="0.12"/>
|
||||
<polygon points="980,150 1095,215 1095,345 980,410 865,345 865,215"
|
||||
fill="none" stroke="#6366f1" stroke-width="0.5" opacity="0.08"/>
|
||||
|
||||
<!-- Left-side decorative connection lines -->
|
||||
<line x1="60" y1="560" x2="200" y2="460" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
|
||||
<line x1="200" y1="460" x2="380" y2="540" stroke="#6366f1" stroke-width="1" opacity="0.15"/>
|
||||
<circle cx="60" cy="560" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
<circle cx="200" cy="460" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
<circle cx="380" cy="540" r="3" fill="#6366f1" opacity="0.3"/>
|
||||
|
||||
<!-- ── Main icon (hex + nodes) centered-left ── -->
|
||||
<g transform="translate(128, 265)">
|
||||
<!-- Hex -->
|
||||
<polygon points="64,0 112,28 112,92 64,120 16,92 16,28" fill="url(#hexGrad)"/>
|
||||
<!-- Center node -->
|
||||
<circle cx="64" cy="60" r="11" fill="white" opacity="0.95"/>
|
||||
<!-- Lines -->
|
||||
<line x1="64" y1="49" x2="64" y2="18" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="73.5" y1="65.5" x2="97" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="54.5" y1="65.5" x2="31" y2="79" stroke="white" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Outer nodes -->
|
||||
<circle cx="64" cy="14" r="7" fill="url(#nodeGrad)"/>
|
||||
<circle cx="100" cy="80" r="7" fill="url(#nodeGrad)"/>
|
||||
<circle cx="28" cy="80" r="7" fill="url(#nodeGrad)"/>
|
||||
</g>
|
||||
|
||||
<!-- ── Typography ── -->
|
||||
<!-- Title -->
|
||||
<text x="290" y="298"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="72" font-weight="800" letter-spacing="-2"
|
||||
fill="url(#titleGrad)">Agent Dashboard</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="292" y="355"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="26" font-weight="400"
|
||||
fill="#8888aa">Real-time monitoring platform for Claude Code agent activity</text>
|
||||
|
||||
<!-- Divider line -->
|
||||
<line x1="290" y1="385" x2="840" y2="385" stroke="#252538" stroke-width="1"/>
|
||||
|
||||
<!-- Tech pills -->
|
||||
<g transform="translate(290, 408)" font-family="'Inter', 'Helvetica Neue', Arial, sans-serif" font-size="15" font-weight="600">
|
||||
<!-- Node.js -->
|
||||
<rect x="0" y="0" width="88" height="28" rx="6" fill="#33993320" stroke="#33993340"/>
|
||||
<text x="44" y="19" text-anchor="middle" fill="#6dbf6d">Node.js 18+</text>
|
||||
<!-- React -->
|
||||
<rect x="100" y="0" width="82" height="28" rx="6" fill="#61dafb18" stroke="#61dafb30"/>
|
||||
<text x="141" y="19" text-anchor="middle" fill="#61dafb">React 18</text>
|
||||
<!-- TypeScript -->
|
||||
<rect x="194" y="0" width="100" height="28" rx="6" fill="#3178c618" stroke="#3178c630"/>
|
||||
<text x="244" y="19" text-anchor="middle" fill="#5a9fd4">TypeScript</text>
|
||||
<!-- SQLite -->
|
||||
<rect x="306" y="0" width="72" height="28" rx="6" fill="#003b5718" stroke="#005f8a30"/>
|
||||
<text x="342" y="19" text-anchor="middle" fill="#5bb8e0">SQLite</text>
|
||||
<!-- WebSocket -->
|
||||
<rect x="390" y="0" width="100" height="28" rx="6" fill="#10b98118" stroke="#10b98130"/>
|
||||
<text x="440" y="19" text-anchor="middle" fill="#34d399">WebSocket</text>
|
||||
<!-- MIT -->
|
||||
<rect x="502" y="0" width="62" height="28" rx="6" fill="#f59e0b18" stroke="#f59e0b30"/>
|
||||
<text x="533" y="19" text-anchor="middle" fill="#fbbf24">MIT</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom author strip -->
|
||||
<text x="290" y="580"
|
||||
font-family="'Inter', 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="18" font-weight="500"
|
||||
fill="#55556a">github.com/SmartGift · smartgift.vn</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB |
+103
-6
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.5.0
|
||||
version: 1.6.7
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -6788,17 +6788,18 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Updates
|
||||
summary: Pull, rebuild, and restart the dashboard when fast-forwardable
|
||||
summary: Update this install to the newest release and restart the dashboard
|
||||
description: 'Two modes, chosen by how this copy was installed and never by the request body (which is ignored). checkout: fast-forward onto the release tag, npm run setup, npm run build in production, then self-restart. plugin: the install runs from ~/.claude/plugins/cache/, so a detached helper runs `claude plugin marketplace update`, `claude plugin update -y`, then plugin-bootstrap.js --force from the NEW cache directory, which stops this server and starts the new one. In both modes the response is written before anything stops the process.'
|
||||
operationId: applyUpdate
|
||||
responses:
|
||||
'200':
|
||||
description: Checkout fast-forwarded and rebuilt; the process is about to self-restart
|
||||
description: 'Update is being applied; the process is about to be replaced. mode: "plugin" means the detached plugin chain is running, otherwise the checkout was fast-forwarded and rebuilt in-place'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 'Same shape as GET /api/updates/status, plus applied: true.'
|
||||
description: 'Same shape as GET /api/updates/status, plus applied: true. In plugin mode also mode: "plugin" and runtime_dir (where the chain writes its verdict, read back afterwards as last_plugin_update).'
|
||||
'409':
|
||||
description: Declined - nothing to apply, or the checkout isn't safely fast-forwardable
|
||||
content:
|
||||
@@ -6806,7 +6807,7 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable".'
|
||||
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing to pull: either release_check is "ok" and the published release is already installed, or the release check did not succeed (the message says which — an unknown is never reported as "nothing newer"). When a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.'
|
||||
'500':
|
||||
description: Update apply failed
|
||||
content:
|
||||
@@ -7975,6 +7976,102 @@ paths:
|
||||
pluginJson:
|
||||
name: superpowers
|
||||
version: 1.2.0
|
||||
/api/cc-config/plugins/update-all:
|
||||
get:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Progress of the current or last update-all batch
|
||||
description: Read-only. Returns the detached batch worker's on-disk journal from ~/.claude/agenthub/runtime/cc-update-all.json, or `{batch:null}` when no batch has ever run. `status` is `running`|`done`|`partial`|`failed`|`abandoned`; `abandoned` is DERIVED, not stored — a `running` journal older than one hour is reported that way because the worker is detached and a host reboot leaves nobody to write the terminal entry. `partial` means the batch finished with at least one failed step. Malformed or half-written journals return null rather than erroring, since the UI polls this endpoint.
|
||||
operationId: ccConfigGetUpdateAll
|
||||
responses:
|
||||
'200':
|
||||
description: The batch journal, or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
batch:
|
||||
status: partial
|
||||
started_at: '2026-09-10T08:30:00.000Z'
|
||||
finished_at: '2026-09-10T08:34:12.000Z'
|
||||
steps:
|
||||
- key: marketplaces
|
||||
label: Update all marketplaces
|
||||
status: ok
|
||||
- key: plugin:ponytail@ponytail
|
||||
label: ponytail@ponytail
|
||||
status: ok
|
||||
- key: plugin:vercel@claude-plugins-official
|
||||
label: vercel@claude-plugins-official
|
||||
status: failed
|
||||
error: marketplace unreachable
|
||||
- key: reload
|
||||
label: Reload open Claude Code sessions
|
||||
status: ok
|
||||
lanes_total: 2
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Update every marketplace and plugin, then reload the open sessions
|
||||
description: 'Mutating. Spawns a DETACHED worker and returns immediately — the response is an acknowledgement, not a result; poll GET on this path to follow it. Order: `claude plugin marketplace update` (no name = all), then `claude plugin update <id> -y` for every installed plugin, then `/reload-plugins` + `/reload-skills` typed into the live lane panes. The plugin this dashboard itself runs from is updated LAST and followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement — which is why the work cannot happen in the request handler and why the journal lives outside the plugin cache. Plugin ids come from `claude plugin list --json`, never from the body, and the "self" plugin is derived from this server''s own path, so no caller can nominate a different plugin for the restart. Answers 409 EBUSY when a batch is already in flight; the body is ignored apart from an optional `reload:false`.'
|
||||
operationId: ccConfigUpdateAll
|
||||
responses:
|
||||
'200':
|
||||
description: Batch worker spawned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
ok: true
|
||||
spawned: true
|
||||
runtime_dir: /Users/son/.claude/agenthub/runtime
|
||||
planned: 7
|
||||
self_plugin:
|
||||
marketplace: sg
|
||||
plugin: agenthub
|
||||
'409':
|
||||
description: A batch is already running.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/reload:
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
operationId: ccConfigReload
|
||||
responses:
|
||||
'200':
|
||||
description: Commands delivered. `lanes_total` counts only panes running a live claude; zero is a normal outcome, not a failure.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
ok: true
|
||||
commands:
|
||||
- /reload-plugins
|
||||
- /reload-skills
|
||||
lanes_total: 1
|
||||
lanes:
|
||||
- id: agenthub-lane-3
|
||||
laneId: 3
|
||||
sent:
|
||||
- /reload-plugins
|
||||
- /reload-skills
|
||||
'400':
|
||||
description: '`kinds` was present but not an array.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/mcp:
|
||||
get:
|
||||
tags:
|
||||
@@ -10603,7 +10700,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
@@ -1943,7 +1943,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
|
||||
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
|
||||
"funding": [
|
||||
|
||||
+3
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.7",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -55,7 +55,7 @@
|
||||
"dev:client": "cd client && npm run dev",
|
||||
"build": "cd client && npm run build",
|
||||
"start": "node server/index.js",
|
||||
"setup": "npm install && (cd client && npm install) && (cd vscode-extension && npm install) && npm run link-cli",
|
||||
"setup": "npm install && (cd client && npm install) && npm run link-cli",
|
||||
"update:pull-setup": "git pull --ff-only && npm run setup",
|
||||
"install-hooks": "node scripts/install-hooks.js",
|
||||
"seed": "node scripts/seed.js",
|
||||
@@ -68,9 +68,7 @@
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"openapi:yaml": "node scripts/generate-openapi-yaml.js",
|
||||
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\"",
|
||||
"docker:up": "docker compose up -d --build",
|
||||
"docker:down": "docker compose down"
|
||||
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
|
||||
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build
|
||||
---
|
||||
|
||||
Run the diagnostic and report what it found. Do not fix anything unless the user
|
||||
@@ -31,6 +31,16 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
||||
means that attempt has not run or did not succeed — say `/agenthub-update` retries
|
||||
it, and print the `claude plugin install …` line from the output as the manual
|
||||
route.
|
||||
- `WARN systemd` → when it says the unit names a **previous plugin version**,
|
||||
the dashboard is running now but will NOT come back after a reboot. Starting
|
||||
a new Claude Code session repairs it; print the `--force` command from the
|
||||
output as the immediate fix. "installed but not running" just needs
|
||||
`systemctl --user start agenthub`.
|
||||
- `WARN tmux` → only the run console, `agenthub lanes shell` and the
|
||||
session-reload keystrokes are affected; monitoring, lanes and the CLI are not.
|
||||
Print the install command from the output. If the row says `ok` but Start Run
|
||||
still fails, the server has a different PATH than this check — ask it directly
|
||||
with `curl -s localhost:4820/api/run/tmux`.
|
||||
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
|
||||
quotes a build error, report that error — it is the actual problem.
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# https://smartgift.github.io/Claude-Code-Agent-Monitor/
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://smartgift.github.io/Claude-Code-Agent-Monitor/sitemap.xml
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Detached worker for the dashboard's "Update all" button. Runs the whole
|
||||
* batch and writes each step's outcome to `cc-update-all.json` in the runtime
|
||||
* directory as it goes, so the UI can follow along by polling.
|
||||
*
|
||||
* Order, and why:
|
||||
* 1. `claude plugin marketplace update` (no name = every marketplace) — a
|
||||
* plugin update can only see a version its marketplace already knows about.
|
||||
* 2. `claude plugin update <id> -y` for every installed plugin EXCEPT the one
|
||||
* the dashboard itself runs from.
|
||||
* 3. That one last, followed by `plugin-bootstrap.js --force` from the NEW
|
||||
* cache directory — this stops the dashboard server and starts the
|
||||
* replacement. Anything ordered after it would run while the server is
|
||||
* down, which is exactly why it is last.
|
||||
* 4. `/reload-plugins` + `/reload-skills` typed into the live lane panes.
|
||||
* Deliberately after everything: one reload picks up every pending change.
|
||||
*
|
||||
* Spawned detached precisely because step 3 kills its parent. It must never
|
||||
* throw its way out without a terminal journal entry — a silent death would
|
||||
* leave the UI polling a batch that says "running" forever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
|
||||
const [, , runtimeDirArg, planJson] = process.argv;
|
||||
|
||||
const JOURNAL = path.join(runtimeDirArg || ".", "cc-update-all.json");
|
||||
const LOG_FILE = path.join(runtimeDirArg || ".", "cc-update-all.log");
|
||||
|
||||
// Ids reach an argv, so they are re-validated here rather than trusted across
|
||||
// the process boundary — same reasoning as scripts/plugin-update-helper.js.
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/** @type {{status: string, started_at: string, finished_at?: string, steps: object[]}} */
|
||||
const state = { status: "running", started_at: new Date().toISOString(), steps: [] };
|
||||
|
||||
function log(line) {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist after every transition — the UI polls this file, and the process can
|
||||
* be SIGTERMed at step 3 without warning. */
|
||||
function flush() {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(JOURNAL, JSON.stringify(state, null, 2));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function addStep(key, label) {
|
||||
const step = { key, label, status: "running", started_at: new Date().toISOString() };
|
||||
state.steps.push(step);
|
||||
flush();
|
||||
return step;
|
||||
}
|
||||
|
||||
function finishStep(step, status, extra = {}) {
|
||||
step.status = status;
|
||||
step.finished_at = new Date().toISOString();
|
||||
Object.assign(step, extra);
|
||||
flush();
|
||||
}
|
||||
|
||||
function resolveClaude() {
|
||||
const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")];
|
||||
for (const cmd of candidates) {
|
||||
const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 });
|
||||
if (!probe.error && probe.status === 0) return cmd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(cmd, args, timeout) {
|
||||
log(`$ ${cmd} ${args.join(" ")}`);
|
||||
const out = execFileSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
maxBuffer: 10_000_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (out) log(out.trim().slice(0, 2000));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Newest cache dir for the self plugin, excluding the one being replaced. */
|
||||
function findNewRoot(marketplace, plugin, exclude) {
|
||||
const base = path.join(
|
||||
require("os").homedir(),
|
||||
".claude",
|
||||
"plugins",
|
||||
"cache",
|
||||
marketplace,
|
||||
plugin
|
||||
);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = entries
|
||||
.map((e) => path.join(base, e.name))
|
||||
.filter((p) => p !== exclude)
|
||||
.filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js")))
|
||||
.map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
return candidates.length ? candidates[0].p : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the reload commands into every live `claude` lane pane. Uses tmux
|
||||
* directly rather than the server's lib: by this point the server may be a
|
||||
* different process than the one that started the batch.
|
||||
*/
|
||||
function reloadPanes() {
|
||||
const step = addStep("reload", "Reload open Claude Code sessions");
|
||||
let names;
|
||||
try {
|
||||
const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
names = out
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.startsWith("agenthub-lane-"));
|
||||
} catch {
|
||||
// No tmux server at all means no open sessions — nothing to reload, which
|
||||
// is a normal outcome and not a batch failure.
|
||||
finishStep(step, "skipped", { note: "no tmux sessions are open" });
|
||||
return;
|
||||
}
|
||||
|
||||
const reloaded = [];
|
||||
for (const name of names) {
|
||||
let paneCmd = null;
|
||||
try {
|
||||
paneCmd = execFileSync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", name, "#{pane_current_command}"],
|
||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
continue; // pane vanished between listing and probe
|
||||
}
|
||||
// A pane at a shell prompt has no REPL to reload; the text would be run as
|
||||
// a shell command instead.
|
||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||
try {
|
||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
reloaded.push(name);
|
||||
} catch {
|
||||
/* pane died mid-send; the rest of the batch stands */
|
||||
}
|
||||
}
|
||||
finishStep(step, "ok", { lanes: reloaded, lanes_total: reloaded.length });
|
||||
}
|
||||
|
||||
function main() {
|
||||
let plan;
|
||||
try {
|
||||
plan = JSON.parse(planJson);
|
||||
} catch {
|
||||
state.status = "failed";
|
||||
state.error = "unreadable plan";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
const claude = resolveClaude();
|
||||
if (!claude) {
|
||||
const step = addStep("resolve_claude", "Locate the claude CLI");
|
||||
finishStep(step, "failed", {
|
||||
error: "the `claude` CLI is not on this server's PATH; run the update from a terminal",
|
||||
});
|
||||
state.status = "failed";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Every marketplace, in one call (no name = all).
|
||||
const mktStep = addStep("marketplaces", "Update all marketplaces");
|
||||
try {
|
||||
run(claude, ["plugin", "marketplace", "update"], 300_000);
|
||||
finishStep(mktStep, "ok");
|
||||
} catch (err) {
|
||||
// A marketplace that can't be refreshed (offline, auth) does not stop the
|
||||
// batch: the plugins may still have updates from the marketplaces that did.
|
||||
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
}
|
||||
|
||||
const self = plan.selfPlugin;
|
||||
const selfId = self ? `${self.plugin}@${self.marketplace}` : null;
|
||||
const others = (plan.plugins || []).filter((id) => PLUGIN_SPEC_RE.test(id) && id !== selfId);
|
||||
|
||||
// 2. Every other plugin.
|
||||
for (const id of others) {
|
||||
const step = addStep(`plugin:${id}`, id);
|
||||
try {
|
||||
run(claude, ["plugin", "update", id, "-y"], 600_000);
|
||||
finishStep(step, "ok");
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. AgentHub itself, last — its bootstrap stops this server.
|
||||
if (self && SAFE_NAME.test(self.marketplace) && SAFE_NAME.test(self.plugin)) {
|
||||
const step = addStep(`plugin:${selfId}`, `${selfId} (this dashboard)`);
|
||||
try {
|
||||
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
reloadPanes();
|
||||
state.status = "failed";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const newRoot = findNewRoot(self.marketplace, self.plugin, plan.selfRoot);
|
||||
if (!newRoot) {
|
||||
// Nothing newer was published: the running server is correct as-is.
|
||||
finishStep(step, "ok", { note: "already on the newest version; server left running" });
|
||||
} else {
|
||||
// Mark the step done BEFORE the restart: bootstrap SIGTERMs us mid-call,
|
||||
// so a journal entry written afterwards may never be written at all.
|
||||
finishStep(step, "ok", { note: `restarting the dashboard from ${newRoot}`, restarted: true });
|
||||
reloadPanes();
|
||||
state.status = "done";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
try {
|
||||
run(
|
||||
process.execPath,
|
||||
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
|
||||
900_000
|
||||
);
|
||||
} catch (err) {
|
||||
log(`bootstrap failed: ${err.message}`);
|
||||
state.status = "failed";
|
||||
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
|
||||
state.note = "the old server was stopped; run /agenthub-update to recover";
|
||||
flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reload, once, after every update.
|
||||
if (plan.reload !== false) reloadPanes();
|
||||
|
||||
state.status = state.steps.some((s) => s.status === "failed") ? "partial" : "done";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
// A worker that dies without a terminal entry leaves the UI polling forever.
|
||||
state.status = "failed";
|
||||
state.error = err.message || String(err);
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
}
|
||||
+282
-31
@@ -14,8 +14,14 @@
|
||||
* scoped to this session, same as before.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
|
||||
* on every plugin update.
|
||||
* the plugin cache, which Claude Code replaces wholesale on every plugin
|
||||
* update. The one thing this file does reach into the cache for is deleting
|
||||
* the copies those updates leave behind (`pruneRetiredPluginRoots`).
|
||||
*
|
||||
* A bootstrap that is no longer the installed build does nothing at all
|
||||
* (`supersededBy`): an old cache directory, still loaded by a Claude Code
|
||||
* process that has not been restarted, would otherwise repoint systemd at
|
||||
* itself and downgrade the dashboard once per session, forever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -40,14 +46,65 @@ function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function pluginVersion() {
|
||||
function pluginVersion(root = PLUGIN_ROOT) {
|
||||
try {
|
||||
return require(path.join(PLUGIN_ROOT, "package.json")).version;
|
||||
return require(path.join(root, "package.json")).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- superseded plugin build */
|
||||
|
||||
/** Claude Code's own record of which build of each plugin is installed. */
|
||||
function installedPluginsPath() {
|
||||
return path.join(getClaudeHome(), "plugins", "installed_plugins.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* The build Claude Code has installed IN PLACE OF this one, or null when this
|
||||
* build is the installed one (or when there is no such record to read).
|
||||
*
|
||||
* `claude plugin update` unpacks the new version into a new cache directory and
|
||||
* says "Restart to apply changes" — but a Claude Code process that was already
|
||||
* running keeps loading the OLD directory, hooks and all. Every session it
|
||||
* opens from then on runs the OLD bootstrap, which happily repoints the systemd
|
||||
* unit at itself and restarts the dashboard onto the version the user just
|
||||
* upgraded away from. The dashboard then reports the old version and offers the
|
||||
* update again — forever, once per session, until Claude Code is restarted.
|
||||
*
|
||||
* So a bootstrap that is no longer the installed build does NOTHING. It cannot
|
||||
* hand over either: the process it would have to replace is the one loading it.
|
||||
*
|
||||
* Matched by SIBLING directory rather than by `<plugin>@<marketplace>` key: the
|
||||
* builds of one plugin are exactly the directories beside this one, and that
|
||||
* holds without parsing a cache path or trusting a name. An entry naming a
|
||||
* directory that no longer exists is ignored — a phantom must never wedge a
|
||||
* bootstrap shut.
|
||||
*/
|
||||
function supersededBy(root = PLUGIN_ROOT) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
|
||||
} catch {
|
||||
return null; // no record, unreadable, or not a plugin install at all
|
||||
}
|
||||
const siblings = path.dirname(root);
|
||||
const found = [];
|
||||
for (const entries of Object.values((data && data.plugins) || {})) {
|
||||
for (const entry of entries || []) {
|
||||
const installed =
|
||||
entry && typeof entry.installPath === "string" ? path.resolve(entry.installPath) : null;
|
||||
if (!installed) continue;
|
||||
// This build IS installed (possibly at one scope among several) — the
|
||||
// whole question is settled, whatever any other entry says.
|
||||
if (installed === root) return null;
|
||||
if (path.dirname(installed) === siblings && fs.existsSync(installed)) found.push(installed);
|
||||
}
|
||||
}
|
||||
return found[0] || null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
|
||||
function statePath(rt = runtimeDir()) {
|
||||
@@ -107,6 +164,12 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
|
||||
// actual check (manifest + every user SKILL.md) runs once, in the worker.
|
||||
if (!state.skillPluginsSettled) return false;
|
||||
// A live server says nothing about whether it will come back. When our unit
|
||||
// still names a previous plugin cache directory, everything looks healthy
|
||||
// until the next reboot — and then the dashboard is simply gone, from a path
|
||||
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
|
||||
// path; `unitIsStale` deliberately spawns nothing.
|
||||
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
|
||||
return serverIsLive();
|
||||
}
|
||||
|
||||
@@ -441,16 +504,133 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ plugin-cache pruning */
|
||||
|
||||
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
|
||||
const KEEP_RETIRED_ROOTS = 2;
|
||||
|
||||
/**
|
||||
* Delete the cache directories earlier updates left behind.
|
||||
*
|
||||
* Claude Code unpacks every plugin update into a NEW directory beside the old
|
||||
* one and does not reliably remove the old. A machine that tracks releases
|
||||
* closely ends up with a dozen full copies of this repo — node_modules and all
|
||||
* — which is what this clears.
|
||||
*
|
||||
* This is the one place anything here touches the plugin cache, and the file
|
||||
* header's "never write into the cache" rule is about the RUNTIME living
|
||||
* outside it, not about leaving retired copies forever. Everything still in use
|
||||
* is protected, by four independent readings of "in use":
|
||||
*
|
||||
* - this build;
|
||||
* - every build Claude Code records as installed, at any scope;
|
||||
* - the directory our systemd unit runs from, foreign unit included;
|
||||
* - any directory still named in the user's `settings.json` hooks — a Claude
|
||||
* Code process that has not been restarted is still executing from there,
|
||||
* and deleting it would break that session's hooks mid-flight. (This is
|
||||
* exactly the directory `supersededBy` describes, seen from the other side.)
|
||||
*
|
||||
* …plus the newest `KEEP_RETIRED_ROOTS` of whatever is left, so a rollback has
|
||||
* something to roll back to. Best effort throughout: a directory that will not
|
||||
* delete is left alone, never an error.
|
||||
*
|
||||
* @returns {number} how many directories were removed
|
||||
*/
|
||||
function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
|
||||
// Only inside Claude Code's plugin cache. For a git checkout the parent
|
||||
// directory holds the user's own work and is never ours to delete from.
|
||||
const cacheMarker = `${path.sep}plugins${path.sep}cache${path.sep}`;
|
||||
if (!root.includes(cacheMarker)) return 0;
|
||||
const cacheDir = path.dirname(root);
|
||||
|
||||
const keep = new Set([root]);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
|
||||
for (const entries of Object.values((data && data.plugins) || {})) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry && typeof entry.installPath === "string")
|
||||
keep.add(path.resolve(entry.installPath));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no record to read — the other three protections still apply */
|
||||
}
|
||||
try {
|
||||
const unitRoot = require("./systemd-service").unitRoot("agenthub");
|
||||
if (unitRoot) keep.add(path.resolve(unitRoot));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
let settings = "";
|
||||
try {
|
||||
settings = fs.readFileSync(getSettingsPath(), "utf8");
|
||||
} catch {
|
||||
/* no settings file — nothing pinned by a hook */
|
||||
}
|
||||
|
||||
let dirs;
|
||||
try {
|
||||
dirs = fs.readdirSync(cacheDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const retired = dirs
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => path.join(cacheDir, d.name))
|
||||
.filter((dir) => !keep.has(dir) && !settings.includes(dir))
|
||||
.map((dir) => {
|
||||
let at = 0;
|
||||
try {
|
||||
at = fs.statSync(dir).mtimeMs;
|
||||
} catch {
|
||||
/* unreadable sorts oldest, so it goes first */
|
||||
}
|
||||
return { dir, at };
|
||||
})
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.slice(KEEP_RETIRED_ROOTS);
|
||||
|
||||
let removed = 0;
|
||||
for (const { dir } of retired) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
/* in use, read-only, whatever — leaving it is always safe */
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
log(rt, `removed ${removed} retired plugin cache ${removed === 1 ? "copy" : "copies"}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real work. Runs detached from the SessionStart hook, so its only channel
|
||||
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
|
||||
* reinstalls dependencies and restarts the server unconditionally.
|
||||
*
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
|
||||
*/
|
||||
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
// First, and ahead of the lock: a retired build has nothing to do and must
|
||||
// not take the lock to discover it. `force` does NOT override this — the
|
||||
// plugin-update chain runs `--force` from the NEW cache directory, which is
|
||||
// never superseded, so the only `--force` this can refuse is one that would
|
||||
// downgrade the user on purpose.
|
||||
const newer = supersededBy();
|
||||
if (newer) {
|
||||
log(
|
||||
rt,
|
||||
`this build (${pluginVersion()}) has been replaced by ${pluginVersion(newer)} — doing nothing. ` +
|
||||
`Restart Claude Code so it loads the new one; until then this session keeps running the old plugin.`
|
||||
);
|
||||
return "superseded";
|
||||
}
|
||||
|
||||
if (!nodeVersionOk()) {
|
||||
log(
|
||||
rt,
|
||||
@@ -465,6 +645,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
return "locked";
|
||||
}
|
||||
|
||||
// Declared out here because the restart it guards has to happen in the
|
||||
// `finally`, after the lock is released and the state is on disk: it is the
|
||||
// step that may cost this process its own life.
|
||||
let pendingSystemdRestart = false;
|
||||
|
||||
try {
|
||||
const wantDeps = depsHash();
|
||||
const state = readState(rt) || {};
|
||||
@@ -504,44 +689,85 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
|
||||
const svc = require("./systemd-service");
|
||||
// Who owns the running server decides how it may be retired. A unit of
|
||||
// ours means systemd owns it — and this process is a descendant of that
|
||||
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
|
||||
// there does two fatal things at once: the clean exit(0) tells systemd
|
||||
// "meant to stop", and `KillMode=control-group` reaps this bootstrap
|
||||
// before it can write state or point the unit at the new cache dir. That
|
||||
// is the bug that left a dashboard dead after every self-update until the
|
||||
// next Claude Code session happened to bootstrap again.
|
||||
// ACTIVE matters, not merely present: a unit of ours that is installed but
|
||||
// stopped owns nothing right now. If a detached server is holding the port
|
||||
// in that state, treating systemd as the owner would skip the stop and then
|
||||
// ask systemd to start a second server onto a bound port — a crash loop,
|
||||
// courtesy of Restart=always.
|
||||
const systemdOwned =
|
||||
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
|
||||
|
||||
// After a plugin update the running server executes code from a cache
|
||||
// directory Claude Code has already discarded — retire it first.
|
||||
// directory Claude Code has already discarded — retire it first. Only
|
||||
// when nothing else owns its lifecycle; otherwise systemd retires it, in
|
||||
// one restart job, once everything below is durable.
|
||||
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
|
||||
if (movedRoot && stopDashboard()) {
|
||||
if (movedRoot && !systemdOwned && stopDashboard()) {
|
||||
log(rt, "stopped the server started from the previous plugin version");
|
||||
waitForPortsFree();
|
||||
}
|
||||
|
||||
if (movedRoot || !serverIsLive()) {
|
||||
const svc = require("./systemd-service");
|
||||
const result = svc.systemctlAvailable()
|
||||
? svc.installUserService({
|
||||
name: "agenthub",
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: PLUGIN_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
// systemd --user units get systemd's own minimal PATH, not the
|
||||
// shell's — forward it so the server can find `claude`/`codex`.
|
||||
PATH: process.env.PATH || "",
|
||||
},
|
||||
})
|
||||
: "unavailable";
|
||||
// Two different needs, deliberately not collapsed. `needsServer` is "the
|
||||
// running process must be replaced"; `unitStale` is "the unit names a cache
|
||||
// directory that is no longer this one", which is invisible until a reboot
|
||||
// and must be repaired even while a perfectly healthy server is up.
|
||||
const needsServer = movedRoot || !serverIsLive();
|
||||
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
|
||||
if (needsServer || unitStale) {
|
||||
const unit = {
|
||||
name: "agenthub",
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: PLUGIN_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
// systemd --user units get systemd's own minimal PATH, not the
|
||||
// shell's — forward it so the server can find `claude`/`codex`.
|
||||
PATH: process.env.PATH || "",
|
||||
},
|
||||
};
|
||||
// Two shapes of the same step. When systemd already owns the server the
|
||||
// unit is only REWRITTEN here — pointing at the new plugin root — and
|
||||
// the restart is deferred to `pendingSystemdRestart` below, because
|
||||
// starting it now would kill this process mid-bootstrap. Otherwise
|
||||
// there is no cgroup to be reaped by, and install-and-start is fine.
|
||||
const result = systemdOwned
|
||||
? svc.writeUserService(unit)
|
||||
: svc.systemctlAvailable()
|
||||
? svc.installUserService(unit)
|
||||
: "unavailable";
|
||||
if (result === "written" || result === "unchanged") {
|
||||
if (result === "written") svc.enableLinger();
|
||||
// Only when the RUNNING process has to change. A stale unit beside a
|
||||
// healthy server is now corrected on disk and left to take effect at
|
||||
// the next start — restarting a working dashboard to fix a file that
|
||||
// only matters at boot would be churn the user feels for no reason.
|
||||
pendingSystemdRestart = systemdOwned && needsServer;
|
||||
log(
|
||||
rt,
|
||||
result === "written"
|
||||
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
||||
: "systemd --user service already up to date"
|
||||
result === "unchanged"
|
||||
? "systemd --user service already up to date"
|
||||
: unitStale && !needsServer
|
||||
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
|
||||
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
||||
);
|
||||
} else {
|
||||
} else if (needsServer) {
|
||||
// No systemd (macOS/Windows/WSL without it), or the unit exists but
|
||||
// wasn't ours to touch — same detached-spawn fallback as before,
|
||||
// scoped to this session only.
|
||||
// scoped to this session only. Guarded by `needsServer`: entering this
|
||||
// block for a stale unit alone must never spawn a second server onto
|
||||
// the port the live one already holds.
|
||||
const pid = startDashboard(rt);
|
||||
log(
|
||||
rt,
|
||||
@@ -569,9 +795,22 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
},
|
||||
rt
|
||||
);
|
||||
|
||||
// After the state is durable, so a prune that goes wrong cannot also cost
|
||||
// us the record of a bootstrap that went right.
|
||||
pruneRetiredPluginRoots(rt);
|
||||
return "ok";
|
||||
} finally {
|
||||
releaseLock(rt);
|
||||
// LAST, and after the lock: systemd stops the old main process to start
|
||||
// the new one, and this process shares its cgroup, so it may not outlive
|
||||
// this call. Everything that had to be durable already is — the state
|
||||
// file is written and the lock is gone, so even being killed here leaves
|
||||
// no debris and no half-recorded bootstrap.
|
||||
if (pendingSystemdRestart) {
|
||||
log(rt, "handing the server over to systemd (restarting the unit)");
|
||||
require("./systemd-service").restartUserService("agenthub");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +835,14 @@ if (require.main === module) {
|
||||
// npm output and the result.
|
||||
const result = bootstrap(runtimeDir(), { force });
|
||||
if (force) {
|
||||
console.log(result === "ok" ? "AgentHub runtime refreshed." : `bootstrap: ${result}`);
|
||||
console.log(
|
||||
result === "ok"
|
||||
? "AgentHub runtime refreshed."
|
||||
: result === "superseded"
|
||||
? "A newer AgentHub plugin build is already installed — this one did nothing on purpose.\n" +
|
||||
"Restart Claude Code so it loads the new build, then try again."
|
||||
: `bootstrap: ${result}`
|
||||
);
|
||||
if (result !== "ok") process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
@@ -611,6 +857,11 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
||||
* declared package, not just that node_modules exists), server liveness,
|
||||
* duplicate hook entries (the one failure that silently doubles every token and
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, whether the slash
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
|
||||
* every browser-started run depends on, whether the slash
|
||||
* commands the pipeline templates name as node drivers are actually resolvable
|
||||
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
|
||||
* build error when it is missing.
|
||||
@@ -82,6 +83,8 @@ function diagnose() {
|
||||
);
|
||||
|
||||
add(...cliStatus());
|
||||
add(...systemdStatus());
|
||||
add(...tmuxStatus());
|
||||
add(...skillPluginStatus());
|
||||
|
||||
add(...dashboardUiStatus(rt));
|
||||
@@ -218,6 +221,76 @@ function skillPluginStatus() {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this row exists for is silent by construction: a unit still
|
||||
* naming a retired plugin cache directory keeps a running dashboard looking
|
||||
* perfectly healthy, and only bites at the next reboot — when the server does
|
||||
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
|
||||
* in this report would show it.
|
||||
*
|
||||
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
|
||||
* Windows or a Linux box without systemd there is no unit to have an opinion
|
||||
* about.
|
||||
*/
|
||||
function systemdStatus() {
|
||||
const svc = require("./systemd-service");
|
||||
const unit = svc.unitPath("agenthub");
|
||||
|
||||
if (!svc.unitIsOurs("agenthub")) {
|
||||
if (fs.existsSync(unit)) {
|
||||
return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`];
|
||||
}
|
||||
return [
|
||||
"ok",
|
||||
"systemd",
|
||||
svc.systemctlAvailable()
|
||||
? "no unit — the server is started per session (install one with `agenthub service install`)"
|
||||
: "not available on this platform — the server is started per session",
|
||||
];
|
||||
}
|
||||
|
||||
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
|
||||
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
|
||||
];
|
||||
}
|
||||
|
||||
if (!svc.unitIsActive("agenthub")) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
"unit is installed and current but not running — start it with `systemctl --user start agenthub`",
|
||||
];
|
||||
}
|
||||
return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"];
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
||||
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
||||
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
||||
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
|
||||
* skill-plugins row: a partial feature loss must not fail a doctor run.
|
||||
*
|
||||
* This checks the doctor's own PATH. A server started as a systemd `--user`
|
||||
* service can still see a different one — `GET /api/run/tmux` is the server's
|
||||
* own answer, and `agenthub doctor` reports that.
|
||||
*/
|
||||
function tmuxStatus() {
|
||||
if (require("../server/lib/tmux").isTmuxAvailable()) {
|
||||
return ["ok", "tmux", "on PATH — the run console can start sessions"];
|
||||
}
|
||||
return [
|
||||
"warn",
|
||||
"tmux",
|
||||
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
|
||||
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
|
||||
];
|
||||
}
|
||||
|
||||
function countLegacyHookEntries() {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
||||
|
||||
+11
-1
@@ -73,7 +73,17 @@ function buildClient({
|
||||
// tarball against the lockfile's integrity hash, so a bad cache entry is
|
||||
// refetched instead of silently reused. It needs a lockfile that matches
|
||||
// package.json — fall back to `install` when the copy has none or has drifted.
|
||||
const npmFlags = ["--no-audit", "--no-fund"];
|
||||
//
|
||||
// `--include=dev` is not optional here: the whole build toolchain (vite,
|
||||
// typescript, @vitejs/plugin-react, tailwindcss, @types/*) lives in the
|
||||
// client's devDependencies, and npm reads `NODE_ENV=production` as
|
||||
// `--omit=dev`. Every bootstrap descended from the running server inherits
|
||||
// that NODE_ENV — the self-update chain and any SessionStart fired from a
|
||||
// tmux pane the server started — so the install silently dropped `vite`,
|
||||
// `npm run build` failed, and the PREVIOUS bundle stayed in place: the
|
||||
// dashboard kept serving the old version's UI while the API was already the
|
||||
// new one. An explicit CLI flag outranks the env, which is the point.
|
||||
const npmFlags = ["--no-audit", "--no-fund", "--include=dev"];
|
||||
if (
|
||||
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
|
||||
!exec(NPM, ["install", ...npmFlags], src, stdio)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Detached helper that performs a full plugin-cache update end to end, so
|
||||
* the dashboard's "Update now" button works for an install running out of
|
||||
* `~/.claude/plugins/cache/`. Spawned by `server/lib/plugin-update.js` after the
|
||||
* HTTP response is written, because step 3 SIGTERMs the very server that asked
|
||||
* for the update — an in-process version would kill itself mid-flight.
|
||||
*
|
||||
* The three steps:
|
||||
* 1. `claude plugin marketplace update <marketplace>`
|
||||
* 2. `claude plugin update <plugin>@<marketplace> -y` → new cache dir appears
|
||||
* 3. `node <newRoot>/scripts/plugin-bootstrap.js --force` → stops the old
|
||||
* server, waits for the ports, installs deps and starts the new one
|
||||
*
|
||||
* Step 3 is what makes `/reload-plugins` unnecessary here: `plugin-bootstrap.js`
|
||||
* derives its own `PLUGIN_ROOT` from `__dirname`, not from `${CLAUDE_PLUGIN_ROOT}`,
|
||||
* so running it out of the NEW cache directory is enough to move the dashboard
|
||||
* onto the new code. `/reload-plugins` remains needed only for an interactive
|
||||
* Claude Code session to pick up new skills/commands/hooks — a thing no
|
||||
* background process can do on that session's behalf.
|
||||
*
|
||||
* Every outcome, including failure, is recorded in `plugin-update.json` under the
|
||||
* runtime directory (outside the plugin cache, so it survives the swap) and the
|
||||
* new server reports it back as `last_plugin_update`. Without that, a failed
|
||||
* chain would look identical to a slow one: the tab would poll a server that
|
||||
* never comes back and say nothing about why.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
|
||||
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
||||
|
||||
const RESULT_FILE = path.join(runtimeDirArg, "plugin-update.json");
|
||||
const LOG_FILE = path.join(runtimeDirArg, "plugin-update.log");
|
||||
|
||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||
// reach an argv — so they are re-validated here rather than trusted across the
|
||||
// process boundary. Anything else is a bug or tampering, and either way this
|
||||
// helper must not run.
|
||||
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
||||
|
||||
function log(line) {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function record(result) {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
RESULT_FILE,
|
||||
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the `claude` CLI. The server is started detached from a hook, so its
|
||||
* PATH is not the user's login shell PATH — check the documented install
|
||||
* location before giving up. */
|
||||
function resolveClaude() {
|
||||
const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")];
|
||||
for (const cmd of candidates) {
|
||||
const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 });
|
||||
if (!probe.error && probe.status === 0) return cmd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Newest cache directory for this plugin, excluding `exclude`. */
|
||||
function findNewRoot(exclude) {
|
||||
const base = path.join(
|
||||
require("os").homedir(),
|
||||
".claude",
|
||||
"plugins",
|
||||
"cache",
|
||||
marketplace,
|
||||
plugin
|
||||
);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = entries
|
||||
.map((e) => path.join(base, e.name))
|
||||
.filter((p) => p !== exclude)
|
||||
// A cache directory without the bootstrap script is not a usable root.
|
||||
.filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js")))
|
||||
.map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
return candidates.length ? candidates[0].p : null;
|
||||
}
|
||||
|
||||
function run(cmd, args, timeout) {
|
||||
log(`$ ${cmd} ${args.join(" ")}`);
|
||||
const out = execFileSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
maxBuffer: 10_000_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (out) log(out.trim().slice(0, 4000));
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!SAFE_NAME.test(marketplace || "") || !SAFE_NAME.test(plugin || "")) {
|
||||
record({ ok: false, step: "validate", error: "unsafe marketplace or plugin name" });
|
||||
return;
|
||||
}
|
||||
|
||||
const claude = resolveClaude();
|
||||
if (!claude) {
|
||||
record({
|
||||
ok: false,
|
||||
step: "resolve_claude",
|
||||
error: "the `claude` CLI is not on this server's PATH; run /agenthub-update instead",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||
} catch (err) {
|
||||
record({ ok: false, step: "marketplace_update", error: String(err.stderr || err.message) });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
record({ ok: false, step: "plugin_update", error: String(err.stderr || err.message) });
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoot = findNewRoot(oldRoot);
|
||||
if (!newRoot) {
|
||||
// Already newest: the marketplace had nothing to pull. Not a failure — the
|
||||
// old server is still running and untouched, so say so plainly.
|
||||
record({
|
||||
ok: true,
|
||||
step: "already_current",
|
||||
note: "no new plugin version appeared; the running server was left alone",
|
||||
plugin_root: oldRoot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log(`new plugin root: ${newRoot}`);
|
||||
// Write the verdict BEFORE the handover, not only after it. The step below
|
||||
// retires the process tree this helper hangs off, and an observed run reached
|
||||
// the new server with no verdict file at all — so the dashboard reported
|
||||
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
||||
// pre-written record is overwritten by the real outcome when this survives,
|
||||
// and is the outcome when it does not.
|
||||
record({
|
||||
ok: true,
|
||||
step: "bootstrap",
|
||||
plugin_root: newRoot,
|
||||
note: "the new plugin version is installed; the server is being handed over",
|
||||
});
|
||||
try {
|
||||
// Foreground and blocking: this stops the old server (us), waits for the
|
||||
// ports, then starts the new one. It must finish before we record success.
|
||||
run(
|
||||
process.execPath,
|
||||
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
|
||||
900_000
|
||||
);
|
||||
} catch (err) {
|
||||
record({
|
||||
ok: false,
|
||||
step: "bootstrap",
|
||||
error: String(err.stderr || err.message),
|
||||
plugin_root: newRoot,
|
||||
note: "the old server was stopped; run /agenthub-update to recover",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
record({ ok: true, step: "done", plugin_root: newRoot });
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
// A helper that dies without a verdict is the failure mode this file exists
|
||||
// to prevent, so even an unexpected throw gets recorded.
|
||||
record({ ok: false, step: "unexpected", error: err.message || String(err) });
|
||||
}
|
||||
+136
-13
@@ -26,9 +26,89 @@ function unitPath(name) {
|
||||
return path.join(unitDir(), `${name}.service`);
|
||||
}
|
||||
|
||||
// Test seam: swap the systemctl implementation so unit tests never talk to a
|
||||
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
|
||||
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
||||
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
||||
}
|
||||
|
||||
function systemctlAvailable() {
|
||||
if (process.platform !== "linux") return false;
|
||||
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0;
|
||||
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a unit by this name, and did WE write it? The distinction decides
|
||||
* whether the dashboard may be stopped by hand: a unit of ours means systemd
|
||||
* owns the process, and SIGTERM-ing it out of band is the one thing that must
|
||||
* never happen (see `restartUserService`).
|
||||
*/
|
||||
function unitIsOurs(name) {
|
||||
try {
|
||||
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
|
||||
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
|
||||
* stop during a plugin self-update — then left the dashboard down until some
|
||||
* later Claude Code session happened to run the bootstrap. An explicit
|
||||
* `systemctl stop` (what uninstall does) still stops it for good.
|
||||
*/
|
||||
/**
|
||||
* Does our unit still point at `root`? A plugin update moves the install to a
|
||||
* new cache directory, and a unit left naming the old one is a failure that
|
||||
* shows NOTHING until the next reboot — the dashboard simply does not come
|
||||
* back, from a path Claude Code garbage-collected weeks earlier.
|
||||
*
|
||||
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
|
||||
* runs on every single SessionStart and may not spawn a process.
|
||||
*
|
||||
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
|
||||
*/
|
||||
function unitIsStale(name, root) {
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return false; // no unit at all: nothing to be stale
|
||||
}
|
||||
if (!body.includes(MARKER)) return false;
|
||||
return !body.includes(`\nWorkingDirectory=${root}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory our unit actually runs from, or null when there is no unit.
|
||||
*
|
||||
* Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is
|
||||
* not ours to rewrite, but whatever directory it runs from is still a directory
|
||||
* nothing here may delete. The one caller is the plugin-cache pruner.
|
||||
*/
|
||||
function unitRoot(name) {
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = body.match(/^WorkingDirectory=(.*)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
||||
* from the fast path. */
|
||||
function unitIsActive(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
function unitContent({ description, workingDir, execStart, env }) {
|
||||
@@ -44,7 +124,7 @@ After=network.target
|
||||
Type=simple
|
||||
WorkingDirectory=${workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
${envLines}
|
||||
|
||||
@@ -54,17 +134,18 @@ WantedBy=default.target
|
||||
}
|
||||
|
||||
function systemctl(args) {
|
||||
return spawnSync("systemctl", ["--user", ...args], { stdio: "inherit" });
|
||||
return execImpl(args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
|
||||
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
|
||||
* cache path after an update) is reloaded and restarted.
|
||||
* Writes (or refreshes) the unit file and enables it for boot — but starts
|
||||
* nothing. Split out of `installUserService` for the plugin self-update, which
|
||||
* must be able to point the unit at the new cache directory *before* anything
|
||||
* stops the running server, and defer the restart to its very last act.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function installUserService({ name, description, workingDir, execStart, env = {} }) {
|
||||
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ description, workingDir, execStart, env });
|
||||
const target = unitPath(name);
|
||||
@@ -72,22 +153,56 @@ function installUserService({ name, description, workingDir, execStart, env = {}
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
if (!current.includes(MARKER)) return "foreign";
|
||||
if (current === body) {
|
||||
systemctl(["enable", "--now", `${name}.service`]);
|
||||
return "unchanged";
|
||||
}
|
||||
if (current === body) return "unchanged";
|
||||
}
|
||||
fs.mkdirSync(unitDir(), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
systemctl(["daemon-reload"]);
|
||||
systemctl(["enable", "--now", `${name}.service`]);
|
||||
systemctl(["restart", `${name}.service`]);
|
||||
systemctl(["enable", `${name}.service`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask systemd to restart the unit and do NOT wait for it.
|
||||
*
|
||||
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
|
||||
* spawned by the running server, so it lives in that server's cgroup; the
|
||||
* moment systemd stops the old main process, `KillMode=control-group` reaps
|
||||
* the caller too. Blocking here means being killed mid-call, which is why the
|
||||
* restart must be the last thing the bootstrap does — by then there is nothing
|
||||
* left to lose.
|
||||
*/
|
||||
function restartUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const res = systemctl(["restart", "--no-block", `${name}.service`]);
|
||||
return res.status === 0 ? "restarted" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
|
||||
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
|
||||
* cache path after an update) is reloaded and restarted.
|
||||
*
|
||||
* Safe for `agenthub service install`, which is invoked from a user's own
|
||||
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
|
||||
* is, and uses `writeUserService` + `restartUserService` instead.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function installUserService(opts) {
|
||||
const result = writeUserService(opts);
|
||||
if (result === "unchanged") {
|
||||
systemctl(["enable", "--now", `${opts.name}.service`]);
|
||||
} else if (result === "written") {
|
||||
systemctl(["enable", "--now", `${opts.name}.service`]);
|
||||
systemctl(["restart", `${opts.name}.service`]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
|
||||
function uninstallUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
@@ -110,8 +225,16 @@ function enableLinger() {
|
||||
|
||||
module.exports = {
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
uninstallUserService,
|
||||
enableLinger,
|
||||
unitPath,
|
||||
__setExecImpl,
|
||||
__reset,
|
||||
};
|
||||
|
||||
+6
-35
@@ -13,8 +13,6 @@ Enterprise-grade Node.js backend for Claude Code agent monitoring with real-time
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -557,7 +555,12 @@ failures return `500` with `error.stderr`.
|
||||
|
||||
`start` returns `409 ERUNLIVE` when the lane's recorded run is still `spawning`
|
||||
or `running`: overwriting `run_id` would orphan that child, and a later `reset`
|
||||
would then `git clean -fd` a directory the orphan is still writing into.
|
||||
would then `git clean -fd` a directory the orphan is still writing into. It
|
||||
returns the same code when the lane's tmux pane is running an agent and the
|
||||
request carried a `prompt` or `resumeSessionId` — `pty-run.js` will not type
|
||||
over a live agent, and adopting the pane silently would answer 200 for a resume
|
||||
that never spawned. `POST /api/run` maps that code to `409` too; a start with
|
||||
neither field still adopts the pane.
|
||||
|
||||
Besides the stage a skill declares explicitly, every lane also carries a
|
||||
server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
|
||||
@@ -1532,38 +1535,6 @@ pm2 start server/index.js --name agenthub
|
||||
sudo systemctl start agenthub
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile (root of project)
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
COPY client/package*.json ./client/
|
||||
RUN npm ci --production && cd client && npm ci --production
|
||||
|
||||
# Build client
|
||||
COPY client ./client
|
||||
RUN cd client && npm run build
|
||||
|
||||
# Copy server
|
||||
COPY server ./server
|
||||
COPY data ./data
|
||||
|
||||
EXPOSE 4820
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -t agenthub .
|
||||
docker run -p 127.0.0.1:4820:4820 -v "$HOME/.claude/agenthub:/app/data" agenthub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -369,7 +369,7 @@ describe("agenthub CLI — import & administration", () => {
|
||||
assert.match(err, /Usage: agenthub import/);
|
||||
});
|
||||
|
||||
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
|
||||
it("doctor reports API, hooks, database, tmux, remotes, and uptime lines", async () => {
|
||||
const { code, out } = await agenthub("doctor");
|
||||
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
|
||||
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
|
||||
@@ -377,6 +377,10 @@ describe("agenthub CLI — import & administration", () => {
|
||||
assert.match(out, /Claude Code hooks/);
|
||||
assert.match(out, /Database/);
|
||||
assert.match(out, /Server uptime/);
|
||||
// Asked of the server (GET /api/run/tmux), not of this shell: a missing
|
||||
// tmux is why Start Run fails, and the server's PATH is the one that
|
||||
// decides. Reported either way, and never fatal.
|
||||
assert.match(out, /tmux/);
|
||||
assert.match(out, /Remote sources/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
|
||||
* native profile, and the guarantee that a stored API key never appears in a
|
||||
* response the browser can read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
|
||||
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
|
||||
|
||||
/** The smallest profile the API accepts. */
|
||||
function nineRouter(over = {}) {
|
||||
return {
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ai-routers — the built-in native profile", () => {
|
||||
it("is listed even before anything has been saved", () => {
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].name, "native");
|
||||
assert.equal(items[0].builtin, true);
|
||||
});
|
||||
|
||||
it("leaves the two connection variables empty rather than absent", () => {
|
||||
// The reference native settings.json sets both to "" — an absent key would
|
||||
// let a user-level router base URL leak into a lane meant to run native.
|
||||
const native = routers.getRouter("native");
|
||||
assert.equal(native.baseUrl, "");
|
||||
assert.equal(native.apiKey, "");
|
||||
});
|
||||
|
||||
it("refuses to be edited or removed", () => {
|
||||
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
|
||||
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — create", () => {
|
||||
it("saves a profile and derives its name from the label", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
assert.equal(created.name, "9Router".toLowerCase());
|
||||
assert.equal(created.label, "9Router");
|
||||
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
|
||||
});
|
||||
|
||||
it("stores the file 0600 inside a 0700 directory", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
|
||||
const dir = fs.statSync(ROUTERS_DIR);
|
||||
assert.equal(file.mode & 0o777, 0o600);
|
||||
assert.equal(dir.mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
it("fills every model slot from `model` when no per-tier value is given", () => {
|
||||
// The 9Router reference sets all six slots to one symbolic value; typing it
|
||||
// once must be enough.
|
||||
routers.createRouter(nineRouter());
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "Highest");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
assert.equal(saved.models.haiku, "Highest");
|
||||
assert.equal(saved.models.smallFast, "Highest");
|
||||
assert.equal(saved.models.subagent, "Highest");
|
||||
});
|
||||
|
||||
it("keeps a per-tier value that was given explicitly", () => {
|
||||
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "PremiumCombo");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
});
|
||||
|
||||
it("gives a second profile with the same label a free name", () => {
|
||||
const a = routers.createRouter(nineRouter());
|
||||
const b = routers.createRouter(nineRouter());
|
||||
assert.notEqual(a.name, b.name);
|
||||
});
|
||||
|
||||
it("rejects a label that is empty or carries control characters", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
|
||||
code: "EBADLABEL",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a base URL that is not http(s)", () => {
|
||||
// A `file:` or `javascript:` base URL is never a router; it is either a
|
||||
// mistake or an attempt to point the CLI somewhere it should not go.
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to shadow the built-in native name", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
|
||||
code: "EBUILTIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — the key never reaches the browser", () => {
|
||||
it("is absent from the list, which reports only that one is set", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
const { items } = routers.listRouters();
|
||||
const saved = items.find((i) => i.name === "9router");
|
||||
assert.equal(saved.hasKey, true);
|
||||
assert.equal(saved.apiKey, undefined);
|
||||
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
||||
});
|
||||
|
||||
it("is still absent when the profile has no key at all", () => {
|
||||
routers.createRouter(nineRouter({ apiKey: "" }));
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — update", () => {
|
||||
it("changes the label without changing the name a lane already references", () => {
|
||||
// A lane stores the name. Renaming the label must not orphan that lane.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
|
||||
assert.equal(updated.name, created.name);
|
||||
assert.equal(updated.label, "9Router (nhà)");
|
||||
});
|
||||
|
||||
it("keeps the stored key when the update omits it", () => {
|
||||
// The edit form cannot show the key, so it cannot send it back either.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { label: "renamed" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
|
||||
});
|
||||
|
||||
it("replaces the key when a new one is given", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
it("deletes the file", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.removeRouter(created.name);
|
||||
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
|
||||
assert.equal(routers.listRouters().items.length, 1); // native only
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
|
||||
it("lists the healthy profiles around it", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
|
||||
const { items } = routers.listRouters();
|
||||
assert.ok(items.some((i) => i.name === "9router"));
|
||||
assert.ok(!items.some((i) => i.name === "broken"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — testRouter", () => {
|
||||
const realFetch = global.fetch;
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** Capture the one request testRouter makes, and answer it however asked. */
|
||||
function stubFetch(answer) {
|
||||
const calls = [];
|
||||
global.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
|
||||
|
||||
it("posts one capped message to the endpoint's /v1/messages", async () => {
|
||||
const calls = stubFetch(ok());
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.max_tokens, 1);
|
||||
assert.equal(body.model, "Highest");
|
||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||
});
|
||||
|
||||
it("reports a router that answered with an error rather than throwing", async () => {
|
||||
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
|
||||
});
|
||||
|
||||
it("reports an endpoint that never answered", async () => {
|
||||
// The shape node's own fetch throws: a useless outer message wrapping the
|
||||
// one thing the user can act on.
|
||||
stubFetch(
|
||||
Object.assign(new Error("fetch failed"), {
|
||||
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
|
||||
})
|
||||
);
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, null);
|
||||
assert.match(result.message, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("falls back to the stored key for the endpoint it was stored against", async () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await routers.testRouter({
|
||||
name: created.name,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
});
|
||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||
});
|
||||
|
||||
it("never sends the stored key to a different endpoint", async () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await assert.rejects(
|
||||
routers.testRouter({
|
||||
name: created.name,
|
||||
baseUrl: "https://attacker.example/v1",
|
||||
model: "Highest",
|
||||
}),
|
||||
{ code: "EBADKEY" }
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("refuses a base URL that is not http(s)", async () => {
|
||||
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -151,10 +151,13 @@ describe("cc-plugin-cli argv construction", () => {
|
||||
"disable",
|
||||
"demo@m",
|
||||
]);
|
||||
// `-y` is mandatory, not cosmetic: runClaude closes stdin, and the CLI
|
||||
// refuses to update without it whenever stdin is not a TTY.
|
||||
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"update",
|
||||
"demo@m",
|
||||
"-y",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
|
||||
"plugin",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user