Compare commits

..

14 Commits

Author SHA1 Message Date
nntrivi2001 e01cb26bde chore(release): bump version to 1.8.13 2026-10-01 13:41:54 +07:00
nntrivi2001 f887c16309 feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
2026-10-01 13:39:17 +07:00
nntrivi2001 c5aba094bd feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview
- AI Tools moves from a Settings section into a modal behind a button at the
  Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs,
  /settings#ai-tools); rows get an enable switch, Default badge, and an
  Update button for every tool whose catalog names an updater (no version
  lookup on open); not-installed tools fold behind a disclosure, a finished
  job's log folds to one line; shared AiToolIcon
- codex updates through its own `codex update`, openclaw/hermes gain
  `update --yes`; update-all runs every installed updater and skips an npm
  one only when npm does not manage the binary
- Add-lane tool picker is one button showing the chosen tool that opens the
  enabled tools as buttons
- Claude Code / Codex AgentHub sub-tab folded into Overview (home editor,
  hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME;
  HomeDirSetting shows the saved value only while the field differs
- Preview/Raw toggle for markdown files in the config FileViewer and the
  Changes panel's whole-file view (MarkdownPreview: react-markdown +
  remark-gfm, raw HTML never run, frontmatter as a code block)
- docs and the CLAUDE.md marker updated
2026-10-01 10:11:43 +07:00
nntrivi2001 058c574138 fix(updates): report a plugin build installed beside the running one, never "up to date" on a failed check
A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).

- pluginStatus asks supersededBy for its own root: a recorded sibling install
  sets `installed_not_running: {root, version}` and `update_available`, with
  no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
  on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
  sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
  updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
2026-10-01 10:09:12 +07:00
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00
nntrivi2001 7ec5a7cd85 feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2) 2026-09-29 08:25:37 +07:00
nntrivi2001 fcfebe44f1 feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)
Settings → Dashboard gains an AI Tools section (installed tools with
Enabled/Disabled and Set default, available ones with a homepage link,
Refresh). useAiTools shares one catalog copy across the app and replaces
LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the
router row reads Native (<tool>) and disables AI Router profiles for tools
that cannot use one. The run form gives catalog tools resume and a red
per-run Bypass permissions switch (Codex too). Claude Code / Codex settings
tabs hide while that tool is disabled. The Workspace probes each lane tool's
CLI fresh.

Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md,
server/README.md, OpenAPI fragment + regenerated openapi.yaml.
2026-09-29 08:21:05 +07:00
nntrivi2001 77917203fd feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from
Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and
keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the
login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes
and toggles; /api/run/binary resolves through the catalog.

Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL
at start once a tool left the catalog). pty-run keeps the Claude/Codex
builders and builds every other tool's argv from its catalog file, pasting an
after-start prompt through a tmux buffer; every pane gets the hydrated PATH.
A per-run bypass switch appends the catalog's bypass args (Codex included).
POST /api/run now forwards sandbox/approval/resumeLast, which it dropped.
agenthub lanes add gains --tool.
2026-09-29 08:18:40 +07:00
nntrivi2001 0aa7fc232e docs(spec): AI Tools part A1 — catalog, detection, enable/default, lanes for any tool (#40) 2026-09-29 08:18:40 +07:00
nntrivi2001 887dc4640e chore(release): bump version to 1.8.12 2026-09-28 17:49:38 +07:00
nntrivi2001 2b8f83d2dd refactor(lanes): drop the workflow skills and Superpowers link; pipelines go dormant
The plugin stops shipping its workflow layer:
- skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash
  commands (plugin.json keeps agenthub-doctor/update/open), and all four
  agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer).
- CLI `agenthub skills install`, `agenthub lanes agents install`,
  `agenthub ship-full|ship-quick`; routes POST /api/skills/install and
  POST /api/lanes/:id/agents/install; the Settings button, the lane Tools
  menu item and the Add-lane setup step that called them.
- The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's
  autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row.

Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no
template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0),
createLane still accepts it, POST /api/lanes/:id/stage answers 400
ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap
and strip stage chip for a lane with no nodes. A template dropped into
DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its
templates from server/__tests__/fixtures/pipelines/.

Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE,
docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
2026-09-28 17:49:05 +07:00
nntrivi2001 3c238bc5c4 chore(release): bump version to 1.8.11 2026-09-28 10:26:56 +07:00
nntrivi2001 9d096b61f7 feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
  carries the first prompt, `--continue` resumes; `agy` has no model,
  effort or permission flag, and `--dangerously-skip-permissions` is never
  emitted. No pipeline, no agents install, and no AI Router (native only,
  else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
  probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
  and the prompt box names the lane's CLI ("Ask Codex anything…"), via
  LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
  (healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
  hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
  continues the previous thread.
2026-09-28 10:22:07 +07:00
nntrivi2001 952e8584f0 feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
2026-09-25 17:30:28 +07:00
274 changed files with 23912 additions and 6742 deletions
+1 -19
View File
@@ -12,25 +12,7 @@
"commands": [
"./plugins/agenthub/commands/agenthub-doctor.md",
"./plugins/agenthub/commands/agenthub-update.md",
"./plugins/agenthub/commands/agenthub-open.md",
"./plugins/agenthub/commands/intake.md",
"./plugins/agenthub/commands/plan.md",
"./plugins/agenthub/commands/implement.md",
"./plugins/agenthub/commands/tests.md",
"./plugins/agenthub/commands/e2e.md",
"./plugins/agenthub/commands/e2e-passed.md",
"./plugins/agenthub/commands/review.md",
"./plugins/agenthub/commands/qc-plan.md",
"./plugins/agenthub/commands/qc.md",
"./plugins/agenthub/commands/gate.md",
"./plugins/agenthub/commands/publishing.md",
"./plugins/agenthub/commands/pr-open.md",
"./plugins/agenthub/commands/reported.md",
"./plugins/agenthub/commands/watching-pr.md",
"./plugins/agenthub/commands/merged.md",
"./plugins/agenthub/commands/done.md",
"./plugins/agenthub/commands/ship-full.md",
"./plugins/agenthub/commands/ship-quick.md"
"./plugins/agenthub/commands/agenthub-open.md"
],
"hooks": {
"PreToolUse": [
+26
View File
@@ -2,6 +2,11 @@
# Override this if your Claude Code data is in a different location, e.g.:
# CLAUDE_HOME=~/.codefuse/engine/cc
# CLAUDE_HOME=~/.claude
# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR)
# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins
# over it, and the real environment wins over both; a key set here or in the real
# environment is reported as env-owned in Settings (a value saved there would be
# overridden again at the next start).
# ── Server / network ────────────────────────────────────────────────────────
# Port to listen on (default: 4820)
@@ -66,6 +71,27 @@
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
# DASHBOARD_SESSION_SYNC_MS=30000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
# Where agy keeps its data, when not ~/.gemini (agy itself has no override).
# AGENTHUB_GEMINI_DIR=~/.gemini
# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**):
# drives both the codex lane running/idle mirror and Codex sessions + tokens
# (0 disables both; default: 15000).
# DASHBOARD_CODEX_POLL_MS=15000
# Automatic pricing: a model seen in token usage with no rate row of its own
# gets one from LiteLLM's public price list. 0 / false / off disables it
# (default: enabled).
# AGENTHUB_PRICING_SYNC=0
# Where settings saved from the dashboard persist (default:
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
# Here or in the real environment; the file is located after this one loads.
# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env
# ── Remote Data Sources (SSH multi-machine collection) ───────────────────────
# The dashboard can pull Claude Code history from other machines over SSH:
# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging
+4 -35
View File
@@ -11,42 +11,11 @@
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
## Declare your lane stage as you work
## Lane stage
This repository is watched by AgentHub, its own dashboard. A **lane** is keyed by
working directory, and the pipeline map only moves forward when the agent doing
the work declares its stage:
```bash
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
```
- Call it at every real transition — starting to plan, starting to implement,
running tests, opening the PR — not once at the end.
- Stage names, `default` template: `intake → plan → implement → tests → review
→ gate → ship → done` (the 16-node `ship-feature` template accepts these as
aliases too; `agenthub stage` warns on stderr and names the valid nodes if a stage
matches none).
- **`--tool codex` matters.** One directory can back both a `claude` lane and a
`codex` lane, and without a tool the command fails rather than guess (it names
both candidates). Inside a pane the dashboard started you need nothing — that
pane carries `AGENTHUB_LANE_ID`, which outranks everything but `--lane`.
- If `agenthub stage` reports that no lane owns the directory, this checkout was
never adopted: skip the stage calls and carry on. Stage reporting is
reporting, never control flow — a failed `agenthub stage` never justifies skipping
a step of the actual work.
The dashboard also *infers* your stage from your tool calls (it reads Codex's
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
that inference does move the pipeline map forward on its own. It still never
counts as **evidence**: a node it moved through stays amber and never renders
green. Only your own declaration does that — which is why the call above is
worth making even when the map already looks right.
If you hold a stage for a while with no evidence recorded, the dashboard may
type a one-line `[agenthub]` reminder straight into this pane asking you to run
`agenthub stage ... --evidence "..."`. That is the dashboard, not the user — treat
it as the same instruction as this section and just make the real call.
AgentHub's `codex` lanes (like every non-Claude AI tool lane) carry no pipeline: the dashboard shows no stage for
them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex.
## Where to work
- `server/` for API/routes/data processing.
+208 -93
View File
File diff suppressed because one or more lines are too long
+14 -13
View File
File diff suppressed because one or more lines are too long
+23 -16
View File
@@ -31,11 +31,10 @@ dashboard, run it from source, or stay on Node 20.
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **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) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. 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` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` |
| 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` |
---
@@ -101,9 +100,14 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **No systemd, so no auto-start on boot.** The server is a detached process
that runs until logout or reboot. It comes back at the next Claude Code
session start (within about 10 s), or by hand with `agenthub start`. A
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
service install`), so the dashboard comes back after every login with no
Claude Code session, and launchd restarts it if it dies. Lane panes survive
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
`launchctl` fails, the server is a detached process started by the next
Claude Code session (within about 10 s) or by `agenthub start`, as before. A
discovery entry left over from before the reboot is ignored, so a recycled
pid can never pass for a live dashboard.
- **Started from a Claude Code session.** The server drops the variables that
@@ -124,15 +128,17 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
**Mac checklist** — please run it and report the result on issue #22:
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
2. `tmux show-environment -g | grep -i claude` prints nothing.
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
5. Changes panel: diff, stage and commit on a lane.
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
8. Reboot, open `claude`, and the dashboard comes back within about 10 s.
9. `/agenthub-update` completes and the server restarts on the new version.
8. Reboot and log in WITHOUT opening `claude`: the dashboard answers within about 10 s (`agenthub service status` shows `state = running`).
9. `kill <server pid>` (from `agenthub status`): it is back within a few seconds.
10. With a lane run open, `/agenthub-update` completes, the server restarts on the new version, the plist names the new cache directory, and the run's terminal is still alive.
11. `agenthub service uninstall`: the plist is gone and nothing restarts the server after a `kill`.
---
@@ -208,7 +214,7 @@ After starting a Claude Code session, you should see:
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
- **Activity Feed** — events streaming in as Claude Code uses tools
- **Dashboard** — stats updating in real-time
- **Settings page** — model pricing rules, hook configuration status, data export and cleanup tools
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
@@ -287,13 +293,15 @@ npm start # Start Express serving client/dist on port 4820
Open **http://localhost:4820** in your browser.
`npm start` (and `agenthub start`, its backgrounded form) only run while that
process is alive — a reboot loses it. On Linux, run the server as a systemd
`--user` service instead so it comes back on its own after every reboot, with
no manual step and no Claude Code (or any other tool) needed to trigger it:
process is alive — a reboot loses it. Run the server as a systemd `--user`
service (Linux) or a LaunchAgent (macOS) instead so it comes back on its own
after every reboot, with no manual step and no Claude Code (or any other tool)
needed to trigger it:
```bash
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
agenthub service status # systemctl --user status agenthub
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
@@ -310,7 +318,6 @@ This repository includes extension packs for both Claude Code and Codex.
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- `agents/` (shipped as the `agenthub` plugin's agents)
- Codex project packs live under `.codex/`:
- `AGENTS.md`
- `.codex/rules/`
+51 -60
View File
@@ -3,7 +3,7 @@
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
parallel agent work through a pipeline; and the `agenthub` CLI drives real terminal
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, lane runtimes, and Claude Code's own configuration.
AgentHub records and shows — it does not orchestrate. The session in control makes
@@ -31,11 +31,13 @@ Internal build — all rights reserved.
**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
that never calls `agenthub stage` still shows progress — rendered dashed amber and
never as done, because an inference is not evidence.
- **Lanes.** One lane per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a lane moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
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
@@ -51,8 +53,6 @@ Internal build — all rights reserved.
merge/rebase/reset button by design: recovery from those needs a terminal.
- **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.
**Configure**
@@ -78,10 +78,10 @@ Internal build — all rights reserved.
| **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 |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane |
| **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 |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
| **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
@@ -122,34 +122,6 @@ 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
`superpowers:brainstorming`, `writing-plans`, `test-driven-development`,
`requesting-code-review` and `verification-before-completion` phase by phase,
and both built-in pipeline templates name those same slash commands as their
per-node drivers. Without it the dashboard, hooks and CLI all work
normally — the two pipeline skills are what stop partway. `/agenthub-doctor` reports
it as a `WARN` row.
**You do not normally have to install it.** The plugin's `SessionStart`
bootstrap installs it for you, once, in its detached worker — so a session never
waits on the clone, and the current session needs `/reload-plugins` before the
skills resolve (any later session picks them up on its own). The outcome is
logged to `~/.claude/agenthub/runtime/bootstrap.log`, and `/agenthub-update`
retries a failed one. To install it by hand instead, or after opting out:
```bash
claude plugin install superpowers@claude-plugins-official
```
Set `AGENTHUB_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the
plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed
this way — `superpowers` today. A custom pipeline template naming some other
plugin's command is *reported* by `/agenthub-doctor` and never installed, because a
template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`.
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
@@ -193,19 +165,20 @@ every event is counted twice.
### Run as a service (auto-start on boot)
`npm start` only runs while that terminal (or `agenthub start`'s background
process) is alive — a reboot loses it. On Linux, `agenthub service install` sets
up a systemd `--user` service instead, so `:4820` is up again on its own after
process) is alive — a reboot loses it. `agenthub service install` sets up a
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
every reboot, with no manual step and no Claude Code (or any AI tool) needed
to trigger it:
```bash
npm run build # the service serves the built client, same as npm start
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
agenthub service status # systemctl --user status agenthub
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
`agenthub service install` also tries `loginctl enable-linger` so the service
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. Plugin installs get the same persistence automatically — see
@@ -223,7 +196,6 @@ agenthub sessions # recent sessions
agenthub lanes # lanes with stage and progress
agenthub lanes pipeline # this lane's pipeline template, or switch it
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
# (in a session: /intake, /qc, /ship-full, /ship-quick …)
agenthub stage <name> # declare the current lane's stage
agenthub tail # live event feed
```
@@ -250,22 +222,41 @@ agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktre
Every lane also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex emits none of this
dashboard's hooks, so a Codex lane records its runs but stays absent from
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
also **advances the declared stage** — the one place an inference moves the blue
`current` ring, because a Codex session has no hook and nothing else would ever
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
node stays amber (`passed-no-evidence`), never green, since only the session
itself can say what it finished. Two things help it get there (the run console
no longer pre-fills anything into the prompt box — that box is the user's to
type): `agenthub lanes agents install` writes the stage-declaration
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
a lane's pane if its current stage sits without evidence for more than two
minutes.
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a lane's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex lane turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the lane
card and as a notification while Codex waits for an approval. A Codex lane also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** lane runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
lane that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity lane never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its lane set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity lanes carry no pipeline either.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
+20 -31
View File
@@ -3,7 +3,7 @@
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
công việc song song của agent qua một pipeline; còn CLI `agenthub` điều khiển phiên
công việc song song của agent, mỗi working directory một lane; còn CLI `agenthub` điều khiển phiên
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
@@ -31,10 +31,12 @@ Internal build — all rights reserved.
**Đ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
session không bao giờ gọi `agenthub stage` vẫn hiển thị progress — render dạng
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Khi bạn
cài một pipeline template (không có template nào đi kèm), lane đi qua các
stage của nó và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
progress — render dạng
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
không phải bằng chứng.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
@@ -44,8 +46,6 @@ Internal build — all rights reserved.
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.
**Cấu hình**
@@ -67,10 +67,10 @@ Internal build — all rights reserved.
| **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 |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống |
| **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 |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` |
| **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
@@ -110,23 +110,6 @@ trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub l
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
Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không cần
@@ -200,7 +183,6 @@ agenthub sessions # session gần đây
agenthub lanes # danh sách lane kèm stage và progress
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
# (trong session: /intake, /qc, /ship-full, /ship-quick …)
agenthub stage <name> # khai báo stage hiện tại của lane
agenthub tail # xem live event feed
```
@@ -228,10 +210,17 @@ chọn trong modal Add lane và cố định suốt vòng đời của lane. Lan
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms,
mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính
session đó mới gọi được `agenthub stage`.
Lane Codex cũng **không có pipeline** — không stage, không pipeline map, không
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
nào, nên lane không có stage cho tới khi bạn thả một file template JSON vào
`DASHBOARD_PIPELINES_DIR` (xem
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
này sẽ được phát triển tiếp.
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
+5 -1
View File
@@ -132,7 +132,11 @@ npm run clear-data
### Data management via Settings page
The Settings page (`/settings`) provides a UI for:
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
File diff suppressed because one or more lines are too long
-27
View File
@@ -1,27 +0,0 @@
---
name: backend-reviewer
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
tools: Read, Grep, Glob, Bash
model: sonnet
memory: project
---
You are a backend reviewer for this repository. Review ONLY files under `server/` in the diff you are given — ignore `client/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
Focus on:
- Hook event lifecycle correctness.
- Session/agent state-machine regressions.
- API contract compatibility.
- Transaction and persistence correctness.
- Missing or weak verification coverage.
## Output (MANDATORY — the caller parses your last line)
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
- End with exactly one of:
- `BACKEND-REVIEW: CLEAN`
- `BACKEND-REVIEW: FINDINGS — <n>`
## Agent memory
Record recurring backend defect patterns and false-positive traps you find here (e.g. hook shapes that look wrong but aren't, common state-machine edge cases). Future backend-reviewer runs read this.
-27
View File
@@ -1,27 +0,0 @@
---
name: frontend-reviewer
description: Review React UI changes for behavior regressions, state consistency, and UX breakage.
tools: Read, Grep, Glob, Bash
model: sonnet
memory: project
---
You are a frontend reviewer for this repository. Review ONLY files under `client/` in the diff you are given — ignore `server/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
Focus on:
- Routing and navigation consistency.
- State updates from websocket and API responses.
- Loading/empty/error state correctness.
- Breaking visual or interaction regressions.
- Missing tests for changed UI behavior.
## Output (MANDATORY — the caller parses your last line)
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
- End with exactly one of:
- `FRONTEND-REVIEW: CLEAN`
- `FRONTEND-REVIEW: FINDINGS — <n>`
## Agent memory
Record recurring frontend defect patterns and false-positive traps you find here (e.g. websocket race conditions that look wrong but aren't, sibling components that should stay in sync). Future frontend-reviewer runs read this.
-74
View File
@@ -1,74 +0,0 @@
---
name: "qc-local"
description: "Local browser-QC agent for the ship-feature-lane pipeline in AgentHub. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `agenthub lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the AgentHub proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
model: opus
color: green
memory: project
---
You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.
## Context you will be given (by the ship-feature-lane skill)
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically; there is no marker file to check).
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).
Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).
## Target environment
- **App**: the lane's own booted frontend — check `agenthub lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: agenthub lanes up --no-build)`.
- **QC boot env**: the QC stack boots via `agenthub lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
- **Account / login.** Two paths:
- **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
- **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.
## Procedure
1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
- **Cover every in-scope scenario** in the plan — skip none.
- **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
- **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
- **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
- **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
- **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
- **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
- **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
- No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
2. **Execute with the local-QC MCP's tools**:
- Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
- After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
- Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`agenthub lanes logs <svc>`).
- **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`agenthub lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.
## Checkpoint & resume *(rate-limits and crashes must not zero your work)*
- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.
## Quality bar (verify before returning)
- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
- [ ] No commits made; password never echoed anywhere.
## Output format (MANDATORY — the skill parses your last line)
A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:
- `LOCAL-QC: PASS`
- `LOCAL-QC: FAIL — <comma-separated reasons>`
A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).
## Agent memory
Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
-52
View File
@@ -1,52 +0,0 @@
---
name: "senior-gate-reviewer"
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in AgentHub. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
model: opus
color: red
memory: project
---
You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.
## Context you will be given (by the ship-feature-lane skill)
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically).
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
- The **`agenthub lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.
If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).
## Your checks
**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.
1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.
2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.
3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && agenthub lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
- **Stack-specific checks (from the active profile):** read `<lane>/.agenthub/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
- **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.
4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
- **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
- Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
- Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.
5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.
## Output format (MANDATORY — the skill parses your last line)
Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:
- `VERDICT: GO`
- `VERDICT: NO-GO — <comma-separated required fixes>`
Rules:
- Only return `GO` when all three checks pass with high confidence.
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
+49 -180
View File
@@ -1463,16 +1463,24 @@ async function cmdStart(flags) {
}
/**
* Installs/removes a systemd `--user` service so the dashboard survives
* reboots without anything (Claude Code included) needing to start it first.
* Linux/systemd only; other platforms are told to keep using `agenthub start`.
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code
* included) needing to start it first. Elsewhere, `agenthub start` by hand.
*/
async function cmdService(sub, flags) {
const svc = require(path.join(REPO_ROOT, "scripts", "systemd-service.js"));
const svc = require(path.join(REPO_ROOT, "scripts", "service-manager.js"));
if (!svc.systemctlAvailable()) {
console.error(c.red("✖ systemd --user is not available on this machine."));
console.error(
c.dim(" Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead.")
c.red(
`✖ ${svc.kind === "launchd" ? "launchd (a GUI login session)" : "systemd --user"} is not available here.`
)
);
console.error(
c.dim(
svc.kind === "launchd"
? " A LaunchAgent needs the user logged in at the Mac itself, not only over SSH. Use `agenthub start` meanwhile."
: " Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead."
)
);
process.exit(1);
}
@@ -1493,24 +1501,27 @@ async function cmdService(sub, flags) {
description: "AgentHub dashboard server",
workingDir: REPO_ROOT,
execStart: `${process.execPath} ${path.join(REPO_ROOT, "server", "index.js")}`,
argv: [process.execPath, path.join(REPO_ROOT, "server", "index.js")],
env,
});
if (result === "foreign") {
console.error(
c.red(
`✖ ~/.config/systemd/user/${name}.service already exists and wasn't written by agenthub.`
)
c.red(`✖ ${svc.unitPath(name)} already exists and wasn't written by agenthub.`)
);
console.error(c.dim(" Remove or rename it, then re-run `agenthub service install`."));
process.exit(1);
}
if (result === "failed") {
console.error(c.red("✖ Failed to write/enable the systemd unit."));
console.error(c.red(`✖ Failed to write/enable the ${svc.label}.`));
process.exit(1);
}
console.log(
`${c.green("✔")} systemd --user service '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
`${c.green("✔")} ${svc.label} '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
);
if (svc.kind === "launchd") {
console.log(c.dim(` It starts at every login. Server log: ${svc.logPath()}`));
return;
}
const lingered = svc.enableLinger();
if (lingered) {
console.log(
@@ -1531,20 +1542,24 @@ async function cmdService(sub, flags) {
if (sub === "uninstall") {
const result = svc.uninstallUserService(name);
if (result === "foreign") {
console.error(
c.red(
`✖ ~/.config/systemd/user/${name}.service wasn't written by agenthub — not touching it.`
)
);
console.error(c.red(`✖ ${svc.unitPath(name)} wasn't written by agenthub — not touching it.`));
process.exit(1);
}
console.log(
`${c.green("✔")} systemd service ${result === "absent" ? "was not installed" : "removed"}.`
`${c.green("✔")} ${svc.label} ${result === "absent" ? "was not installed" : "removed"}.`
);
return;
}
if (sub === "status") {
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], { stdio: "inherit" });
if (svc.kind === "launchd") {
spawn("launchctl", ["print", `gui/${process.getuid()}/io.smartgift.${name}`], {
stdio: "inherit",
});
} else {
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], {
stdio: "inherit",
});
}
return;
}
console.error(c.red("✖ Usage: agenthub service install|uninstall|status [--port N]"));
@@ -1593,10 +1608,11 @@ async function cmdLanesAdd(args) {
const pipeline = flag("pipeline");
const base = flag("base");
const slug = flag("slug");
const tool = flag("tool");
if (repo) {
if (cwd || pipeline) {
console.error(
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]"
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1604,6 +1620,7 @@ async function cmdLanesAdd(args) {
const body = { sourceRepo: repo, title: title || path.basename(repo) };
if (base) body.base = base;
if (slug) body.slug = slug;
if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body);
const deadline = Date.now() + 30_000;
let lane = provisioning;
@@ -1629,37 +1646,19 @@ async function cmdLanesAdd(args) {
return;
}
if (!cwd || !title) {
console.error("usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>]");
console.error(
"usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
);
process.exitCode = 1;
return;
}
const body = { cwd, title };
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
}
/**
* `agenthub skills install` — copy this checkout's `skills/ship-feature-lane/`
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
* discoverable from a session running inside any lane's own working
* directory, not just inside this repo. Pure filesystem action; does not
* talk to the dashboard server at all. Reinstall, not merge — overwrites an
* existing global copy with the current one.
*/
function cmdSkillsInstall() {
const { installShipFeatureLaneSkill } = require(
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
);
try {
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
} catch (err) {
console.error(`✖ ${err.message}`);
process.exitCode = 1;
}
}
/**
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on
@@ -2402,25 +2401,6 @@ async function cmdLanesProofLink(args) {
);
}
/** `agenthub lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
* git-excluded. Never run automatically; a session calls it explicitly. */
async function cmdLanesAgentsInstall(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(
`/api/lanes/${resolved.laneId}/agents/install`,
{},
{ allowError: true }
);
if (result.status) {
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
}
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */
@@ -2499,8 +2479,7 @@ async function cmdLanesCi(args) {
* was display-only: the dashboard rendered a copy button and the operator
* pasted it into a session by hand. This runs it, in the lane's own cwd:
*
* - a slash command (`/superpowers:brainstorming`) starts `claude` with it
* as the prompt, exactly like `agenthub ship-full` does with the whole skill;
* - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
*
* With no node id it lists the nodes and their commands, which is also the
@@ -2663,88 +2642,6 @@ async function cmdLanesShell(args) {
});
}
/**
* Start a `claude` session in this lane driven by a pipeline's own skill.
* Two commands, two depths of work:
*
* agenthub ship-full → the 16-node `ship-feature` template, `/agenthub:ship-feature-lane`
* — autonomous to a published PR, then watched until a
* human merges it.
* agenthub ship-quick → the 8-node `default` template, `/agenthub:ship-feature`
* — in-repo feature work that stops at review/gate.
*
* Each command puts the lane on its own template, so the choice is the command
* you type; the skill string itself lives in the template's `skill` field and
* is never duplicated here.
*
* The prompt is passed as ONE argv element to `claude`, never through a shell —
* a requirement containing quotes, `$`, or `;` is text, not code. `--print`
* shows the exact command, switches nothing, and runs nothing.
*/
const SHIP_TEMPLATES = {
"ship-full": "ship-feature",
"ship-quick": "default",
};
async function cmdShip(command, args) {
const templateId = SHIP_TEMPLATES[command];
const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args);
if (!resolved) return;
let { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
const skill = pipelines.find((p) => p.id === templateId)?.skill;
if (!skill) {
console.error(
`✖ pipeline template "${templateId}" declares no driving skill — ` +
`add a "skill" field to it (see docs/LANES.md § Custom template format)`
);
process.exitCode = 1;
return;
}
// `resolveLaneArg` only strips a leading bare lane id; the rest of the
// positionals are the requirement, joined back into one prompt line.
const requirement = positional
.filter((p) => p !== String(resolved.laneId))
.join(" ")
.trim();
const prompt = requirement ? `${skill} ${requirement}` : skill;
if (flags.print || !requirement) {
// `--print` shows what would run, so it switches nothing.
if (!requirement) console.error(`usage: agenthub ${command} "<requirement>" [<id>] [--print]`);
console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`);
if (!requirement) process.exitCode = 1;
return;
}
// The command picks the pipeline, not the lane's current setting: put the
// lane on the matching template first so the dashboard renders the right
// node count from the start rather than after the skill self-corrects.
if (lane.pipeline !== templateId) {
({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId }));
}
console.log(
`${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` +
`${c.cyan("→")} claude ${JSON.stringify(prompt)}`
);
const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd });
await new Promise((resolve) => {
child.on("close", (code) => {
if (code) process.exitCode = code;
resolve();
});
child.on("error", (err) => {
console.error(c.red(`✖ ${err?.message || err}`));
process.exitCode = 1;
resolve();
});
});
}
async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
@@ -2774,7 +2671,7 @@ async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
);
process.exitCode = 1;
return;
@@ -2897,10 +2794,14 @@ const COMMAND_GROUPS = [
"Lanes",
[
["lanes", "", "List lanes with stage, liveness and progress"],
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new lane bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree lane",
],
[
@@ -2923,16 +2824,6 @@ const COMMAND_GROUPS = [
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
],
[
"ship-full",
'"<requirement>" [<id>] [--print]',
"Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)",
],
[
"ship-quick",
'"<requirement>" [<id>] [--print]',
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
],
[
"lanes shell",
"[<id>]",
@@ -2961,11 +2852,6 @@ const COMMAND_GROUPS = [
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lanes agents install",
"[<id>]",
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
],
[
"lanes mcp sync",
"[<id>]",
@@ -2986,11 +2872,6 @@ const COMMAND_GROUPS = [
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
],
[
"skills install",
"",
"Install skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
@@ -2999,7 +2880,7 @@ const COMMAND_GROUPS = [
[
"stage <stage> [flags]",
"",
"Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)",
"Report the current pipeline stage for a lane (--tool <id> when one cwd has lanes for two tools)",
],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
@@ -3817,9 +3698,6 @@ async function runCommand(argv) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
if (rest[0] === "agents" && rest[1] === "install") {
return cmdLanesAgentsInstall(rest.slice(2));
}
if (rest[0] === "mcp" && rest[1] === "sync") {
return cmdLanesMcpSync(rest.slice(2));
}
@@ -3842,12 +3720,6 @@ async function runCommand(argv) {
return cmdLanesGc(rest.slice(1));
}
return cmdLanes();
case "skills": {
if (rest[0] === "install") return cmdSkillsInstall();
console.error("usage: agenthub skills install");
process.exitCode = 1;
return;
}
case "lock": {
const sub = rest[0];
if (sub === "status") return cmdLockStatus(rest.slice(1));
@@ -3861,9 +3733,6 @@ async function runCommand(argv) {
}
case "stage":
return cmdStage(rest);
case "ship-full":
case "ship-quick":
return cmdShip(cmd, rest);
case "feature": {
const sub = rest[0];
if (sub === "list") return cmdFeatureList(rest.slice(1));
+24 -3
View File
@@ -186,6 +186,15 @@ client/
│ │ ├── Sidebar.tsx
│ │ ├── Layout.tsx
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
│ │ ├── settings/ # Settings panels, among them:
│ │ │ ├── AiToolsModal.tsx # AI Tools dialog, opened from the Settings tab bar's right edge
│ │ │ ├── AiToolsSettings.tsx # Its body: installed/available CLIs, enable/default, install/update
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
│ │
│ ├── pages/ # Route pages
@@ -195,6 +204,7 @@ client/
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx
│ │ ├── Settings.tsx
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
│ │ └── NotFound.tsx
│ │
│ ├── lib/ # Core utilities & business logic
@@ -210,7 +220,7 @@ client/
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── i18n/ # Internationalization (en / vi)
│ ├── App.tsx # Root component + router setup
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
@@ -239,7 +249,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
Settings[Settings<br/>pricing + notifications + hooks]
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → Overview, Antigravity:<br/>each tool's pricing + hooks]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
@@ -285,7 +295,7 @@ sequenceDiagram
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → Overview), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
### State Strategy
@@ -755,6 +765,17 @@ closes. Consequences worth knowing:
expanding a truncated **number** inside a line of text, but draws the same
surface so the two read as one thing.
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
behind a **Preview** / **Raw** toggle in two places: the config explorer's
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
---
## Utilities
+1484 -8
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^6.28.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+42
View File
@@ -0,0 +1,42 @@
/**
* @file AiToolIcon.tsx
* @description An AI tool's icon: the server's cached favicon
* (GET /api/ai-tools/:id/icon), or the label's first letter when there is
* none. Shared by the AI Tools modal and the Add-lane tool picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api, type AiTool } from "../lib/api";
/**
* @param props.tool - The catalog entry.
* @param props.className - Size classes (default `h-8 w-8`).
*/
export function AiToolIcon({
tool,
className = "h-8 w-8",
}: {
tool: Pick<AiTool, "id" | "label">;
className?: string;
}) {
// Keyed by id: one instance showing another tool must retry its icon.
const [failedId, setFailedId] = useState<string | null>(null);
if (failedId !== tool.id) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailedId(tool.id)}
className={`${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`}
/>
);
}
return (
<span
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
>
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file MarkdownPreview.tsx
* @description A markdown file rendered for reading (GitHub-flavoured:
* tables, task lists, strikethrough, autolinks), shared by the config
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
* shown as text, never run (react-markdown without rehype-raw), and links
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
* shown as a code block instead of being misread as a heading. `isMarkdown`
* says which paths get the preview toggle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/** Whether `path` names a markdown file. */
export function isMarkdown(path: string | null | undefined): boolean {
return !!path && /\.(md|markdown|mdx)$/i.test(path);
}
/** Split a leading `---` frontmatter block off the body. */
function splitFrontmatter(text: string): { front: string | null; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
}
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
const { front, body } = splitFrontmatter(text);
return (
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
{front !== null && <pre className="md-frontmatter">{front}</pre>}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noreferrer noopener" />
),
}}
>
{body}
</ReactMarkdown>
</div>
);
}
+17 -5
View File
@@ -2,10 +2,11 @@
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
* the owning page keeps the active tab in its own query param.
* the owning page keeps the active tab in its own query param. `actions` sits at
* the bar's right edge, outside the tablist.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import type { ComponentType, ReactNode } from "react";
export interface PageTab {
id: string;
@@ -18,6 +19,8 @@ interface PageTabsProps {
activeId: string;
onSelect: (id: string) => void;
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
}
/**
@@ -25,12 +28,14 @@ interface PageTabsProps {
* @param props - Tabs to render, the active tab id, and the selection callback.
* @returns The tab bar element.
*/
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
return (
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
const list = (
<div
role="tablist"
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
>
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
})}
</div>
);
if (!actions) return list;
return (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
);
}
@@ -0,0 +1,30 @@
/**
* @file SessionToolBadge — names the AI coding CLI that produced a session
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
* list and session header look as before for every Claude row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useAiTools } from "../hooks/useAiTools";
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
export function SessionToolBadge({ tool }: { tool?: string | null }) {
if (!tool || tool === "claude") return null;
return <ToolChip tool={tool} />;
}
// Split out so the catalog hook only mounts for a non-Claude session.
function ToolChip({ tool }: { tool: string }) {
const { t } = useTranslation("sessions");
const { label } = useAiTools();
return (
<span
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
title={t("toolBadgeTitle", { tool: label(tool) })}
>
{label(tool)}
</span>
);
}
+19 -9
View File
@@ -368,15 +368,25 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
? t("nav:checkingForUpdates")
: checkError
? t("nav:checkFailed")
: updateAvailable
? 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");
: updateStatus?.installed_not_running
? t("nav:installedNotRunning", {
version: updateStatus.installed_not_running.version ?? "?",
})
: updateAvailable
? 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 })
: // A plugin install has no commit count to fall back on: a release
// that could not be read is "unknown", never "up to date".
updateStatus?.install_kind === "plugin" &&
updateStatus.release_check &&
updateStatus.release_check !== "ok"
? t("nav:releaseUnchecked")
: 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];
+13 -3
View File
@@ -185,7 +185,13 @@ export function UpdateNotifier() {
// 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;
// A build installed from a terminal while this server kept running has no
// tag or sha when the forge is unreachable — key it by its directory.
const installed = status?.installed_not_running ?? null;
const dismissKey =
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
const dismiss = useCallback(() => {
@@ -320,12 +326,16 @@ export function UpdateNotifier() {
id="update-notifier-title"
className="text-sm font-semibold text-fg-primary truncate"
>
{release ? t("releaseTitle", { version: release }) : t("title")}
{installed
? t("installedTitle", { version: installed.version ?? "?" })
: release
? t("releaseTitle", { version: release })
: t("title")}
</h2>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{/* 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
{(release || installed) && !status.shallow_clone && status.local_version
? t("releaseSubtitle", { current: status.local_version })
: t("commitsBehind", { count: behind, ref: refLabel })}
</p>
@@ -0,0 +1,75 @@
/**
* @file MarkdownPreview.test.tsx
* @description The markdown preview: GFM renders (headings, tables, task
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
* code block; and the config FileViewer offers Raw / Preview only for a
* markdown file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { FileViewer } from "../config/FileViewer";
describe("MarkdownPreview", () => {
it("renders GFM: headings, tables and task lists", () => {
render(
<MarkdownPreview
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
/>
);
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("never runs raw HTML and opens links in a new tab", () => {
const { container } = render(
<MarkdownPreview
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
/>
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("img")).toBeNull();
const link = screen.getByRole("link", { name: "x" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
});
it("shows frontmatter as a block, not as a heading", () => {
const { container } = render(
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
);
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByText("Body text")).toBeInTheDocument();
});
it("knows a markdown path", () => {
expect(isMarkdown("/a/SKILL.md")).toBe(true);
expect(isMarkdown("README.markdown")).toBe(true);
expect(isMarkdown("config.toml")).toBe(false);
expect(isMarkdown(null)).toBe(false);
});
});
describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
});
it("offers no preview for another file type", () => {
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
});
});
@@ -0,0 +1,29 @@
/**
* @file SessionToolBadge.test.tsx
* @description The session tool chip: nothing for a Claude Code session (so
* every hook row renders as before), the tool's name for a Codex one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SessionToolBadge } from "../SessionToolBadge";
vi.mock("../../lib/api", () => ({ api: { aiTools: { list: () => new Promise(() => {}) } } }));
describe("SessionToolBadge", () => {
it("renders nothing for a Claude or unset tool", () => {
const { container } = render(
<>
<SessionToolBadge tool="claude" />
<SessionToolBadge tool={undefined} />
</>
);
expect(container).toBeEmptyDOMElement();
});
it("names a Codex session's tool", () => {
render(<SessionToolBadge tool="codex" />);
expect(screen.getByText("Codex")).toHaveAttribute("title", "Recorded by Codex");
});
});
@@ -5,10 +5,11 @@
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
import { eventBus } from "../../lib/eventBus";
function renderSidebar(wsConnected: boolean, collapsed = false) {
return render(
@@ -103,4 +104,44 @@ describe("Sidebar", () => {
expect(screen.getByText("VI")).toBeInTheDocument();
});
});
describe("update status line", () => {
const plugin = {
git_repo: false,
install_kind: "plugin",
situation: "plugin_managed",
local_version: "1.8.10",
};
const publish = (data: object) =>
act(() => eventBus.publish({ type: "update_status", data } as never));
it("does not claim up to date when a plugin install could not read the release", () => {
renderSidebar(true);
publish({ ...plugin, update_available: false, release_check: "unreachable" });
expect(screen.queryByText("Up to date")).toBeNull();
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
});
it("names a build installed beside the running one", () => {
renderSidebar(true);
publish({
...plugin,
update_available: true,
release_check: "unreachable",
installed_not_running: { root: "/x", version: "1.8.12" },
});
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
});
it("still says up to date when the release was read and matches", () => {
renderSidebar(true);
publish({
...plugin,
update_available: false,
release_check: "ok",
latest_release: "v1.8.10",
});
expect(screen.getByText("Up to date")).toBeInTheDocument();
});
});
});
@@ -137,6 +137,33 @@ describe("UpdateNotifier — Update now", () => {
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
});
it("offers Update now for a build installed from a terminal, even with no release read", async () => {
// `claude plugin update` started nothing and the forge was unreachable:
// no release tag and no sha to key the modal on, yet a newer build sits
// on disk. That used to read as "up to date" with no way to switch.
statusMock.mockResolvedValue({
...BASE_STATUS,
git_repo: false,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
remote_sha: null,
commits_behind: 0,
local_version: "1.8.10",
release_check: "unreachable",
latest_release: null,
installed_not_running: { root: "/cache/sg/agenthub/new", version: "1.8.12" },
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:installedTitle", { version: "1.8.12" }));
expect(
screen.getByText(i18n.t("updates:releaseSubtitle", { current: "1.8.10" }))
).toBeInTheDocument();
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
});
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
+25 -3
View File
@@ -3,15 +3,17 @@
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
* (`MarkdownPreview`); Raw is the default.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
export function FileViewer({
state,
@@ -21,6 +23,8 @@ export function FileViewer({
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -42,6 +46,17 @@ export function FileViewer({
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
{canPreview && (
<button
type="button"
aria-pressed={preview}
onClick={() => setPreview((v) => !v)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{preview ? t("common.raw") : t("common.preview")}
</button>
)}
<CopyButton value={state.path} />
<button
onClick={onClose}
@@ -59,6 +74,13 @@ export function FileViewer({
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : preview && canPreview ? (
<>
<MarkdownPreview text={state.data.text} />
{state.data.truncated && (
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
)}
</>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
+93 -25
View File
@@ -19,13 +19,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -109,13 +111,30 @@ export function AddLaneModal({
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
.filter((x) => x.installed && x.enabled)
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false);
};
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed";
mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null);
@@ -131,7 +150,7 @@ export function AddLaneModal({
setBase("");
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
setPickedTool(null);
setBranchesError(null);
setError(null);
setBusy(false);
@@ -294,7 +313,7 @@ export function AddLaneModal({
// POST /worktree returns as soon as the DB row exists (202) — the
// actual `git worktree add` runs afterward, in the background, on the
// server. Firing setup against the lane's cwd before that finishes
// means agents/mcp write into a directory that doesn't exist yet, so
// means mcp writes into a directory that doesn't exist yet, so
// wait for provisioning to leave the "provisioning" status first.
const provisionedLane = await waitForProvisioned(result.lane.id);
if (!provisionedLane || provisionedLane.status === "failed") {
@@ -303,9 +322,8 @@ export function AddLaneModal({
return;
}
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
const [profileOutcome, 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),
]);
@@ -316,7 +334,6 @@ export function AddLaneModal({
? "scaffolded"
: "skipped"
: "failed",
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
@@ -328,7 +345,6 @@ export function AddLaneModal({
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.)
@@ -418,18 +434,62 @@ export function AddLaneModal({
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === "claude"}
label={t("addLaneToolClaude")}
onClick={() => setTool("claude")}
/>
<Seg
active={tool === "codex"}
label={t("addLaneToolCodex")}
onClick={() => setTool("codex")}
/>
</div>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings#ai-tools" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div>
@@ -642,7 +702,10 @@ export function AddLaneModal({
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{
value: "native",
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
},
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
@@ -650,10 +713,16 @@ export function AddLaneModal({
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;
const disabled =
option.value === "router" && (!routersLoaded || !toolRouterCapable);
return (
<label
key={option.value}
title={
option.value === "router" && !toolRouterCapable
? t("router.nativeOnly", { tool: aiTools.label(tool) })
: undefined
}
// 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.
@@ -728,7 +797,6 @@ export function AddLaneModal({
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
{
label: t("addLaneSetupRouter"),
+25 -41
View File
@@ -30,6 +30,7 @@ import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
* git refresh because a stack dying is exactly what the user needs to see, and slower than
@@ -120,6 +121,13 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
*/
export function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const toolLabel = aiTools.label(lane.tool || "claude");
// Only Claude and Codex have an endpoint/model override to write; every
// other tool is offered native alone (the server refuses the rest anyway).
const routerCapable =
aiTools.byId(lane.tool || "claude")?.routerCapable ??
(lane.tool === "claude" || lane.tool === "codex" || !lane.tool);
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
@@ -225,8 +233,15 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
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
key={r.name}
value={r.name}
disabled={!r.builtin && !routerCapable}
title={
!r.builtin && !routerCapable ? t("router.nativeOnly", { tool: toolLabel }) : undefined
}
>
{r.builtin ? t("router.native", { tool: toolLabel }) : r.label}
</option>
))}
</select>
@@ -271,7 +286,7 @@ export function since(sec: number | null): string {
}
/**
* Repo tooling (agents install / MCP sync / dev-sync check, and the
* Repo tooling (MCP sync / dev-sync check, and the
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
* collapsed into a single dropdown. Lives outside LaneCard so it can be
* placed in the Workspace page header, next to "Add lane" — it polls its
@@ -281,16 +296,11 @@ export function since(sec: number | null): string {
export function LaneToolsMenu({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
const [laneActionBusy, setLaneActionBusy] = useState<
"agents" | "mcp" | "sync-check" | "docs" | null
>(null);
const [laneActionBusy, setLaneActionBusy] = useState<"mcp" | "sync-check" | "docs" | null>(null);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const runtime = useLaneRuntime(lane.id, 0);
const runLaneAction = async (
which: "agents" | "mcp" | "sync-check" | "docs",
fn: () => Promise<string>
) => {
const runLaneAction = async (which: "mcp" | "sync-check" | "docs", fn: () => Promise<string>) => {
setLaneActionBusy(which);
setLaneActionResult(null);
try {
@@ -302,12 +312,6 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
}
};
const handleAgentsInstall = () =>
runLaneAction("agents", async () => {
const result = await api.lanes.agentsInstall(lane.id);
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
});
// A skill only exists inside a CLI session, so there is nothing server-side
// to call: this opens the lane's own terminal run with the slash command as
// its first message. Claude-only — codex has no slash commands.
@@ -363,20 +367,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
role="menu"
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-agents-install"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleAgentsInstall();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.agentsInstall")}
</button>
{lane.tool !== "codex" && (
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
@@ -484,18 +475,11 @@ export default function LaneCard({
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{(lane.ci_status || lane.tool === "codex") && (
{lane.ci_status && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
{lane.ci_status && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
{lane.tool === "codex" && (
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
</div>
)}
@@ -5,7 +5,8 @@
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
@@ -38,7 +39,9 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Code2,
Columns2,
Eye,
FileText,
FoldVertical,
Folder,
@@ -51,15 +54,15 @@ import {
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneDiffHunk,
LaneTreeEntry,
} from "../../lib/types";
@@ -436,6 +439,9 @@ export function LaneChangesPanel({
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
@@ -454,10 +460,33 @@ export function LaneChangesPanel({
// The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false);
// Where the editor opens: the line clicked in the read view, and that view's
// scroll offset, so the text does not jump when the textarea replaces it.
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null);
const editing = draft !== null;
useEffect(() => {
const at = editAt.current;
const el = editorRef.current;
if (!editing || !at || !el) return;
editAt.current = null;
const offset = el.value
.split("\n")
.slice(0, at.line)
.reduce((n, l) => n + l.length + 1, 0);
el.setSelectionRange(offset, offset);
el.focus({ preventScroll: true });
el.scrollTop = at.scrollTop;
}, [editing]);
const startEdit = (line: number, scrollTop: number) => {
if (!text?.sha) return;
editAt.current = { line, scrollTop };
setDraft(text.lines.join("\n"));
};
useEffect(() => {
if (!editWanted.current || !text) return;
editWanted.current = false;
if (text.sha) setDraft(text.lines.join("\n"));
if (text.sha) startEdit(0, 0);
// Asked for from the diff, refused once the file arrived: say why here,
// not with an Edit button that silently does nothing.
else setError(t(noEditReason(text)));
@@ -786,16 +815,22 @@ export function LaneChangesPanel({
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
// One change block, named by its hunk's `@@` line and its index inside that
// hunk, matched against a fresh diff at the context this diff was read
// with — so the server never applies a stale patch.
const actHunk = async (
action: "stage-hunk" | "discard-hunk",
path: string,
change: LaneDiffChange
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
path,
hunk,
hunk: change.hunk.header,
block: change.block,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
@@ -810,13 +845,13 @@ export function LaneChangesPanel({
}
};
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
// Stage / Undo on each change block of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
? (hunk: LaneDiffHunk) => {
const key = `${f.path}\n${hunk.header}`;
? (change: LaneDiffChange) => {
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
const armed = armedHunk === key;
return (
<>
@@ -826,7 +861,7 @@ export function LaneChangesPanel({
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
}
className={`${HUNK_CHIP} ${
armed
@@ -841,7 +876,7 @@ export function LaneChangesPanel({
aria-label={t("changes.stageHunk")}
title={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
onClick={() => void actHunk("stage-hunk", f.path, change)}
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
>
{t("changes.stageHunkShort")}
@@ -887,12 +922,6 @@ export function LaneChangesPanel({
return next;
});
// Full width edits in place: an editable whole file IS the editor, and
// Cancel just puts the saved text back (this effect re-seeds the draft).
const editableText = full && mode === "file" && whole && range !== "commit" && text?.sha;
useEffect(() => {
if (editableText && draft === null && text) setDraft(text.lines.join("\n"));
}, [editableText, draft, text]);
const dirty = draft !== null && draft !== text?.lines.join("\n");
const files = data?.available ? data.files : [];
@@ -912,8 +941,8 @@ export function LaneChangesPanel({
}
}, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file. Always-on editing (full
// width) makes a stray click here the easy way to lose typed work.
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
const openWhole = (p: string) => {
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
show(p);
@@ -1407,7 +1436,22 @@ export function LaneChangesPanel({
binary={current.binary}
/>
)}
{whole && text && draft === null && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
an Edit button. */}
{range !== "commit" &&
(draft === null && whole && text && !text.sha ? (
<span
@@ -1416,16 +1460,22 @@ export function LaneChangesPanel({
>
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
)
) : draft === null ? (
<button
type="button"
data-testid="lane-file-edit"
disabled={busy}
onClick={() => {
// From the diff the file is not loaded yet: the
// editor opens as soon as the whole-file read lands.
if (whole && text?.sha) setDraft(text.lines.join("\n"));
else editWanted.current = true;
// The file is not loaded yet: the editor opens as
// soon as the whole-file read lands.
editWanted.current = true;
setWhole(true);
}}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
@@ -1446,7 +1496,7 @@ export function LaneChangesPanel({
</button>
<button
type="button"
disabled={busy || (full && !dirty)}
disabled={busy}
onClick={() => setDraft(null)}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
@@ -1457,6 +1507,7 @@ export function LaneChangesPanel({
</div>
{whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"
aria-label={t("changes.editorLabel", { path: openFile })}
value={draft}
@@ -1472,12 +1523,17 @@ export function LaneChangesPanel({
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : whole ? (
text ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
truncated={text.truncated}
fill
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
+135 -51
View File
@@ -273,10 +273,13 @@ function Gutter({ n }: { n: number | null }) {
function Row({
tint,
style,
anchor,
children,
}: {
tint: string;
style?: CSSProperties;
/** This row starts change block `anchor` (file-wide): the bar's arrows land here. */
anchor?: number;
children: React.ReactNode;
}) {
return (
@@ -284,6 +287,7 @@ function Row({
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
data-change-index={anchor}
>
{children}
</div>
@@ -305,40 +309,82 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
* last changed line. A context rather than a prop because lines render from
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
/** One change block: a run of consecutive +/- lines inside a hunk. git merges
* edits a few lines apart into one `@@` hunk, so the review bar acts on these
* instead (#50) — `block` is the index the server's `stage-hunk` /
* `discard-hunk` take beside the hunk's header. */
export type LaneDiffChange = {
hunk: LaneDiffHunk;
block: number;
/** First/last index in `hunk.lines` (unified). */
firstLine: number;
lastLine: number;
/** First/last split-view row. */
firstRow: number;
lastRow: number;
};
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
/** A hunk's change blocks, cached per hunk object like `analyseHunk`. */
export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
const hit = blocksOf.get(hunk);
if (hit) return hit;
const out: LaneDiffChange[] = [];
const owner = new Map<LaneDiffLine, LaneDiffChange>();
hunk.lines.forEach((line, i) => {
if (line.type === "ctx") return;
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
if (!c) {
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
out.push(c);
}
c.lastLine = i;
owner.set(line, c);
});
analyseHunk(hunk).rows.forEach((r, i) => {
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
if (!c) return;
if (c.firstRow < 0) c.firstRow = i;
c.lastRow = i;
});
blocksOf.set(hunk, out);
return out;
}
/** The block whose last line (unified) / last row (split) is `i`, if any. */
const barAtLine = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.lastLine === i);
const barAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastRow === i);
const startAtLine = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.firstLine === i);
const startAtRow = (hunk: LaneDiffHunk, i: number) =>
changeBlocks(hunk).find((c) => c.firstRow === i);
/** Per-block review controls (Undo / Keep), drawn in a bar under each change
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
actions: (change: LaneDiffChange) => ReactNode;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
function lastChangedLine(hunk: LaneDiffHunk) {
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
if (hunk.lines[i]!.type !== "ctx") return i;
}
return -1;
}
function lastChangedRow(hunk: LaneDiffHunk) {
const rows = analyseHunk(hunk).rows;
for (let i = rows.length - 1; i >= 0; i -= 1) {
const r = rows[i]!;
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
}
return -1;
/** File-wide index of `change`, for a row's `anchor`. */
function useAnchor(change?: LaneDiffChange) {
const ctx = useContext(HunkActions);
return change && ctx ? ctx.changes.indexOf(change) : undefined;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
/** The review bar: hangs just BELOW a change block's last line, pinned to the
* visible right edge, so it sits on the change it acts on — the editor-style
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
@@ -370,14 +416,13 @@ function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
{ctx.actions(change)}
</span>
</span>
);
}
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const ctx = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
@@ -387,7 +432,6 @@ function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
@@ -403,16 +447,20 @@ function UnifiedLine({
emph,
style,
bar,
start,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
/** This is the hunk's last changed line: hang the review bar under it. */
bar?: LaneDiffHunk;
/** This is a change block's last line: hang its review bar under it. */
bar?: LaneDiffChange;
/** This is a change block's first line: the bar's arrows scroll here. */
start?: LaneDiffChange;
}) {
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line.type)} style={style}>
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
@@ -421,14 +469,13 @@ function UnifiedLine({
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
{bar && <HunkBar hunk={bar} />}
{bar && <HunkBar change={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
@@ -438,7 +485,8 @@ function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
))}
</div>
@@ -473,16 +521,19 @@ function SplitLine({
side,
style,
bar,
start,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
bar?: LaneDiffChange;
start?: LaneDiffChange;
}) {
const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
@@ -493,7 +544,7 @@ function SplitLine({
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
{bar && <HunkBar change={bar} />}
</Row>
);
}
@@ -519,8 +570,9 @@ function SplitColumn({
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
// One bar per change block: on the right (new) column only.
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
))}
</div>
@@ -555,7 +607,8 @@ function SplitSlice({
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
)
)}
@@ -652,8 +705,8 @@ export function LaneDiffView({
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
/** Controls drawn in each change block's review bar. */
hunkActions?: (change: LaneDiffChange) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
@@ -672,12 +725,15 @@ export function LaneDiffView({
[layout, hunks]
);
// The bar's arrows: bring hunk `index`'s header into view. A windowed
// unified diff may not have it in the DOM, so there it is found by offset.
const changes = useMemo(() => (hunks ? hunks.flatMap(changeBlocks) : []), [hunks]);
// The bar's arrows: bring change block `index`'s first line into view. A
// windowed unified diff may not have it in the DOM, so there it is found by
// offset.
const go = (index: number) => {
const el = root.current;
if (!el || !hunks) return;
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
const target = changes[index];
if (!el || !target) return;
const header = el.querySelector<HTMLElement>(`[data-change-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
@@ -688,10 +744,10 @@ export function LaneDiffView({
return;
}
// Windowed: the header is not in the DOM, but its offset is known.
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -727,7 +783,8 @@ export function LaneDiffView({
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
bar={barAtLine(hunk, i)}
start={startAtLine(hunk, i)}
/>
)
)}
@@ -751,12 +808,16 @@ export function LaneFileView({
lines,
truncated,
fill = false,
onEditAt,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
/** Set when the file is editable: a click on the text (0-based line, and
* where the view was scrolled) asks to open the editor there. */
onEditAt?: (line: number, scrollTop: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
@@ -765,9 +826,12 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
{/* `contents`: a line marker for the click handler, not a layout box. */}
<span data-ln={i} className="contents">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</span>
</Row>
);
@@ -775,7 +839,27 @@ export function LaneFileView({
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
tabIndex={onEditAt ? 0 : undefined}
onClick={
onEditAt &&
((e) => {
// A click that ends a drag-selection is copying, not asking to edit.
if (window.getSelection()?.toString()) return;
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
})
}
onKeyDown={
onEditAt &&
((e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onEditAt(0, e.currentTarget.scrollTop);
})
}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
onEditAt ? " cursor-text" : ""
}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
+12 -7
View File
@@ -18,6 +18,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
@@ -41,7 +42,7 @@ const LIVENESS_DOT: Record<Lane["liveness"], string> = {
* recordDetection refuses any detection that isn't strictly ahead.
*/
function detectionLeads(lane: Lane): boolean {
if (!lane.detected_stage) return false;
if (!lane.detected_stage || !lane.pipeline_nodes.length) return false;
const nodes = lane.pipeline_nodes;
return (
nodes.findIndex((n) => n.id === lane.detected_stage) >=
@@ -64,6 +65,7 @@ export default function LaneStripCard({
onReorder: (from: number, to: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const { label: toolLabel } = useAiTools();
const [dropTarget, setDropTarget] = useState(false);
return (
@@ -115,15 +117,18 @@ export default function LaneStripCard({
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
{lane.tool === "codex" && (
{/* Only claude lanes carry a pipeline, and only while a template is
installed; the others show their CLI's tag in place of the stage chip. */}
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
{toolLabel(lane.tool)}
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
)}
{detectionLeads(lane) && (
{lane.tool === "claude" && detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
@@ -10,11 +10,12 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
@@ -27,15 +28,48 @@ vi.mock("../../../lib/api", () => ({
browse: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
}));
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: null,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
install: null,
update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over,
});
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
],
};
function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
@@ -108,6 +142,8 @@ async function fillWorktreeForm(
}
beforeEach(() => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
@@ -120,7 +156,6 @@ beforeEach(() => {
vi.mocked(api.lanes.profileInit)
.mockReset()
.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",
@@ -140,7 +175,7 @@ beforeEach(() => {
function nativeRouter() {
return {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -383,7 +418,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
it("fires profileInit and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
@@ -401,7 +436,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
@@ -416,7 +450,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
const onAdded = vi.fn();
const onClose = vi.fn();
@@ -482,7 +515,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
expect(api.lanes.profileInit).not.toHaveBeenCalled();
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
});
});
@@ -654,7 +686,7 @@ describe("AddLaneModal — AI Router", () => {
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
@@ -721,7 +753,7 @@ describe("AddLaneModal — a router profile that disappeared", () => {
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());
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
@@ -756,7 +788,20 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /codex/i }));
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -764,6 +809,25 @@ describe("AddLaneModal — tool selection", () => {
);
});
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({});
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
"href",
"/settings#ai-tools"
);
});
it("defaults to claude code", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
routerApply: vi.fn(),
@@ -96,13 +95,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
}
describe("LaneCard lane tool badge", () => {
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
// The strip tile already names the CLI; a card holding only that tag was an
// empty frame in the lane detail.
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
for (const tool of ["codex", "antigravity"] as const) {
const { container, unmount } = render(
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
);
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
unmount();
}
});
});
@@ -306,17 +308,6 @@ describe("LaneToolsMenu", () => {
} as unknown as LaneRuntime);
});
it("clicking Install agents calls the API and shows the result", async () => {
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
installed: ["qc-local.md", "senior-gate-reviewer.md"],
});
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-agents-install"));
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
});
it("clicking Sync MCP calls the API and shows the result", async () => {
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
servers: ["playwright"],
@@ -387,7 +378,7 @@ describe("LaneToolsMenu", () => {
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -415,6 +406,16 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers only native to a tool that cannot use an AI Router", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
const select = await screen.findByDisplayValue("Native (gemini)");
const router = Array.from((select as HTMLSelectElement).options).find(
(o) => o.value === "9router"
);
expect(router?.disabled).toBe(true);
});
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({
@@ -426,9 +427,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
@@ -449,9 +448,7 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
@@ -473,9 +470,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
@@ -496,14 +491,14 @@ describe("LaneRouterRow — the Model picker", () => {
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude / Codex)");
await screen.findByDisplayValue("Native (Claude)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
@@ -519,9 +514,7 @@ describe("LaneRouterRow — the Model picker", () => {
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
@@ -530,7 +523,7 @@ describe("LaneRouterRow — the Model picker", () => {
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
@@ -541,10 +534,8 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
});
@@ -321,7 +321,35 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "README.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Hello", "", "- one"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -346,19 +374,24 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
// Opening a file only shows it: no editor until the text itself is clicked.
const view = await screen.findByTestId("lane-file-view");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
await user.click(view);
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(editor).toHaveFocus();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel reverts to the saved text and stays in the editor.
// Cancel drops the edit and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("at full width opens a changed file from the overview straight into the editor", async () => {
it("at full width opens a changed file from the overview for reading, not editing", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
@@ -372,10 +405,31 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("does not enter the editor when a click only ends a text selection", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["copy me"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
} as unknown as Selection);
await user.click(view);
spy.mockRestore();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -615,9 +669,7 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
await user.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
@@ -676,8 +728,7 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-edit"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
@@ -922,6 +973,59 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
block: 0,
context: 3,
});
});
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 2,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,4 +1,5 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "new" },
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
],
},
],
},
],
})
);
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,4 +1,5 @@",
block: 1,
context: 3,
});
});
@@ -959,6 +1063,7 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,2 +1,3 @@",
block: 0,
context: 3,
confirm: true,
});
@@ -422,8 +422,8 @@ describe("LaneDiffView — hunk review bar navigation", () => {
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
const k = Number(el.dataset.hunkIndex);
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
const k = Number(el.dataset.changeIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
@@ -454,6 +454,49 @@ describe("LaneDiffView — hunk review bar navigation", () => {
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
const merged: LaneChangeFile = {
...FILE,
hunks: [
{
header: "@@ -1,6 +1,6 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "c" },
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
],
},
],
};
const seen: number[] = [];
for (const layout of ["unified", "split"] as const) {
const { unmount } = render(
<LaneDiffView
file={merged}
layout={layout}
hunkActions={(c) => {
seen.push(c.block);
return <span />;
}}
/>
);
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
unmount();
}
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
+23 -6
View File
@@ -43,7 +43,8 @@ import type {
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session, Lane, LaneTool } from "../../lib/types";
import type { Session, Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
@@ -68,7 +69,7 @@ export interface LaneConsolePaneProps {
showLaneSelector: boolean;
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
@@ -149,7 +150,14 @@ export function LaneConsolePane({
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool);
// A new run starts on the tool's default model (AI Tools settings), and a
// lane switch to another tool drops the previous tool's pick.
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
useEffect(() => {
setModel(defaultModel);
}, [laneTool, defaultModel]);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -312,6 +320,7 @@ export function LaneConsolePane({
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
bypass: args.bypass,
});
} catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane
@@ -565,10 +574,16 @@ export function LaneConsolePane({
/>
</header>
{binaryStatus && !binaryStatus[laneTool]?.found && (
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing", { cli: laneTool, tool: toolLabel })}</span>
<span>
{t("binary.missing", {
cli: aiTools.byId(laneTool)?.command ?? laneTool,
tool: toolLabel,
})}
</span>
</div>
)}
@@ -607,7 +622,9 @@ export function LaneConsolePane({
onSandboxChange={setSandbox}
approval={approval}
onApprovalChange={setApproval}
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
onResumeLast={(bypass) =>
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
+135 -54
View File
@@ -43,7 +43,7 @@ import {
RotateCcw,
Lock,
} from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api";
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
@@ -56,6 +56,7 @@ import type {
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select";
export interface SlashCommand {
@@ -388,9 +389,10 @@ interface RunSetupProps {
onSandboxChange: (s: CodexSandbox) => void;
approval: CodexApproval;
onApprovalChange: (a: CodexApproval) => void;
/** Codex lanes resume with `--last`: the sessions table holds Claude
/** Every non-Claude lane resumes its tool's LAST session (`codex resume
* --last`, a catalog tool's `resume` args): the sessions table holds Claude
* sessions only, so there is nothing for the picker to list. */
onResumeLast: () => void;
onResumeLast: (bypass?: boolean) => void;
}
export function RunSetup(props: RunSetupProps) {
@@ -398,6 +400,17 @@ export function RunSetup(props: RunSetupProps) {
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
const aiTools = useAiTools();
const entry = aiTools.byId(props.tool);
// Claude and Codex keep their hand-written fields; every other catalog tool
// gets the plain form — cwd, prompt, resume and bypass when it has them.
const catalogTool = props.tool !== "claude" && props.tool !== "codex";
const canResume = !catalogTool || !!entry?.resume;
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
@@ -430,10 +443,13 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
{hasModel && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{catalogTool ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
@@ -472,35 +488,39 @@ export function RunSetup(props: RunSetupProps) {
/>
</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,
}))}
{!catalogTool && (
<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>
{canResume && (
<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")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
</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")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
)}
</div>
{resumePicked && (
@@ -514,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s);
if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
@@ -522,7 +543,7 @@ export function RunSetup(props: RunSetupProps) {
/>
) : (
<button
onClick={() => props.onResumeLast()}
onClick={() => props.onResumeLast(canBypass && bypass ? true : undefined)}
disabled={props.busy}
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -541,10 +562,12 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
slash={props.tool !== "codex"}
slash={props.tool === "claude"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -552,7 +575,27 @@ export function RunSetup(props: RunSetupProps) {
</div>
</div>
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
{canBypass && (
<div className="px-4 pb-3">
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
<input
type="checkbox"
checked={bypass}
onChange={(e) => setBypass(e.target.checked)}
className="accent-[rgb(var(--status-danger))]"
/>
{t("fields.bypass")}
</label>
{bypass && (
<div className="mt-2 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" />
<span>{t("hint.bypassWarning", { tool: aiTools.label(props.tool) })}</span>
</div>
)}
</div>
)}
{props.tool === "codex" && !bypass && 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" />
<span>{t("hint.sandboxWarning")}</span>
@@ -577,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
) : null}
</div>
<button
onClick={() => handleStart(props)}
onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
@@ -606,25 +651,38 @@ export function RunSetup(props: RunSetupProps) {
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session) {
function handleStart(
props: RunSetupProps,
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null
) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
// Any catalog tool: none of Claude's or Codex's own flags apply.
const other = props.tool !== "claude" && !codex;
props.onStart({
laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: props.model || undefined,
permissionMode: codex ? undefined : props.permissionMode || undefined,
// A resume keeps the session's own model: the tool's default the form
// starts on is not a pick, so only a model the user changed is sent.
model:
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
? undefined
: props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
effort: other ? undefined : props.effort || undefined,
resumeSessionId: codex || other ? undefined : resume?.id || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
bypass: bypass || undefined,
});
}
@@ -853,7 +911,10 @@ function SessionPicker({
if (sessions !== null && fetchedForCwd.current === cwd) return;
fetchedForCwd.current = cwd;
api.sessions
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
// `claude --resume` can only resume a Claude session; a Codex lane at the
// same directory records its own sessions there too. Filtered by the
// server, before its limit.
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
.then((r) => setSessions(r.sessions))
.catch(() => setSessions([]));
}, [open, cwd, sessions]);
@@ -986,7 +1047,7 @@ function SessionPicker({
// the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__";
function ModelPicker({
export function ModelPicker({
value,
onChange,
tool,
@@ -996,11 +1057,31 @@ function ModelPicker({
tool: LaneTool;
}) {
const { t } = useTranslation("run");
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES;
// "Custom" is selected when the value isn't one of our curated IDs.
// The tool's own list (Codex's models cache, Claude's aliases named after
// the newest version, a catalog tool's vendor rows); Claude's static
// aliases only when the server cannot answer.
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
useEffect(() => {
let live = true;
setFetched(null);
Promise.resolve()
.then(() => api.aiTools.models(tool))
.then((r) => live && setFetched(r.models))
.catch(() => live && setFetched([]));
return () => {
live = false;
};
}, [tool]);
const choices = useMemo<ModelChoice[]>(() => {
const inherit = { id: "", label: "" };
if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fetched, tool]);
// "Custom" is selected when the value isn't one of the listed IDs — judged
// only once the list has arrived, so a listed default never flashes as custom.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom = value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(isCustom);
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
@@ -10,6 +10,7 @@ import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -28,6 +29,10 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(),
list: vi.fn(),
},
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
@@ -36,7 +41,6 @@ vi.mock("../../../lib/api", () => ({
},
},
RUN_MODEL_CHOICES: [],
RUN_MODEL_CHOICES_CODEX: [],
RUN_EFFORT_CHOICES: [],
}));
@@ -84,6 +88,7 @@ function baseProps() {
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
cwdSuggestions: [],
activeRuns: { items: [] },
@@ -135,6 +140,31 @@ describe("LaneConsolePane", () => {
);
});
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
@@ -25,6 +25,42 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiTools: {
list: r({
default: "claude",
tools: [
{
id: "claude",
label: "Claude Code",
resume: false,
bypass: false,
modelFlag: true,
defaultModel: "sonnet",
},
{ id: "codex", label: "Codex", resume: false, bypass: true },
{
id: "antigravity",
label: "Antigravity",
resume: true,
bypass: true,
modelFlag: true,
},
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}),
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
models: vi.fn(async (id: string) => {
const lists: Record<string, { id: string; label: string }[]> = {
claude: [
{ id: "sonnet", label: "Sonnet (latest · 5.5)" },
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
],
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
};
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: {
@@ -134,11 +170,12 @@ describe("RunSetup — selections report through callbacks", () => {
onlyCalled(spies, "onCwdChange");
});
it("reports the model from the picker, including a custom id", () => {
it("reports the model from the picker, including a custom id", async () => {
const { spies } = renderSetup();
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
// what keeps the list from going stale on every model release.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
// The list is the tool's own, fetched from the server — aliases named
// after the newest version, then pinned versions — never a hardcoded copy.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
@@ -225,6 +262,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
);
});
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
renderSetup({ laneCwd: "/Users/tester/lane-a" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await screen.findByText("/Users/tester/lane-a");
// Filtered by the server, before its limit: a busy Codex lane at the same
// directory cannot push Claude sessions off the page.
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
});
it("starts the resume immediately when a session is picked", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
@@ -342,6 +400,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
});
});
describe("RunSetup — resume keeps the session's model", () => {
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
it("sends no model when it is still the tool's default", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
await screen.findByText(i18n.t("run:fields.model"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
);
});
it("sends a model the user picked", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
});
});
describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" });
@@ -380,7 +459,7 @@ describe("RunSetup — per-tool fields", () => {
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
const box = () => screen.getByPlaceholderText(/ anything…$/);
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
@@ -423,3 +502,79 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({
model: "gemini-3.1-pro",
effort: undefined,
permissionMode: undefined,
})
);
});
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
await screen.findByPlaceholderText("Ask Gemini anything…");
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
});
});
describe("RunSetup — codex lanes", () => {
it("offers the models Codex itself lists", async () => {
renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledOnce();
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined();
});
});
describe("RunSetup — catalog tools", () => {
it("offers a per-run bypass switch with a warning, and sends it", async () => {
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" });
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") });
expect(box).not.toBeChecked();
expect(screen.queryByText(/without asking/)).toBeNull();
fireEvent.click(box);
expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true);
// Resume is offered because the catalog gives the tool a resume flag.
expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy();
});
it("leaves out resume and bypass for a tool with neither", async () => {
const { spies } = renderSetup({ tool: "gemini", prompt: "go" });
expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy();
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined();
});
it("gives Codex the switch but not Claude", async () => {
renderSetup({ tool: "codex" });
expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy();
});
it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => {
renderSetup({ tool: "claude" });
await screen.findByPlaceholderText("Ask Claude Code anything…");
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
});
});
@@ -0,0 +1,67 @@
/**
* @file AiToolsModal.tsx
* @description The AI Tools dialog opened from the Settings tab bar's right
* edge: a header with the title, description and a close button around
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
* nested ConfirmModal already handled (defaultPrevented) is left to it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Bot, X } from "lucide-react";
import { AiToolsSettings } from "./AiToolsSettings";
/**
* @param props.onClose - Called on Escape, a backdrop click or the X.
* @returns The modal.
*/
export function AiToolsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("settings");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-tools-title"
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2
id="ai-tools-title"
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<Bot className="h-4 w-4 text-fg-muted" />
{t("aiTools.title")}
</h2>
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("common:close")}
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<AiToolsSettings />
</div>
</div>
);
}
@@ -0,0 +1,392 @@
/**
* @file AiToolsSettings.tsx
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
const GHOST_BTN =
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50";
function Homepage({ tool }: { tool: AiTool }) {
const { t } = useTranslation("settings");
if (!tool.homepageUrl) return null;
return (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
title={t("aiTools.homepage")}
aria-label={t("aiTools.homepageOf", { tool: tool.label })}
className="text-fg-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
);
}
function Switch({
tool,
disabled,
onToggle,
}: {
tool: AiTool;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation("settings");
return (
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
disabled={disabled}
onClick={onToggle}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
tool.enabled ? "bg-accent" : "bg-surface-4"
}`}
>
<span
aria-hidden
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
tool.enabled ? "left-[18px]" : "left-0.5"
}`}
/>
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
</button>
);
}
function JobLog({ job }: { job: AiToolJob }) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLPreElement | null>(null);
// A finished job folds to its status line; running or failed stays open.
const [open, setOpen] = useState(job.status !== "ok");
useEffect(() => {
if (job.status === "ok") setOpen(false);
}, [job.status]);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [job.log, open]);
const state =
job.status === "running"
? t("aiTools.jobRunning")
: job.status === "ok"
? t("aiTools.jobOk")
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
return (
<div className="mt-2 w-full">
<div className="flex items-center gap-2 text-[11px]">
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
{state}
</span>
{job.status !== "running" && (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
</button>
)}
</div>
{open && (
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
)}
</div>
);
}
export function AiToolsSettings() {
const { t } = useTranslation("settings");
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [showAvailable, setShowAvailable] = useState(false);
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
// unreachable server (or a mocked api without it) means "no job".
useEffect(() => {
Promise.resolve()
.then(() => api.aiTools.job())
.then(setJob)
.catch(() => undefined);
}, []);
const running = job?.status === "running";
useEffect(() => {
if (!running) return;
const h = window.setInterval(async () => {
try {
const j = await api.aiTools.job();
setJob(j);
if (j?.status !== "running") void reload();
} catch {
/* next tick */
}
}, 1000);
return () => window.clearInterval(h);
}, [running, reload]);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
const inst = tool.install;
if (action === "update" && tool.update?.kind === "self") {
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
}
if (inst?.kind === "npm") return npmCmd(inst.package);
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
return "";
};
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
// Mirrors the server's update-all: every installed tool with an updater.
const updateAllTools = installed.filter((x) => x.update);
const startConfirmed = async () => {
const c = confirm;
setConfirm(null);
if (!c) return;
await run("job", async () => {
const r =
c.kind === "install"
? await api.aiTools.install(c.tool.id)
: c.kind === "update"
? await api.aiTools.updateTool(c.tool.id)
: await api.aiTools.updateAll();
setJob(r.job);
});
};
const jobBusy = running || busy !== null;
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
const confirmLines =
confirm?.kind === "update-all"
? updateAllTools.map((x) => commandFor(x, "update"))
: confirm
? [commandFor(confirm.tool, confirm.kind)]
: [];
return (
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
disabled={jobBusy}
className={GHOST_BTN}
>
<Download className="w-3.5 h-3.5" />
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
return (
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
<div className="flex items-center gap-3">
<div
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
>
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("aiTools.default")}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
</div>
</div>
</div>
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<Switch
tool={tool}
disabled={busy !== null}
onToggle={() =>
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
}
/>
</div>
{logFor(tool.id)}
</li>
);
})}
</ul>
</div>
{available.length > 0 && (
<div>
<button
type="button"
aria-expanded={showAvailable}
onClick={() => setShowAvailable((s) => !s)}
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
/>
{t("aiTools.available", { count: available.length })}
</button>
{showAvailable && (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{available.map((tool) => (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
}`}
>
<AiToolIcon tool={tool} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
<span className="block truncate font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "install", tool })}
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.install")}
</button>
)}
{logFor(tool.id)}
</li>
))}
</ul>
)}
</div>
)}
<ConfirmModal
open={confirm !== null}
destructive={false}
title={
confirm?.kind === "update-all"
? t("aiTools.confirmUpdateAll")
: confirm
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
tool: confirm.tool.label,
})
: ""
}
message={t("aiTools.confirmBody")}
confirmLabel={t("aiTools.run")}
cancelLabel={t("aiTools.cancel")}
onConfirm={() => void startConfirmed()}
onCancel={() => setConfirm(null)}
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
</ConfirmModal>
</div>
);
}
@@ -0,0 +1,189 @@
/**
* @file AntigravityHooksSettings.tsx
* @description Settings → Antigravity → Antigravity hooks: whether
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
* written until the user clicks; agy asks for no trust, and has no permission
* hook — the note says both (#40 part D).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
CheckCircle,
Plug,
RefreshCw,
RotateCcw,
Trash2,
XCircle,
} from "lucide-react";
import { api } from "../../lib/api";
import type { AntigravityHooksStatus } from "../../lib/types";
import { Skeleton } from "../Skeleton";
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
/**
* The Antigravity hooks card.
* @returns A section with id `antigravity-hooks` for deep links.
*/
export function AntigravityHooksSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AntigravityHooksStatus | null>(null);
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.settings.antigravityHooks
.get()
.then(setStatus)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
const run = async (kind: "install" | "remove" | "get") => {
setBusy(kind);
setError(null);
try {
setStatus(await api.settings.antigravityHooks[kind]());
} catch (err) {
setError(
t("antigravityHooks.failed", { message: err instanceof Error ? err.message : String(err) })
);
} finally {
setBusy(null);
}
};
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
const chip = !status?.installed ? (
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
<XCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.notInstalled")}
</span>
) : status.enabled ? (
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
<CheckCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.installed")}
</span>
) : (
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
<AlertTriangle className="w-3.5 h-3.5" /> {t("antigravityHooks.disabled")}
</span>
);
return (
<section id="antigravity-hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("antigravityHooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("antigravityHooks.description")}</p>
<div className="card p-5 space-y-4">
{!status && !error ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-5/6" />
</div>
) : null}
{status && !status.supported ? (
<p className="text-xs text-fg-muted">{t("antigravityHooks.unsupported")}</p>
) : null}
{status && status.supported ? (
<>
<div className="flex items-center justify-between flex-wrap gap-3">
{chip}
<div className="flex items-center gap-2">
<button
onClick={() => run("get")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
{t("antigravityHooks.refresh")}
</button>
<button
onClick={() => run("install")}
disabled={busy !== null || !status.readable}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "install" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{status.installed
? t("antigravityHooks.reinstall")
: t("antigravityHooks.install")}
</button>
{anyInstalled ? (
<button
onClick={() => run("remove")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "remove" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
{t("antigravityHooks.remove")}
</button>
) : null}
</div>
</div>
{!status.readable ? (
<p className="text-xs text-status-danger">
{t("antigravityHooks.unreadable", { path: status.path, error: status.error ?? "" })}
</p>
) : null}
<p className="text-xs text-fg-secondary">{t("antigravityHooks.note")}</p>
{anyInstalled ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(status.events).map(([event, e]) => {
const label = e.installed
? t("antigravityHooks.eventInstalled")
: t("antigravityHooks.eventMissing");
return (
<div
key={event}
title={label}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{e.installed ? (
<CheckCircle
aria-hidden="true"
className="w-3 h-3 text-status-success flex-shrink-0"
/>
) : (
<XCircle
aria-hidden="true"
className="w-3 h-3 text-status-danger flex-shrink-0"
/>
)}
<span className="text-fg-secondary truncate">{event}</span>
<span className="sr-only">{label}</span>
</div>
);
})}
</div>
) : null}
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
</>
) : null}
{error ? (
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
{error}
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,222 @@
/**
* @file ClaudeAgentHubSettings.tsx
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
import { api } from "../../lib/api";
import { Skeleton } from "../Skeleton";
import { HomeDirSetting } from "./HomeDirSetting";
import { PricingSettings } from "./PricingSettings";
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
/**
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
* the config it lists, which lives under that home).
* @returns The three sections, each with its own id.
*/
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
const { t } = useTranslation("settings");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hooks, setHooks] = useState<HooksInfo | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{
key: string;
message: string;
isError: boolean;
} | null>(null);
const load = useCallback(async () => {
try {
const infoRes = await api.settings.info();
setHooks(infoRes.hooks);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
load();
}, [load]);
// Hooks can be (re)installed from a terminal while this tab is open; keep the
// badge current the way the old Settings page did, every 10 s.
useEffect(() => {
const id = setInterval(() => {
api.settings
.info()
.then((r) => setHooks(r.hooks))
.catch(() => {});
}, 10000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!actionResult) return;
const timeout = setTimeout(() => setActionResult(null), 5000);
return () => clearTimeout(timeout);
}, [actionResult]);
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(null);
try {
const message = await fn();
setActionResult({ key, message, isError: false });
await load();
} catch (err) {
setActionResult({
key,
message: t("messages.actionFailed", {
message: err instanceof Error ? err.message : t("messages.unknownError"),
}),
isError: true,
});
} finally {
setActionLoading(null);
}
};
const handleReinstallHooks = () =>
runAction("hooks", async () => {
const res = await api.settings.reinstallHooks();
return res.ok ? t("hooks.success") : t("hooks.failed");
});
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
);
}
return (
<div className="space-y-8">
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{hooks?.installed ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
</span>
)}
</div>
<button
onClick={handleReinstallHooks}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "hooks" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("hooks.reinstall")}
</button>
</div>
{actionBanner(["hooks"])}
{hooks && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{Object.entries(hooks.hooks).map(([hook, active]) => (
<div
key={hook}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{active ? (
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
) : (
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
)}
<span className="text-fg-secondary truncate">{hook}</span>
</div>
))}
</div>
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
</>
)}
</div>
</section>
<PricingSettings vendor="claude" allowReset />
</div>
);
}
@@ -0,0 +1,199 @@
/**
* @file CodexHooksSettings.tsx
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
* Install/Reinstall and Remove. Nothing is written until the user clicks;
* Codex itself asks the user to trust the hooks before it runs them, and this
* card says so while they wait, with a Refresh to re-read once they have
* (#40 part C2).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
CheckCircle,
Plug,
RefreshCw,
RotateCcw,
Trash2,
XCircle,
} from "lucide-react";
import { api } from "../../lib/api";
import type { CodexHooksStatus } from "../../lib/types";
import { Skeleton } from "../Skeleton";
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
/**
* The Codex hooks card.
* @returns A section with id `codex-hooks` for deep links.
*/
export function CodexHooksSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.settings.codexHooks
.get()
.then(setStatus)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
const run = async (kind: "install" | "remove" | "get") => {
setBusy(kind);
setError(null);
try {
setStatus(await api.settings.codexHooks[kind]());
} catch (err) {
setError(
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
);
} finally {
setBusy(null);
}
};
// Some of ours present but not all (an event added since, or one taken out
// by hand): still show them, and still offer Remove.
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
!e.installed
? t("codexHooks.eventMissing")
: e.trusted
? t("codexHooks.eventTrusted")
: t("codexHooks.eventUntrusted");
const chip = !status?.installed ? (
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
</span>
) : status.trusted ? (
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
</span>
) : (
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
</span>
);
return (
<section id="codex-hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("codexHooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
<div className="card p-5 space-y-4">
{!status && !error ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-5/6" />
</div>
) : null}
{status && !status.supported ? (
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
) : null}
{status && status.supported ? (
<>
<div className="flex items-center justify-between flex-wrap gap-3">
{chip}
<div className="flex items-center gap-2">
<button
onClick={() => run("get")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
{t("codexHooks.refresh")}
</button>
<button
onClick={() => run("install")}
disabled={busy !== null || !status.readable}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "install" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
</button>
{anyInstalled ? (
<button
onClick={() => run("remove")}
disabled={busy !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy === "remove" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
{t("codexHooks.remove")}
</button>
) : null}
</div>
</div>
{!status.readable ? (
<p className="text-xs text-status-danger">
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
</p>
) : null}
{status.installed && !status.trusted ? (
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
) : null}
{anyInstalled ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{Object.entries(status.events).map(([event, e]) => (
<div
key={event}
title={eventLabel(e)}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{!e.installed ? (
<XCircle
aria-hidden="true"
className="w-3 h-3 text-status-danger flex-shrink-0"
/>
) : e.trusted ? (
<CheckCircle
aria-hidden="true"
className="w-3 h-3 text-status-success flex-shrink-0"
/>
) : (
<AlertTriangle
aria-hidden="true"
className="w-3 h-3 text-status-warning flex-shrink-0"
/>
)}
<span className="text-fg-secondary truncate">{event}</span>
<span className="sr-only">{eventLabel(e)}</span>
</div>
))}
</div>
) : null}
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
</>
) : null}
{error ? (
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
{error}
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,130 @@
/**
* @file HomeDirSetting.tsx
* @description One editable home directory (CLAUDE_HOME in Claude Code →
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen } from "lucide-react";
export interface HomeDirValue {
value: string;
envOwned: boolean;
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
warning?: string | null;
}
interface Props {
/** `<section id>` — a deep-link target. */
id: string;
title: string;
description: string;
/** Optional second line under the description. */
note?: string;
placeholder: string;
load: () => Promise<HomeDirValue>;
save: (path: string) => Promise<HomeDirValue>;
}
/**
* An editable home directory with its own load/save cycle.
* @returns The section, or its skeleton-free shell while loading.
*/
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState<HomeDirValue | null>(null);
const [input, setInput] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
load()
.then((v) => {
if (!live) return;
setCurrent(v);
setInput(v.value);
})
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// `load` is a stable api call per mount; re-running on identity would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
if (!current || input === current.value) return;
setSaving(true);
setError(null);
try {
const next = await save(input);
setCurrent(next);
setInput(next.value);
} catch (err) {
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
} finally {
setSaving(false);
}
};
return (
<section id={id} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</h3>
<p className="text-xs text-fg-muted mb-1">{description}</p>
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
<div className="flex items-center gap-3">
<input
type="text"
value={input}
aria-label={title}
onChange={(e) => {
setInput(e.target.value);
setError(null);
}}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
placeholder={placeholder}
/>
<button
onClick={handleSave}
disabled={saving || !current || input === current.value}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
{/* The saved value, only while the field says something else. */}
{current && input !== current.value && (
<p className="text-xs text-fg-muted">
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
</p>
)}
{current?.envOwned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("homeDir.envOwned")}
</p>
)}
{current?.warning && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{current.warning}
</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,778 @@
/**
* @file PricingSettings.tsx
* @description Model Pricing — the per-model rates every session's cost is
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
* automatic sync owns (OpenAI's, Gemini's) straight after.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { getCurrentLocale } from "../../lib/format";
import { Skeleton } from "../Skeleton";
import type { ModelPricing, WSMessage } from "../../lib/types";
interface EditRow {
model_pattern: string;
display_name: string;
input_per_mtok: string;
output_per_mtok: string;
cache_read_per_mtok: string;
cache_write_per_mtok: string;
cache_write_1h_per_mtok: string;
fast_input_per_mtok: string;
fast_output_per_mtok: string;
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
// values are ignored). Generic: any model can carry a promo window.
intro_until: string;
intro_input_per_mtok: string;
intro_output_per_mtok: string;
intro_cache_read_per_mtok: string;
intro_cache_write_per_mtok: string;
intro_cache_write_1h_per_mtok: string;
}
const emptyRow: EditRow = {
model_pattern: "",
display_name: "",
input_per_mtok: "0",
output_per_mtok: "0",
cache_read_per_mtok: "0",
cache_write_per_mtok: "0",
cache_write_1h_per_mtok: "0",
fast_input_per_mtok: "0",
fast_output_per_mtok: "0",
intro_until: "",
intro_input_per_mtok: "0",
intro_output_per_mtok: "0",
intro_cache_read_per_mtok: "0",
intro_cache_write_per_mtok: "0",
intro_cache_write_1h_per_mtok: "0",
};
function formatTimestamp(iso: string): string {
const normalized =
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
const d = new Date(normalized);
return d.toLocaleString(getCurrentLocale(), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Info popover for the Model Pricing section. Hover or focus the icon to see a
* three-section explanation: how prices are applied, how pattern matching
* works, and where the rates come from (LiteLLM's price list, kept up to date
* daily for the rows marked Auto). All copy is i18n-driven
* (settings.pricing.tooltip.*).
*
* The popover is fixed-positioned and clamped to the viewport so it never
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
* the Workflows stat tooltips.
*/
function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
const claude = vendor === "claude";
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const positionPopover = useCallback(() => {
const btn = buttonRef.current;
const pop = popoverRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const w = pop?.offsetWidth ?? 320;
const h = pop?.offsetHeight ?? 240;
const margin = 8;
let left = r.right - w; // right-align with the icon
if (left < margin) left = margin;
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
let top = r.bottom + 8;
if (top + h > window.innerHeight - margin) {
top = Math.max(margin, r.top - h - 8);
}
setPos({ left, top });
}, []);
useEffect(() => {
if (!open) return;
positionPopover();
const onScroll = () => positionPopover();
const onResize = () => positionPopover();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
const raf = requestAnimationFrame(positionPopover);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, positionPopover]);
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("pricing.tooltip.title")}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-3.5 h-3.5" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }}
>
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.howItWorks")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.howItWorksBody")}
</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.patternsTitle")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.patternsBody")}
</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.synced")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")}
</p>
<p className="text-fg-secondary leading-snug">
{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}
</p>
</div>
)}
</>
);
}
export type PricingVendor = "claude" | "openai" | "google";
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
claude: /^claude-/,
openai: /^(gpt-|o\d|codex-)/,
google: /^gemini-/,
};
interface Props {
vendor: PricingVendor;
/** Offer Reset Defaults (it reseeds Claude's rates and resets only `claude-…` rows). */
allowReset?: boolean;
/** `<section id>` — a deep-link target. */
id?: string;
}
/**
* The pricing table filtered to one vendor's rows.
* @returns The section, or a skeleton while the rules load.
*/
export function PricingSettings({ vendor, allowReset = false, id = "pricing" }: Props) {
const { t } = useTranslation("settings");
const [pricing, setPricing] = useState<ModelPricing[]>([]);
const [loading, setLoading] = useState(true);
const [editingPattern, setEditingPattern] = useState<string | null>(null);
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [actionResult, setActionResult] = useState<{
key: string;
message: string;
isError: boolean;
} | null>(null);
const load = useCallback(async () => {
try {
const res = await api.pricing.list();
setPricing(res.pricing);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
load();
}, [load]);
// A remote source can bring its own pricing rules; reload when one syncs.
useEffect(() => {
return eventBus.subscribe((msg: WSMessage) => {
if (isRemoteDataRefreshMessage(msg)) load();
});
}, [load]);
useEffect(() => {
if (!actionResult) return;
const timeout = setTimeout(() => setActionResult(null), 5000);
return () => clearTimeout(timeout);
}, [actionResult]);
const ours = (pattern: string) => VENDOR_PATTERN[vendor].test(pattern);
const rows = showAll ? pricing : pricing.filter((p) => ours(p.model_pattern));
const hidden = pricing.length - pricing.filter((p) => ours(p.model_pattern)).length;
const startEdit = (rule: ModelPricing) => {
setAdding(false);
setEditingPattern(rule.model_pattern);
setEditRow({
model_pattern: rule.model_pattern,
display_name: rule.display_name,
input_per_mtok: String(rule.input_per_mtok),
output_per_mtok: String(rule.output_per_mtok),
cache_read_per_mtok: String(rule.cache_read_per_mtok),
cache_write_per_mtok: String(rule.cache_write_per_mtok),
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
fast_input_per_mtok: String(rule.fast_input_per_mtok),
fast_output_per_mtok: String(rule.fast_output_per_mtok),
intro_until: rule.intro_until ?? "",
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
});
};
const startAdd = () => {
setEditingPattern(null);
setAdding(true);
setEditRow({ ...emptyRow });
};
const cancelEdit = () => {
setEditingPattern(null);
setAdding(false);
setError(null);
};
const saveEdit = async () => {
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
setError(t("pricing.validationRequired"));
return;
}
const introUntil = editRow.intro_until.trim();
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
setError(t("pricing.introUntilInvalid"));
return;
}
setSaving(true);
setError(null);
try {
await api.pricing.upsert({
model_pattern: editRow.model_pattern.trim(),
display_name: editRow.display_name.trim(),
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
// Always send the intro block so the UI is authoritative for it: an
// empty date clears the promo, a valid date persists the intro rates.
intro_until: introUntil || null,
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
});
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
setEditingPattern(null);
setAdding(false);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally {
setSaving(false);
}
};
const deleteRule = async (pattern: string) => {
try {
await api.pricing.delete(pattern);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
}
};
const handleResetPricing = async () => {
setResetting(true);
setActionResult(null);
setConfirmReset(false);
try {
// Only the Claude tab offers the button, and it resets only Claude's rows.
const res = await api.settings.resetPricing("claude");
setActionResult({
key: "reset-pricing",
message: t("pricing.resetResult", {
count: res.pricing.filter((p) => ours(p.model_pattern)).length,
}),
isError: false,
});
await load();
} catch (err) {
setActionResult({
key: "reset-pricing",
message: t("messages.actionFailed", {
message: err instanceof Error ? err.message : t("messages.unknownError"),
}),
isError: true,
});
} finally {
setResetting(false);
}
};
const lastUpdated =
rows.length > 0
? rows.reduce(
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
rows[0]!.updated_at
)
: null;
const isEditing = editingPattern !== null || adding;
const renderEditCells = () => (
<>
<td className="px-4 py-3">
<input
type="text"
value={editRow.model_pattern}
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
placeholder={t(`pricing.placeholders.${vendor}.pattern`)}
disabled={editingPattern !== null}
className="input w-full text-sm font-mono disabled:opacity-50"
autoFocus={adding}
/>
</td>
<td className="px-4 py-3">
<input
type="text"
value={editRow.display_name}
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
placeholder={t(`pricing.placeholders.${vendor}.name`)}
className="input w-full text-sm"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_read_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_1h_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={saveEdit}
disabled={saving}
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
title={t("common:save")}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
title={t("common:cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
);
// Second edit row: the time-limited introductory-rate block. Rendered under
// the standard-rate cells whenever a row is being edited/added. Leaving the
// date empty means "no promo" — the rate inputs are then ignored. This is the
// ONLY place intro rates are entered, and it works for any model pattern (not
// just Sonnet 5), so a future model with a launch promo needs no code change.
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
{t(labelKey)}
</span>
<input
type={opts.date ? "text" : "number"}
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
value={editRow[key]}
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
/>
</label>
);
const renderIntroEditRow = () => (
<tr className="bg-surface-3">
<td colSpan={10} className="px-4 pb-3 pt-2">
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")}
</span>
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
</div>
<div className="flex flex-wrap items-end gap-3">
{introField("intro_until", "pricing.introUntil", { date: true })}
{introField("intro_input_per_mtok", "common:token.input")}
{introField("intro_output_per_mtok", "common:token.output")}
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
</div>
</div>
</td>
</tr>
);
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
);
}
return (
<section id={id} className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-fg-muted" />
{t("pricing.title")}
<PricingInfoTooltip vendor={vendor} />
</h3>
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
</div>
<div className="flex items-center gap-2">
{hidden > 0 && (
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={showAll}
onChange={(e) => setShowAll(e.target.checked)}
className="accent-current"
/>
{t("pricing.showAll", { count: hidden })}
</label>
)}
{allowReset && (
<button
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
disabled={isEditing || resetting}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
}`}
>
<RotateCcw className="w-3 h-3" />
{confirmReset ? t("pricing.resetConfirm") : t("pricing.resetDefaults")}
</button>
)}
<button
onClick={startAdd}
disabled={isEditing}
className="btn-primary text-xs disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
</button>
</div>
</div>
{error && (
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{actionBanner(["reset-pricing"])}
<div className="card overflow-x-auto mt-4">
<table className="w-full min-w-[1000px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")}
</th>
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.length === 0 && !adding && (
<tr>
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
{t(`pricing.empty.${vendor}`)}
</td>
</tr>
)}
{rows.map((rule) =>
editingPattern === rule.model_pattern ? (
<Fragment key={rule.model_pattern}>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</Fragment>
) : (
<tr key={rule.model_pattern} className="hover:bg-surface-4 transition-colors group">
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
until: rule.intro_until,
})}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
)}
{adding && (
<>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</>
)}
</tbody>
</table>
</div>
{lastUpdated && (
<p className="text-xs text-fg-muted mt-3">
{t("pricing.lastUpdated")}
{formatTimestamp(lastUpdated)}
</p>
)}
</section>
);
}
@@ -0,0 +1,323 @@
/**
* @file AiToolsSettings.test.tsx
* @description The AI Tools modal: the Settings tab bar's button and the
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
* enable switch and Set default, available rows fold behind a disclosure with
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
* disappears from the hub, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
aiTools: {
list: vi.fn(),
refresh: vi.fn(),
update: vi.fn(),
install: vi.fn(),
updateTool: vi.fn(),
updateAll: vi.fn(),
job: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
};
});
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: `https://${id}.example/`,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
install: null,
update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over,
});
const VIEW: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code"),
tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }),
],
};
beforeEach(() => {
i18n.changeLanguage("en");
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
vi.mocked(api.aiTools.install).mockReset();
vi.mocked(api.aiTools.updateTool).mockReset();
vi.mocked(api.aiTools.updateAll).mockReset();
});
const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
id: 1,
tool: "qwen",
action: "install",
status: "running",
startedAt: "2026-09-28T00:00:00Z",
endedAt: null,
exitCode: null,
log: "$ npm install -g @qwen-code/qwen-code@latest\n",
...over,
});
describe("AiToolsSettings", () => {
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
const aider = screen.getByTestId("ai-tool-aider");
expect(within(aider).queryByRole("switch")).toBeNull();
expect(within(aider).queryByText("Set default")).toBeNull();
expect(within(aider).getByRole("link")).toHaveAttribute("href", "https://aider.example/");
});
it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok");
expect(within(grok).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
});
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
});
});
describe("AiToolsSettings — install and update", () => {
const A2: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { update: { kind: "self", args: ["update"] } }),
tool("codex", "Codex", {
install: { kind: "npm", package: "@openai/codex" },
update: { kind: "npm" },
}),
tool("gemini", "Gemini", {
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
}),
tool("qwen", "Qwen", {
installed: false,
path: null,
install: { kind: "npm", package: "@qwen-code/qwen-code" },
}),
tool("cursor", "Cursor", {
installed: false,
path: null,
install: { kind: "script", url: "https://cursor.com/install" },
}),
],
};
beforeEach(() => {
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
});
const openAvailable = async () =>
userEvent.click(await screen.findByRole("button", { name: /Available to install/ }));
it("confirms the exact npm command, then polls and shows the log", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.mocked(api.aiTools.install).mockResolvedValue({ job: job() });
vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
"npm install -g @qwen-code/qwen-code@latest"
);
expect(api.aiTools.install).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Run" }));
expect(api.aiTools.install).toHaveBeenCalledWith("qwen");
expect(await within(qwen).findByTestId("ai-tool-log")).toHaveTextContent("npm install -g");
await act(async () => {
vi.advanceTimersByTime(1100);
});
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done
await user.click(within(qwen).getByRole("button", { name: "Show log" }));
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(vi.mocked(api.aiTools.job).mock.calls.length).toBe(calls); // polling stopped
} finally {
vi.useRealTimers();
}
});
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
"Download https://cursor.com/install and run it with bash"
);
});
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
}
});
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest");
});
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
expect(img).toBeTruthy();
fireEvent.error(img);
expect(within(screen.getByTestId("ai-tool-qwen")).getByText("Q")).toBeInTheDocument();
});
});
describe("SettingsHub — AI tool tabs", () => {
it("hides the Codex tab while Codex is disabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude" }),
tool("gemini", "Gemini"),
tool("qwen-code", "Qwen Code", { enabled: false }),
tool("aider", "Aider", { installed: false }),
],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(screen.queryByRole("dialog")).toBeNull();
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens the modal for the /settings#ai-tools deep link", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
});
});
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("C")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
});
});
@@ -0,0 +1,85 @@
/**
* @file AntigravityHooksSettings.test.tsx
* @description Settings → Antigravity → Antigravity hooks: not installed until
* the user clicks Install, then "Installed" with the note that agy asks no
* trust and has no permission hook; a hook switched off in hooks.json is
* reported; Refresh re-reads; Remove; a server refusal shown inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
import { api } from "../../../lib/api";
import type { AntigravityHooksStatus } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
};
});
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
return {
supported: true,
readable: true,
path: "/home/u/.gemini/config/hooks.json",
script: "/home/u/.gemini/config/agenthub-hook.sh",
installed,
enabled,
events: Object.fromEntries(
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
),
};
}
const hooks = api.settings.antigravityHooks;
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
});
describe("AntigravityHooksSettings", () => {
it("installs on a click, and says agy has no permission hook", async () => {
const user = userEvent.setup();
render(<AntigravityHooksSettings />);
expect(await screen.findByText("Not installed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Install" }));
expect(hooks.install).toHaveBeenCalled();
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
expect(screen.queryByText("PreToolUse")).toBeNull();
});
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
const user = userEvent.setup();
vi.mocked(hooks.get)
.mockResolvedValueOnce(status(true, false))
.mockResolvedValueOnce(status(true, true));
render(<AntigravityHooksSettings />);
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
expect(screen.queryByText(/switched off/)).toBeNull();
await user.click(screen.getByRole("button", { name: "Remove" }));
expect(hooks.remove).toHaveBeenCalled();
expect(await screen.findByText("Not installed")).toBeInTheDocument();
});
it("shows a server refusal inline", async () => {
const user = userEvent.setup();
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
render(<AntigravityHooksSettings />);
await user.click(await screen.findByRole("button", { name: "Install" }));
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
});
});
@@ -0,0 +1,130 @@
/**
* @file ClaudeAgentHubSettings.test.tsx
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
* Settings → Dashboard (#40 part B) — still load and act as they did there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
settings: {
info: vi.fn(),
reinstallHooks: vi.fn(),
resetPricing: vi.fn(),
claudeHome: { get: vi.fn(), set: vi.fn() },
},
},
};
});
const RULE = {
model_pattern: "claude-opus-5-5%",
display_name: "Claude Opus 5.5",
input_per_mtok: 4,
output_per_mtok: 20,
cache_read_per_mtok: 0.2,
cache_write_per_mtok: 5,
cache_write_1h_per_mtok: 8,
fast_input_per_mtok: 8,
fast_output_per_mtok: 40,
updated_at: "2026-09-28 10:00:00",
} as ModelPricing;
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list)
.mockReset()
.mockResolvedValue({ pricing: [RULE] });
vi.mocked(api.settings.info)
.mockReset()
.mockResolvedValue({
hooks: {
installed: true,
path: "/home/u/.claude/settings.json",
hooks: { SessionStart: true, Stop: true },
},
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
vi.mocked(api.settings.claudeHome.get)
.mockReset()
.mockResolvedValue({ claude_home: "/home/u/.claude" });
vi.mocked(api.settings.claudeHome.set)
.mockReset()
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
vi.mocked(api.settings.resetPricing).mockReset();
vi.mocked(api.settings.reinstallHooks)
.mockReset()
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
});
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
const hooks = container.querySelector("section#hooks") as HTMLElement;
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
});
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
const user = userEvent.setup();
const { container } = render(<ClaudeAgentHubSettings />);
const input = await screen.findByDisplayValue("/home/u/.claude");
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
await user.click(within(home).getByRole("button"));
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button"));
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
});
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
ok: true,
claude_home: "/srv/claude",
routers: { missing: ["7 (api): my-router"] },
});
const onHomeChanged = vi.fn();
const user = userEvent.setup();
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
const input = await screen.findByDisplayValue("/home/u/.claude");
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
await user.click(within(home).getByRole("button"));
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
expect(onHomeChanged).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
it("resets pricing only on the second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
render(<ClaudeAgentHubSettings />);
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
/**
* @file CodexHooksSettings.test.tsx
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
* the user clicks Install, then "waiting for your trust" with the note on how
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
* server refusal shown inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { CodexHooksSettings } from "../CodexHooksSettings";
import { api } from "../../../lib/api";
import type { CodexHooksStatus } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
};
});
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
return {
supported: true,
readable: true,
path: "/home/u/.codex/hooks.json",
script: "/home/u/.codex/agenthub-hook.sh",
installed,
trusted: installed && trusted,
events: Object.fromEntries(
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
),
};
}
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
});
describe("CodexHooksSettings", () => {
it("installs on a click, then says Codex still has to trust them", async () => {
const user = userEvent.setup();
render(<CodexHooksSettings />);
expect(await screen.findByText("Not installed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Install" }));
expect(api.settings.codexHooks.install).toHaveBeenCalled();
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
});
it("shows trusted hooks, and removes them", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
render(<CodexHooksSettings />);
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
await user.click(screen.getByRole("button", { name: "Remove" }));
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
expect(await screen.findByText("Not installed")).toBeInTheDocument();
});
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.codexHooks.get)
.mockResolvedValueOnce(status(true, false))
.mockResolvedValueOnce(status(true, true));
render(<CodexHooksSettings />);
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
});
it("still offers Remove when only some of the hooks are there", async () => {
const partial = status(true, false);
partial.installed = false;
partial.events.Stop = { installed: false, trusted: false };
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
render(<CodexHooksSettings />);
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
// The chip, plus the missing event's screen-reader label.
expect(screen.getAllByText("Not installed")).toHaveLength(2);
});
it("shows a server refusal inline", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
);
render(<CodexHooksSettings />);
await user.click(await screen.findByRole("button", { name: "Install" }));
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
/**
* @file HomeDirSetting.test.tsx
* @description The shared editable home directory: loads the value in effect,
* Save is disabled until the path changes, a server refusal shows inline and
* changes nothing, an env-owned variable warns that a save will not survive a
* restart, and a partial-success warning from `save` is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
beforeEach(() => {
i18n.changeLanguage("en");
});
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
const saveFn = vi.fn(save);
const view = render(
<HomeDirSetting
id="x-home"
title="X_HOME"
description="desc"
placeholder="/path"
load={() => Promise.resolve(load)}
save={saveFn}
/>
);
return { saveFn, ...view };
}
describe("HomeDirSetting", () => {
it("saves a changed path and shows the value now in effect", async () => {
const user = userEvent.setup();
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
Promise.resolve({ value: p, envOwned: false })
);
const input = await screen.findByDisplayValue("/a");
const button = screen.getByRole("button", { name: "Save" });
expect(button).toBeDisabled();
expect(container.querySelector("section#x-home")).not.toBeNull();
// The saved value shows only while the field says something else.
expect(screen.queryByText("/a", { selector: "code" })).toBeNull();
await user.clear(input);
await user.type(input, "/b");
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
await user.click(button);
expect(saveFn).toHaveBeenCalledWith("/b");
await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull());
expect(screen.getByDisplayValue("/b")).toBeInTheDocument();
expect(screen.queryByText(/set by the environment/)).toBeNull();
});
it("shows a refusal inline and keeps the old value", async () => {
const user = userEvent.setup();
setup({ value: "/a", envOwned: false }, () =>
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
});
it("warns when the environment owns the variable, and shows a save's warning", async () => {
const user = userEvent.setup();
setup({ value: "/a", envOwned: true }, (p) =>
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
);
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
const input = screen.getByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
});
});
@@ -0,0 +1,110 @@
/**
* @file PricingSettings.test.tsx
* @description The shared Model Pricing table: each tool's tab shows its own
* vendor's rows, **Show all models** lists the rest, a row added outside the
* filter turns that switch on instead of vanishing, and Reset Defaults is
* offered only where asked for (the Claude tab).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
settings: { resetPricing: vi.fn() },
},
};
});
const rule = (model_pattern: string, display_name: string) =>
({
model_pattern,
display_name,
input_per_mtok: 1,
output_per_mtok: 2,
cache_read_per_mtok: 0.1,
cache_write_per_mtok: 0,
cache_write_1h_per_mtok: 0,
fast_input_per_mtok: 0,
fast_output_per_mtok: 0,
updated_at: "2026-09-29 10:00:00",
}) as ModelPricing;
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
vi.mocked(api.pricing.upsert)
.mockReset()
.mockResolvedValue({} as never);
});
describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
});
it("marks a row the automatic sync keeps up to date", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [
{ ...rule("gpt-6-sol%", "GPT-6 Sol"), source: "sync" },
{ ...rule("gpt-5.6-sol%", "GPT-5.6 Sol"), source: "user" },
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
it("says where OpenAI prices come from when there are none", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
});
it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%");
await user.type(name!, "DeepSeek V4");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
});
await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
});
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @file useAiTools — the AI Tools catalog (GET /api/ai-tools) shared by every
* component that names a lane's tool or offers one: the Add-lane modal, the
* run form, the lane strip and Settings → AI Tools. One module-level copy, so
* N components mean one request; refetched on window focus and after every
* change made through `update`/`refresh`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api, type AiTool, type AiToolUpdateBody, type AiToolsView } from "../lib/api";
let view: AiToolsView | null = null;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
function publish(next: AiToolsView) {
view = next;
listeners.forEach((l) => l());
}
function load(): Promise<void> {
if (!inflight) {
inflight = Promise.resolve()
.then(() => api.aiTools.list())
.then(publish)
// Unreachable server: keep what we had; every caller has a fallback.
.catch(() => undefined)
.finally(() => {
inflight = null;
});
}
return inflight;
}
function subscribe(l: () => void) {
listeners.add(l);
if (listeners.size === 1) window.addEventListener("focus", onFocus);
return () => {
listeners.delete(l);
if (listeners.size === 0) window.removeEventListener("focus", onFocus);
};
}
function onFocus() {
void load();
}
/** Built-in names for the three tools a lane may carry before the catalog loads. */
const FALLBACK: Record<string, string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
};
/** Test seam: forget the cached catalog. */
export function __resetAiTools() {
view = null;
inflight = null;
}
export function useAiTools() {
const v = useSyncExternalStore(subscribe, () => view);
useEffect(() => {
if (!view) void load();
}, []);
const tools: AiTool[] = v?.tools ?? [];
const byId = (id: string) => tools.find((t) => t.id === id) ?? null;
return {
tools,
loaded: v !== null,
defaultId: v?.default ?? null,
byId,
/** A tool's display name; the id itself for a tool the catalog no longer knows. */
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
reload: load,
refresh: async () => publish(await api.aiTools.refresh()),
update: async (id: string, body: AiToolUpdateBody) =>
publish(await api.aiTools.update(id, body)),
};
}
+3 -1
View File
@@ -50,7 +50,9 @@
"modified": "Modified",
"scopeLabel": "Scope",
"noFrontmatter": "No frontmatter",
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side."
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
"preview": "Preview",
"raw": "Raw"
},
"skills": {
"name": "Name",
+9 -6
View File
@@ -7,8 +7,6 @@
"action.stop": "stop",
"actionError": "Lane action failed: {{message}}",
"actionErrorUnknown": "Unknown error",
"actions.agentsInstall": "Install agents",
"actions.agentsInstallResult": "Installed: {{files}}",
"actions.busy": "…",
"actions.mcpSync": "Sync MCP",
"actions.mcpSyncResult": "Synced: {{servers}}",
@@ -47,9 +45,7 @@
"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.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupAgents": "Agents",
"addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile",
@@ -57,8 +53,10 @@
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolDefaultBadge": "Default",
"addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
@@ -220,6 +218,8 @@
"renameLabel": "Rename this lane",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
@@ -256,6 +256,9 @@
"changes.edit": "Edit",
"changes.save": "Save",
"changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview",
"changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}",
"changes.prevHunk": "Previous change",
"changes.nextHunk": "Next change",
+3 -1
View File
@@ -68,5 +68,7 @@
"workspace": "Workspace",
"wsEndpoint": "WebSocket",
"releaseAvailable": "{{version}} available",
"devBuild": "Dev build · {{count}} ahead"
"devBuild": "Dev build · {{count}} ahead",
"installedNotRunning": "{{version}} installed, not running",
"releaseUnchecked": "Could not check for updates"
}
+3 -1
View File
@@ -76,12 +76,13 @@
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"bypass": "Bypass permissions for this run",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)",
"prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Full access (dangerous)",
"sandboxReadOnly": "Read only",
@@ -97,6 +98,7 @@
"hint": {
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Enter to send · Shift+Enter for newline"
},
+2 -1
View File
@@ -39,5 +39,6 @@
"timeline": "Timeline ({{shown}}/{{total}})",
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
},
"remoteSourceBadgeTitle": "Collected from a remote machine"
"remoteSourceBadgeTitle": "Collected from a remote machine",
"toolBadgeTitle": "Recorded by {{tool}}"
}
+125 -14
View File
@@ -1,6 +1,6 @@
{
"title": "Settings",
"subtitle": "Manage pricing, notifications, data, and hooks",
"subtitle": "Manage appearance, AI tools, notifications and data",
"exportData": "Export Data",
"acrossSessions": "Across all tracked sessions",
"basedOnUsage": "Based on per-model token usage",
@@ -23,8 +23,6 @@
"resetResult": "Reset to {{count}} default rules",
"addModel": "Add Model",
"pattern": "Pattern",
"patternPlaceholder": "claude-opus-4%",
"namePlaceholder": "Claude Opus 4",
"validationRequired": "Pattern and display name are required",
"lastUpdated": "Last updated: ",
"tooltip": {
@@ -33,24 +31,44 @@
"howItWorksBody": "When a session reports usage, the dashboard finds the first pricing rule whose pattern matches the model id and multiplies (input tokens / 1M) and (output tokens / 1M) by the rates below to compute cost. Rules are evaluated top-to-bottom - the first match wins.",
"patternsTitle": "Pattern syntax",
"patternsBody": "Patterns are SQL-style globs. % is a wildcard that matches any characters. For example: claude-opus-4-7% matches every model id that starts with claude-opus-4-7 (e.g. claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku matches any haiku variant. An exact id like claude-sonnet-4-6 only matches itself.",
"manualUpdates": "You must update prices manually",
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricing": "Reference only - API rates",
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill."
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
"synced": "Prices are filled in automatically",
"syncedBody": "Every current Claude, OpenAI and Gemini model is priced from LiteLLM's public price list, checked once a day, as is any other model the moment a session uses it. Rows marked Auto follow that list when a rate changes; a row you edit becomes yours and is never touched again, and a row you delete stays deleted. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
},
"cacheWrite5m": "Cache Write (5m)",
"cacheWrite1h": "Cache Write (1h)",
"fastInput": "Fast Input",
"fastOutput": "Fast Output"
"fastOutput": "Fast Output",
"showAll": "Show all models ({{count}} more)",
"empty": {
"claude": "No Claude model prices. Reset Defaults restores them.",
"openai": "No OpenAI model prices yet. They are added automatically from LiteLLM's price list once a Codex session uses a model, or add one yourself.",
"google": "No Gemini model prices yet. They are added automatically from LiteLLM's price list once an Antigravity session uses a model, or add one yourself."
},
"placeholders": {
"claude": {
"pattern": "claude-opus-4%",
"name": "Claude Opus 4"
},
"openai": {
"pattern": "gpt-5%",
"name": "GPT 5"
},
"google": {
"pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash"
}
},
"autoBadge": "Auto",
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate."
},
"claudeHome": {
"title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
"save": "Save",
"saving": "Saving...",
"current": "Current:",
"placeholder": "/path/to/claude-home",
"saveFailed": "Failed to update CLAUDE_HOME"
"routersMissing": "Saved. These lanes name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
},
"hooks": {
"title": "Hook Configuration",
@@ -63,9 +81,7 @@
},
"lanesSection": {
"title": "Lanes",
"description": "Machine-wide housekeeping and one-time setup for the ship-feature-lane pipeline.",
"installSkill": "Install ship-feature-lane skill",
"installSkillSuccess": "Installed to {{path}}",
"description": "Machine-wide lane housekeeping.",
"runHousekeeping": "Run housekeeping",
"dryRun": "Dry run",
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
@@ -452,6 +468,7 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"antigravity": "Antigravity",
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
@@ -497,6 +514,36 @@
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
"description": "AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.",
"installed": "Installed ({{count}} detected)",
"available": "Available to install ({{count}})",
"refresh": "Refresh",
"noneInstalled": "No AI tool found on PATH. Install one, then press Refresh.",
"default": "Default",
"setDefault": "Set default",
"enabled": "Enabled",
"disabled": "Disabled",
"enabledOf": "{{tool}} enabled",
"homepage": "Homepage",
"homepageOf": "{{tool}} homepage",
"install": "Install",
"update": "Update",
"updateAll": "Update all ({{count}})",
"runScript": "Download {{url}} and run it with bash",
"confirmInstall": "Install {{tool}}?",
"confirmUpdate": "Update {{tool}}?",
"confirmUpdateAll": "Update AI tools?",
"confirmBody": "AgentHub will run on this machine:",
"run": "Run",
"cancel": "Cancel",
"jobRunning": "Running…",
"jobOk": "Done",
"jobFailed": "Failed (exit {{code}})",
"showLog": "Show log",
"hideLog": "Hide log"
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
@@ -528,5 +575,69 @@
"colSaved": "Saved",
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
},
"homeDir": {
"save": "Save",
"saving": "Saving...",
"current": "Current:",
"saveFailed": "Failed to save the directory",
"envOwned": "This variable is set by the environment the dashboard started in, or by the checkout's .env — either wins at every start: a value saved here lasts until the next restart."
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex lane with it.",
"restartNote": "A codex lane already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home",
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
},
"codexHooks": {
"title": "Codex hooks",
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the lane card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
"notInstalled": "Not installed",
"untrusted": "Installed — waiting for your trust in Codex",
"trusted": "Installed and trusted",
"trustNote": "Codex runs a hook only after you trust it. The next time Codex starts it asks you to review new hooks: choose Trust all and continue (or run /hooks). Until then Codex skips them.",
"install": "Install",
"reinstall": "Reinstall",
"refresh": "Refresh",
"remove": "Remove",
"eventTrusted": "Trusted in Codex",
"eventUntrusted": "Not trusted in Codex yet",
"eventMissing": "Not installed",
"unsupported": "Codex hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
"failed": "Could not change Codex hooks: {{message}}"
},
"antigravityHooks": {
"title": "Antigravity hooks",
"description": "Let Antigravity's CLI (agy) report to AgentHub as it works: each tool call in the session's timeline, and running/idle the moment a run starts or ends. Tokens and cost come from agy's conversation files either way.",
"notInstalled": "Not installed",
"installed": "Installed",
"disabled": "Installed but switched off in hooks.json",
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity lane cannot show \"needs your permission\".",
"install": "Install",
"reinstall": "Reinstall",
"refresh": "Refresh",
"remove": "Remove",
"eventInstalled": "Installed",
"eventMissing": "Not installed",
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
"failed": "Could not change Antigravity hooks: {{message}}"
},
"toolTab": {
"update": "Update…",
"usage_one": "{{count}} session · ${{cost}}",
"usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Lanes:",
"defaultModel": "Default model",
"defaultModelHint": "The model a new run in this tool's lanes starts on. The run form can still pick another one.",
"saveDefaultModel": "Save default model",
"config": "Config files",
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
"missing": "This file does not exist yet",
"save": "Save",
"close": "Close",
"saved": "Saved. Backup: {{backup}}"
}
}
+2 -1
View File
@@ -26,5 +26,6 @@
"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…"
"restartingShort": "Restarting…",
"installedTitle": "Version {{version}} is installed but not running"
}
+3 -1
View File
@@ -50,7 +50,9 @@
"modified": "Đã sửa",
"scopeLabel": "Phạm vi",
"noFrontmatter": "Không có frontmatter",
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ."
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.",
"preview": "Xem trước",
"raw": "Mã nguồn"
},
"skills": {
"name": "Tên",
+9 -6
View File
@@ -7,8 +7,6 @@
"action.stop": "Dừng",
"actionError": "Thao tác làn đường thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định",
"actions.agentsInstall": "Cài agent",
"actions.agentsInstallResult": "Đã cài: {{files}}",
"actions.busy": "…",
"actions.mcpSync": "Đồng bộ MCP",
"actions.mcpSyncResult": "Đã đồng bộ: {{servers}}",
@@ -47,9 +45,7 @@
"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.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupAgents": "Agent",
"addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile",
@@ -57,8 +53,10 @@
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolDefaultBadge": "Mặc định",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
@@ -220,6 +218,8 @@
"renameLabel": "Đổi tên làn đường này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} chạy bằng cấu hình riêng — hồ sơ AI Router chỉ dùng được với Claude Code và Codex.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
@@ -256,6 +256,9 @@
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước",
"changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}",
"changes.prevHunk": "Thay đổi trước",
"changes.nextHunk": "Thay đổi sau",
+3 -1
View File
@@ -68,5 +68,7 @@
"workspace": "Không gian làm việc",
"wsEndpoint": "WebSocket",
"releaseAvailable": "Có {{version}}",
"devBuild": "Bản dev · trước {{count}} commit"
"devBuild": "Bản dev · trước {{count}} commit",
"installedNotRunning": "Đã cài {{version}}, chưa chạy",
"releaseUnchecked": "Không kiểm tra được bản mới"
}
+3 -1
View File
@@ -75,12 +75,13 @@
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
"sandboxReadOnly": "Chỉ đọc",
@@ -96,6 +97,7 @@
"hint": {
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
+2 -1
View File
@@ -38,5 +38,6 @@
"conversation": "Hội thoại",
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
}
},
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
}
+125 -14
View File
@@ -1,6 +1,6 @@
{
"title": "Cài đặt",
"subtitle": "Quản lý định giá, thông báo, dữ liệu và hooks",
"subtitle": "Quản lý giao diện, công cụ AI, thông báo và dữ liệu",
"exportData": "Xuất dữ liệu",
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
@@ -23,8 +23,6 @@
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",
"addModel": "Thêm mô hình",
"pattern": "Mẫu",
"patternPlaceholder": "claude-opus-4%",
"namePlaceholder": "Claude Opus 4",
"validationRequired": "Bắt buộc nhập mẫu và tên hiển thị",
"lastUpdated": "Cập nhật lần cuối: ",
"tooltip": {
@@ -33,24 +31,44 @@
"howItWorksBody": "Khi một phiên báo cáo mức dùng, dashboard tìm quy tắc định giá đầu tiên có mẫu khớp với model id, rồi nhân (token đầu vào / 1 triệu) và (token đầu ra / 1 triệu) với các đơn giá bên dưới để tính chi phí. Quy tắc được duyệt từ trên xuống - quy tắc khớp đầu tiên sẽ được dùng.",
"patternsTitle": "Cú pháp mẫu",
"patternsBody": "Mẫu là dạng glob kiểu SQL. % là ký tự đại diện khớp với bất kỳ chuỗi nào. Ví dụ: claude-opus-4-7% khớp mọi model id bắt đầu bằng claude-opus-4-7 (ví dụ claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku khớp mọi biến thể haiku. Một id chính xác như claude-sonnet-4-6 chỉ khớp với chính nó.",
"manualUpdates": "Bạn phải cập nhật giá thủ công",
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
"apiPricing": "Chỉ để tham khảo - đơn giá API",
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn."
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
"synced": "Giá được điền tự động",
"syncedBody": "Mọi model Claude, OpenAI và Gemini hiện hành được lấy giá từ bảng giá công khai của LiteLLM, kiểm tra mỗi ngày một lần; model khác cũng có giá ngay khi một phiên dùng tới. Dòng có nhãn Tự động sẽ theo bảng giá đó khi giá thay đổi; dòng bạn sửa trở thành của bạn và không bao giờ bị ghi đè, dòng bạn xoá sẽ không quay lại. Phiên đã lưu giữ nguyên giá đã áp dụng lúc ghi nhận.",
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
},
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
"fastInput": "Fast Input",
"fastOutput": "Fast Output"
"fastOutput": "Fast Output",
"showAll": "Hiện mọi model (thêm {{count}})",
"empty": {
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.",
"openai": "Chưa có giá model OpenAI. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Codex dùng model đó, hoặc bạn tự thêm.",
"google": "Chưa có giá model Gemini. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Antigravity dùng model đó, hoặc bạn tự thêm."
},
"placeholders": {
"claude": {
"pattern": "claude-opus-4%",
"name": "Claude Opus 4"
},
"openai": {
"pattern": "gpt-5%",
"name": "GPT 5"
},
"google": {
"pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash"
}
},
"autoBadge": "Tự động",
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn."
},
"claudeHome": {
"title": "CLAUDE_HOME",
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
"save": "Lưu",
"saving": "Đang lưu...",
"current": "Hiện tại:",
"placeholder": "/đường-dẫn/tới/claude-home",
"saveFailed": "Cập nhật CLAUDE_HOME thất bại"
"routersMissing": "Đã lưu. Các lane này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho lane: {{lanes}}"
},
"hooks": {
"title": "Cấu hình hook",
@@ -63,9 +81,7 @@
},
"lanesSection": {
"title": "Lanes",
"description": "Bảo trì toàn máy và cài đặt một lần cho pipeline ship-feature-lane.",
"installSkill": "Cài skill ship-feature-lane",
"installSkillSuccess": "Đã cài vào {{path}}",
"description": "Dọn dẹp lane trên toàn máy.",
"runHousekeeping": "Chạy dọn dẹp",
"dryRun": "Chạy thử (dry run)",
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
@@ -452,6 +468,7 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"antigravity": "Antigravity",
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
@@ -497,6 +514,36 @@
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
},
"aiTools": {
"title": "AI Tools",
"description": "Các CLI lập trình AI mà AgentHub chạy được trong lane. Nhận diện qua PATH của máy; tool bị tắt sẽ ẩn khỏi Add lane.",
"installed": "Đã cài ({{count}} tool)",
"available": "Có thể cài ({{count}})",
"refresh": "Làm mới",
"noneInstalled": "Chưa tìm thấy AI tool nào trong PATH. Cài một tool rồi bấm Làm mới.",
"default": "Mặc định",
"setDefault": "Đặt mặc định",
"enabled": "Đang bật",
"disabled": "Đang tắt",
"enabledOf": "Bật {{tool}}",
"homepage": "Trang chủ",
"homepageOf": "Trang chủ {{tool}}",
"install": "Cài đặt",
"update": "Cập nhật",
"updateAll": "Cập nhật tất cả ({{count}})",
"runScript": "Tải {{url}} và chạy bằng bash",
"confirmInstall": "Cài {{tool}}?",
"confirmUpdate": "Cập nhật {{tool}}?",
"confirmUpdateAll": "Cập nhật các AI tool?",
"confirmBody": "AgentHub sẽ chạy trên máy này:",
"run": "Chạy",
"cancel": "Huỷ",
"jobRunning": "Đang chạy…",
"jobOk": "Xong",
"jobFailed": "Lỗi (exit {{code}})",
"showLog": "Xem log",
"hideLog": "Ẩn log"
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
@@ -528,5 +575,69 @@
"colSaved": "Đã lưu",
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
},
"homeDir": {
"save": "Lưu",
"saving": "Đang lưu...",
"current": "Hiện tại:",
"saveFailed": "Không lưu được thư mục",
"envOwned": "Biến này được đặt bởi môi trường lúc khởi động dashboard, hoặc bởi file .env của bản checkout, và nguồn đó luôn được ưu tiên mỗi lần khởi động: giá trị lưu ở đây chỉ giữ đến lần khởi động lại tiếp theo."
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi lane codex với thư mục này.",
"restartNote": "Lane codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home",
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
},
"codexHooks": {
"title": "Hook của Codex",
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ lane và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
"notInstalled": "Chưa cài",
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
"trusted": "Đã cài và được tin cậy",
"trustNote": "Codex chỉ chạy một hook sau khi bạn tin cậy nó. Lần tới Codex khởi động, nó sẽ hỏi bạn xem lại các hook mới: chọn Trust all and continue (hoặc gõ /hooks). Trước đó Codex bỏ qua chúng.",
"install": "Cài đặt",
"reinstall": "Cài lại",
"refresh": "Làm mới",
"remove": "Gỡ bỏ",
"eventTrusted": "Đã được tin cậy trong Codex",
"eventUntrusted": "Chưa được tin cậy trong Codex",
"eventMissing": "Chưa cài",
"unsupported": "Hook của Codex từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
"failed": "Không đổi được hook của Codex: {{message}}"
},
"antigravityHooks": {
"title": "Hook của Antigravity",
"description": "Cho CLI của Antigravity (agy) báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ file hội thoại của agy dù có hook hay không.",
"notInstalled": "Chưa cài",
"installed": "Đã cài",
"disabled": "Đã cài nhưng đang tắt trong hooks.json",
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên lane Antigravity không thể hiện \"cần bạn cấp quyền\".",
"install": "Cài đặt",
"reinstall": "Cài lại",
"refresh": "Làm mới",
"remove": "Gỡ bỏ",
"eventInstalled": "Đã cài",
"eventMissing": "Chưa cài",
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
"failed": "Không đổi được hook của Antigravity: {{message}}"
},
"toolTab": {
"update": "Cập nhật…",
"usage_one": "{{count}} phiên · ${{cost}}",
"usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Lane:",
"defaultModel": "Model mặc định",
"defaultModelHint": "Model mà một lần chạy mới trong lane của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hình",
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
"missing": "File này chưa có",
"save": "Lưu",
"close": "Đóng",
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
}
}
+2 -1
View File
@@ -26,5 +26,6 @@
"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…"
"restartingShort": "Đang khởi động lại…",
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy"
}
+139
View File
@@ -441,3 +441,142 @@
.animate-pulse-dot {
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme
reads it; no typography plugin. */
.md-preview {
color: rgb(var(--fg-secondary));
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.md-preview > :first-child {
margin-top: 0;
}
.md-preview h1,
.md-preview h2,
.md-preview h3,
.md-preview h4,
.md-preview h5,
.md-preview h6 {
color: rgb(var(--fg-primary));
font-weight: 600;
line-height: 1.3;
margin: 1.4em 0 0.6em;
}
.md-preview h1 {
font-size: 1.6em;
padding-bottom: 0.3em;
border-bottom: 1px solid rgb(var(--border));
}
.md-preview h2 {
font-size: 1.3em;
padding-bottom: 0.25em;
border-bottom: 1px solid rgb(var(--border));
}
.md-preview h3 {
font-size: 1.12em;
}
.md-preview h4,
.md-preview h5,
.md-preview h6 {
font-size: 1em;
}
.md-preview p,
.md-preview ul,
.md-preview ol,
.md-preview blockquote,
.md-preview pre,
.md-preview table {
margin: 0 0 0.9em;
}
.md-preview ul {
list-style: disc;
padding-left: 1.5em;
}
.md-preview ol {
list-style: decimal;
padding-left: 1.5em;
}
.md-preview li + li {
margin-top: 0.2em;
}
.md-preview li > ul,
.md-preview li > ol {
margin: 0.2em 0 0;
}
.md-preview li.task-list-item {
list-style: none;
margin-left: -1.3em;
}
.md-preview li.task-list-item input {
margin-right: 0.4em;
vertical-align: middle;
}
.md-preview a {
color: rgb(var(--accent));
text-decoration: underline;
text-underline-offset: 2px;
}
.md-preview strong {
color: rgb(var(--fg-primary));
font-weight: 600;
}
.md-preview code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: rgb(var(--surface-3));
color: rgb(var(--fg-primary));
}
.md-preview pre,
.md-preview .md-frontmatter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
padding: 0.75em 1em;
border-radius: 6px;
border: 1px solid rgb(var(--border));
background: rgb(var(--surface-0));
overflow-x: auto;
white-space: pre;
}
.md-preview .md-frontmatter {
margin: 0 0 1em;
color: rgb(var(--fg-muted));
}
.md-preview pre code {
padding: 0;
background: none;
font-size: inherit;
}
.md-preview blockquote {
padding: 0 1em;
border-left: 3px solid rgb(var(--border));
color: rgb(var(--fg-muted));
}
.md-preview hr {
margin: 1.5em 0;
border: 0;
border-top: 1px solid rgb(var(--border));
}
.md-preview table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.md-preview th,
.md-preview td {
padding: 0.35em 0.75em;
border: 1px solid rgb(var(--border));
}
.md-preview th {
color: rgb(var(--fg-primary));
font-weight: 600;
background: rgb(var(--surface-2));
}
.md-preview img {
max-width: 100%;
}
+191 -30
View File
@@ -393,6 +393,8 @@ import type {
AlertEvent,
AlertRule,
Analytics,
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
DashboardEvent,
Lane,
@@ -676,6 +678,8 @@ export const api = {
status?: string;
q?: string;
cwd?: string;
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
tool?: string;
sort_by?: string;
sort_desc?: boolean;
limit?: number;
@@ -686,6 +690,7 @@ export const api = {
if (params?.status) qs.set("status", params.status);
if (params?.q) qs.set("q", params.q);
if (params?.cwd) qs.set("cwd", params.cwd);
if (params?.tool) qs.set("tool", params.tool);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
@@ -998,17 +1003,56 @@ export const api = {
* GET /api/settings/claude-home - the currently configured Claude home path.
* @returns `{ claude_home }` — the absolute path the server reads config from.
*/
get: () => request<{ claude_home: string }>("/settings/claude-home"),
get: () => request<{ claude_home: string; env_owned?: boolean }>("/settings/claude-home"),
/**
* PUT /api/settings/claude-home - repoint the server at a new Claude home.
* @param path New absolute `~/.claude` root the server should read from.
* @returns `{ ok, claude_home }` — success flag and the accepted path.
* @returns `{ ok, claude_home, routers }` — the accepted path, and the
* lanes whose AI Router profile the new home's store does not have.
*/
set: (path: string) =>
request<{ ok: boolean; claude_home: string }>("/settings/claude-home", {
method: "PUT",
body: JSON.stringify({ path }),
}),
request<{
ok: boolean;
claude_home: string;
env_owned?: boolean;
routers: { missing: string[] };
}>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }),
},
/** The Codex home: same shape as `claudeHome`, plus the AI Router layers
* a PUT re-wrote into the new home for codex lanes. */
codexHome: {
/** GET /api/settings/codex-home */
get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"),
/** PUT /api/settings/codex-home */
set: (path: string) =>
request<{
ok: boolean;
codex_home: string;
env_owned: boolean;
routers: { updated: number; failed: string[] };
}>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }),
},
/** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a
* click; Codex asks the user to trust them before they run. */
codexHooks: {
/** GET /api/settings/codex-hooks */
get: () => request<CodexHooksStatus>("/settings/codex-hooks"),
/** POST /api/settings/codex-hooks — install, or reinstall */
install: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "POST" }),
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
},
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
* only on a click; agy runs it with no trust step. */
antigravityHooks: {
/** GET /api/settings/antigravity-hooks */
get: () => request<AntigravityHooksStatus>("/settings/antigravity-hooks"),
/** POST /api/settings/antigravity-hooks — install, or reinstall */
install: () =>
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "POST" }),
/** DELETE /api/settings/antigravity-hooks — AgentHub's named hook only */
remove: () =>
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "DELETE" }),
},
/**
* POST /api/settings/clear-data - DESTRUCTIVE: wipes sessions/agents/
@@ -1063,11 +1107,13 @@ export const api = {
* Wipes user-customized pricing rules and reseeds the shipped defaults;
* returns the resulting rule set so the Pricing UI can re-render.
*
* @returns `{ ok, pricing }` — the full default rule list now in effect.
* @param scope - `"claude"` resets only `claude-…` rows; omitted, every row.
* @returns `{ ok, pricing }` — the full rule list now in effect.
*/
resetPricing: () =>
resetPricing: (scope?: "claude") =>
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
method: "POST",
...(scope && { body: JSON.stringify({ scope }) }),
}),
/**
* Direct download URL for GET /api/settings/export (a full DB dump);
@@ -1130,8 +1176,6 @@ export const api = {
purged_events: number;
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
skillsInstall: () =>
request<{ installed: true; path: string }>("/skills/install", { method: "POST", body: "{}" }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
method: "POST",
@@ -1941,6 +1985,58 @@ export const api = {
{ method: "DELETE" }
),
},
aiTools: {
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
list: () => request<AiToolsView>("/ai-tools"),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
update: (id: string, body: AiToolUpdateBody) =>
request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
/** GET /api/ai-tools/:id/models - the models the tool itself offers. */
models: (id: string) => request<AiToolModels>(`/ai-tools/${encodeURIComponent(id)}/models`),
/** GET /api/ai-tools/:id/usage - how many sessions the tool ran, and their cost. */
usage: (id: string) =>
request<{ sessions: number; cost: number }>(`/ai-tools/${encodeURIComponent(id)}/usage`),
/** GET /api/ai-tools/:id/config - the config files the tool's catalog file lists. */
config: (id: string) =>
request<{ files: AiToolConfigFile[] }>(`/ai-tools/${encodeURIComponent(id)}/config`),
/** GET /api/ai-tools/:id/config/:index - one config file's text and sha. */
readConfig: (id: string, index: number) =>
request<{ path: string; format: AiToolConfigFile["format"]; text: string; sha: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`
),
/** PUT /api/ai-tools/:id/config/:index - save over `baseSha` (409 ESTALE otherwise). */
saveConfig: (id: string, index: number, text: string, baseSha: string) =>
request<{ sha: string; backup: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`,
{ method: "PUT", body: JSON.stringify({ text, baseSha }) }
),
/** POST /api/ai-tools/:id/install - start the catalog's install job. */
install: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
method: "POST",
}),
/** POST /api/ai-tools/:id/update - start the catalog's update job. */
updateTool: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/update`, {
method: "POST",
}),
/** POST /api/ai-tools/update-all - one job updating every tool with an update. */
updateAll: () => request<{ job: AiToolJob }>("/ai-tools/update-all", { method: "POST" }),
/** GET /api/ai-tools/job - the current or last job, or null. */
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
@@ -1989,7 +2085,7 @@ export const api = {
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane's CLI (`claude` or `codex`) was found on PATH. */
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
@@ -2572,6 +2668,8 @@ export const api = {
paths?: string[];
path?: string;
hunk?: string;
/** One change block (a run of +/- lines) inside `hunk`, by index. */
block?: number;
context?: number;
confirm?: boolean;
/** resolve: which side wins, or `done` for a hand-edited file. */
@@ -2717,11 +2815,6 @@ export const api = {
body: JSON.stringify(body),
}),
},
agentsInstall: (id: number) =>
request<{ installed: string[] }>(`/lanes/${id}/agents/install`, {
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
@@ -2968,6 +3061,83 @@ export interface GitAuthNeeded {
rejected: boolean;
}
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
export interface AiTool {
id: string;
label: string;
/** The command that was found (or would be looked for) on PATH. */
command: string | null;
homepageUrl: string | null;
installed: boolean;
path: string | null;
enabled: boolean;
source: "builtin" | "remote" | "custom";
/** `claude` / `codex` keep their own run form; null for every other tool. */
launcher: "claude" | "codex" | null;
prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */
resume: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Router profile (Claude, Codex); others are native only. */
routerCapable: boolean;
/** What Install runs; null when the catalog names no install. */
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
/** The CLI takes a model flag (Claude, Codex, or a catalog `model` field). */
modelFlag: boolean;
/** The model a new run starts with; null inherits the CLI's own. */
defaultModel: string | null;
/** Whose priced models the tool offers (its Settings tab's pricing table). */
modelVendor: "claude" | "openai" | "google" | null;
/** How many config files its catalog file lists for the Settings tab. */
configFiles: number;
}
/** One config file a catalog tool's Settings tab may edit. */
export interface AiToolConfigFile {
index: number;
path: string;
format: "json" | "jsonc" | "text";
exists: boolean;
size: number | null;
}
export interface AiToolUpdateBody {
enabled?: boolean;
default?: true;
defaultModel?: string | null;
}
/** GET /api/ai-tools/:id/models - read from the tool's own source. */
export interface AiToolModels {
flag: boolean;
models: ModelChoice[];
defaultModel: string | null;
}
export interface AiToolsView {
tools: AiTool[];
/** The effective default tool id, or null when nothing is installed and enabled. */
default: string | null;
/** Where the catalog came from: this build, or the newer release synced daily. */
catalog?: { source: "builtin" | "remote"; tag: string | null; fetchedAt: string | null };
}
/** An install/update job (GET /api/ai-tools/job). */
export interface AiToolJob {
id: number;
/** null for update-all. */
tool: string | null;
action: "install" | "update" | "update-all";
status: "running" | "ok" | "failed";
startedAt: string;
endedAt: string | null;
exitCode: number | null;
log: string;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
@@ -3672,8 +3842,10 @@ export interface RunStartArgs {
sandbox?: CodexSandbox;
/** Codex only: `-a`. Ignored by a claude lane. */
approval?: CodexApproval;
/** Codex only: `codex resume --last` instead of picking a session id. */
/** Continue the tool's last session: Codex `resume --last`, a catalog tool's `resume` args. */
resumeLast?: boolean;
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
bypass?: boolean;
}
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
@@ -3767,9 +3939,8 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
{ id: "max", label: "Max", hint: "All-out - slowest, most tokens" },
];
// Curated model list. "" means "inherit from settings.json" - no --model flag.
// Static UI data for the Run page's model picker; the first entry inherits the
// configured default and the rest map to concrete `--model` values.
// Claude's fallback model list, used only when GET /api/ai-tools/claude/models
// cannot answer. "" means "inherit from settings.json" - no --model flag.
export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
@@ -3785,16 +3956,6 @@ export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
];
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
// via `codex -m`. "" inherits Codex's own configured default.
export const RUN_MODEL_CHOICES_CODEX: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your Codex config model is" },
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: "Highest capability" },
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", hint: "Balanced capability and speed" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", hint: "Fastest, lightest" },
{ id: "gpt-5.5", label: "GPT-5.5", hint: "Previous generation" },
];
/** Result of a transcript import run - returned by `api.import.rescan`,
* `scanPath`, and `upload`, and mirrored by the final `import.progress`
* WebSocket message (`phase: "complete"`). The core counters (`imported`/
+54 -2
View File
@@ -629,6 +629,40 @@ export type EffectiveSessionStatus = SessionStatus | typeof AWAITING_STATUS;
// `Agent` is one process within it (the main loop or a spawned subagent). Both
// are created lazily on their first hook event and mutated as hooks stream in.
/** GET/POST/DELETE /api/settings/codex-hooks: AgentHub's hooks in Codex. */
export interface CodexHooksStatus {
/** False on Windows: the hook command is a POSIX shell script. */
supported: boolean;
/** False when hooks.json exists but is not a JSON object (see `error`). */
readable: boolean;
error?: string;
/** `$CODEX_HOME/hooks.json` and the forwarder script it points at. */
path: string;
script: string;
/** Every event has AgentHub's hook, and the script exists. */
installed: boolean;
/** Codex's config.toml holds a trust entry for every one of them. */
trusted: boolean;
events: Record<string, { installed: boolean; trusted: boolean }>;
}
/** GET /api/settings/antigravity-hooks — AgentHub's named hook for agy. */
export interface AntigravityHooksStatus {
/** False on Windows: the hook command is a POSIX shell script. */
supported: boolean;
/** False when hooks.json exists but is not a JSON object (see `error`). */
readable: boolean;
error?: string;
/** `~/.gemini/config/hooks.json` and the forwarder script it points at. */
path: string;
script: string;
/** Every event runs AgentHub's forwarder, and the script exists. */
installed: boolean;
/** Installed and not switched off (`"enabled": false`) in the file. */
enabled: boolean;
events: Record<string, { installed: boolean }>;
}
/**
* A Claude Code CLI invocation tracked by the dashboard - one row per top-level
* `claude` process, created on its first hook event (or on import) and updated
@@ -695,6 +729,11 @@ export interface Session {
* dashboard's own machine; any other value is a remote source id (see the
* Remote Data Sources feature / `remote_sources`). Maps to `sessions.source`. */
source?: string;
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks) or `"antigravity"` (agy's conversation files and
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
tool?: string;
}
/**
@@ -998,6 +1037,11 @@ export interface Analytics {
* applied when a token bucket's `speed` is "fast".
*/
export interface ModelPricing {
/** Who owns the row: `"sync"` = added by the automatic sync and kept at
* LiteLLM's published rate daily, `"user"` = edited here and never touched
* again, null = from before this field (adopted by the sync only while it
* still equals the list). */
source?: "sync" | "user" | null;
/** SQL LIKE-style pattern (`%` wildcard) matched against a token bucket's
* model id; longer/more-specific patterns win ties. Primary key. Examples:
* `"claude-opus-4%"` (specific) beats `"claude-%"` (catch-all). */
@@ -1313,6 +1357,10 @@ export interface UpdateStatusPayload {
* or `"http_<status>"`. Anything but `"ok"` means detection fell back to
* the commit-count comparison. */
release_check?: string;
/** Plugin installs only: a newer build Claude Code has installed beside the
* running one (`claude plugin update` from a terminal starts nothing), or
* null. Sets `update_available`; "Update now" hands the server over to it. */
installed_not_running?: { root: string; version: string | null } | null;
/** Tag of the newest published release, e.g. `"v1.5.0"`. null when
* `release_check !== "ok"`. */
latest_release?: string | null;
@@ -2409,8 +2457,12 @@ export interface ProofFeature {
mtime: number;
}
/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */
export type LaneTool = "claude" | "codex";
/**
* Which CLI drives a lane's pane: an AI Tools catalog id (`claude`, `codex`,
* `antigravity`, `gemini`, …). Fixed at lane creation and never patched. Its
* label comes from `useAiTools().label(id)`.
*/
export type LaneTool = string;
export interface Lane {
id: number;
+267
View File
@@ -0,0 +1,267 @@
/**
* @file AiToolConfig.tsx
* @description Settings → <tool>: the one tab every installed, enabled
* catalog tool without a hand-written tab gets (Claude Code and Codex keep
* theirs). Shows where the CLI lives, its homepage and an Update link into the
* AI Tools modal; how many sessions it ran, their cost, and its lanes; a
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
* model flag; its vendor's pricing table; and the config files its catalog
* file lists, each opened over a sha and saved by the server only if it is
* still that sha, valid, and backed up. Antigravity adds its hooks card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { ModelPicker } from "../components/run/RunSetup";
import { PricingSettings } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** Cards one tool adds below the generic ones. */
const EXTRAS: Record<string, ComponentType> = { antigravity: AntigravityHooksSettings };
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** The default-model card: the picker, saved on an explicit click. */
function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const [value, setValue] = useState(saved);
const [error, setError] = useState<string | null>(null);
useEffect(() => setValue(saved), [saved]);
const save = async () => {
setError(null);
try {
await aiTools.update(toolId, { defaultModel: value || null });
} catch (err) {
setError(message(err));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-fg-muted">{t("toolTab.defaultModelHint")}</p>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[16rem] flex-1">
<ModelPicker value={value} onChange={setValue} tool={toolId} />
</div>
<button
type="button"
onClick={save}
disabled={value === saved}
className="btn-ghost text-xs disabled:opacity-50"
>
{t("toolTab.saveDefaultModel")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
);
}
/** The config-files card: pick a declared file, edit it, save over its sha. */
function ConfigFiles({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.aiTools
.config(toolId)
.then((r) => setFiles(r.files))
.catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]);
const openFile = async (f: AiToolConfigFile) => {
setNote(null);
try {
const r = await api.aiTools.readConfig(toolId, f.index);
setOpen({ index: f.index, path: r.path, sha: r.sha });
setText(r.text);
} catch (err) {
setNote({ ok: false, text: message(err) });
}
};
const save = async () => {
if (!open) return;
setBusy(true);
setNote(null);
try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha });
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) {
setNote({ ok: false, text: message(err) });
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3">
<p className="text-xs text-fg-muted">{t("toolTab.configHint")}</p>
<div className="flex flex-wrap gap-2">
{(files ?? []).map((f) => (
<button
key={f.index}
type="button"
onClick={() => openFile(f)}
disabled={!f.exists}
title={f.exists ? undefined : t("toolTab.missing")}
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] disabled:opacity-50 ${
open?.index === f.index
? "border-accent/50 text-fg-primary"
: "border-border text-fg-secondary hover:bg-surface-3"
}`}
>
{f.path}
</button>
))}
</div>
{open && (
<div className="space-y-2">
<textarea
aria-label={open.path}
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={16}
className="w-full rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] text-fg-primary focus:border-accent/50 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={save}
disabled={busy}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")}
</button>
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
{t("toolTab.close")}
</button>
</div>
</div>
)}
{note && (
<p className={`text-xs ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
)}
</div>
);
}
function Card({
title,
Icon,
children,
}: {
title: string;
Icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-fg-secondary">
<Icon className="h-4 w-4 text-fg-muted" />
{title}
</h3>
<div className="card p-5">{children}</div>
</section>
);
}
/** The tab for one catalog tool. */
export function AiToolConfig({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const tool = aiTools.byId(toolId);
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]);
useEffect(() => {
api.aiTools
.usage(toolId)
.then(setUsage)
.catch(() => setUsage(null));
api.lanes
.list()
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
.catch(() => setLanes([]));
}, [toolId]);
if (!tool) return null;
const Extra = EXTRAS[toolId];
return (
<div className="space-y-8">
<div className="card flex flex-wrap items-start gap-4 p-5">
<AiToolIcon tool={tool} className="h-10 w-10" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-base font-semibold text-fg-primary">{tool.label}</h2>
{tool.homepageUrl && (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t("aiTools.homepage")}
</a>
)}
{tool.update && (
<Link to={`/settings?tool=${toolId}#ai-tools`} className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
)}
</div>
<p className="break-all font-mono text-[11px] text-fg-muted">{tool.path}</p>
{usage && (
<p className="text-xs text-fg-secondary">
{t("toolTab.usage", { count: usage.sessions, cost: usage.cost.toFixed(2) })}
</p>
)}
{lanes.length > 0 && (
<p className="flex flex-wrap items-center gap-2 text-xs text-fg-secondary">
{t("toolTab.lanes")}
{lanes.map((l) => (
<Link
key={l.id}
to={`/workspace?lane=${l.id}`}
className="text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
))}
</p>
)}
</div>
</div>
{tool.modelFlag && (
<Card title={t("toolTab.defaultModel")} Icon={Cpu}>
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
</Card>
)}
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
{tool.configFiles > 0 && (
<Card title={t("toolTab.config")} Icon={FileCog}>
<ConfigFiles toolId={toolId} />
</Card>
)}
{Extra && <Extra />}
</div>
);
}
+16 -8
View File
@@ -7,7 +7,9 @@
* low-risk text-file surfaces (skills, agents, commands, output styles,
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
* hooks-in-settings, and settings.json files stay read-only - those have
* concurrent-write races with the live CLI.
* concurrent-write races with the live CLI. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -110,6 +112,7 @@ import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
import {
Empty,
SkeletonRows,
@@ -238,6 +241,8 @@ const EMPTY_STATE: PageState = {
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
@@ -505,7 +510,10 @@ export function CcConfig() {
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
}}
onOpenBackups={() => setBackupsOpen(true)}
onOpenProfiles={() => setProfilesOpen(true)}
wsConnected={wsConnected}
@@ -582,6 +590,12 @@ export function CcConfig() {
</div>
</div>
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal
@@ -1053,12 +1067,6 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
+50 -3
View File
@@ -8,7 +8,10 @@
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
* split between editable artifacts and CLI-driven surfaces. Codex has no
* "project-local" scope and no per-project file-based memory - its scope
* union is exactly "user" | "project".
* union is exactly "user" | "project". Its **Overview** also holds the
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -51,6 +54,9 @@ import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
import { PricingSettings } from "../components/settings/PricingSettings";
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
import {
Empty,
SkeletonRows,
@@ -141,7 +147,12 @@ interface NamedArtifactItem {
export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
const [hooksKey, setHooksKey] = useState(0);
const [scope, setScope] = useState<CodexScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
@@ -361,7 +372,10 @@ export function CodexConfig() {
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
}}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
/>
@@ -437,6 +451,40 @@ export function CodexConfig() {
</div>
</div>
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME
re-reads every list, since they all live under it. */}
{tab === "overview" && (
<div key={agenthubKey} className="space-y-8">
<HomeDirSetting
id="codex-home"
title={tSettings("codexHome.title")}
description={tSettings("codexHome.description")}
note={tSettings("codexHome.restartNote")}
placeholder={tSettings("codexHome.placeholder")}
load={() =>
api.settings.codexHome
.get()
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
}
save={(p) =>
api.settings.codexHome.set(p).then((r) => {
void fetchAll();
setHooksKey((k) => k + 1);
return {
value: r.codex_home,
envOwned: r.env_owned,
warning: r.routers.failed.length
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
: null,
};
})
}
/>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
</div>
)}
{backupsOpen && (
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
)}
@@ -770,7 +818,6 @@ function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow icon={FolderTree} label={t("overview.codexHome")} value={roots.codexHome} />
<RootRow
icon={FolderTree}
label={t("overview.projectCodexDir")}
+2
View File
@@ -85,6 +85,7 @@ import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { CopyButton } from "../components/event-views/primitives";
import {
effectiveSessionStatus,
@@ -589,6 +590,7 @@ export function SessionDetail() {
<h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
<SessionToolBadge tool={session.tool} />
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
+2
View File
@@ -72,6 +72,7 @@ import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
@@ -434,6 +435,7 @@ export function Sessions() {
<p className="text-sm font-medium text-fg-secondary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</p>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
+12 -789
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration.
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`). AI Tools is a modal opened from the tab bar (`SettingsHub.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -55,26 +55,19 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragment } from "react";
import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Plus,
Pencil,
Trash2,
Check,
X,
RefreshCw,
Database,
Plug,
HardDrive,
AlertTriangle,
RotateCcw,
CheckCircle,
HelpCircle,
MessageSquare,
XCircle,
Server,
Bell,
BellOff,
@@ -98,8 +91,6 @@ import {
Coins,
BarChart3,
Settings as SettingsIcon,
FolderOpen,
Info,
PiggyBank,
History,
ChevronLeft,
@@ -110,7 +101,7 @@ import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
import { fmt, fmtCost } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
import { notify } from "../lib/notify";
@@ -120,7 +111,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import type { ModelPricing, WSMessage } from "../lib/types";
import type { WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
// `<section id>` rendered below; the TOC scroll-spies the active one.
@@ -131,10 +122,7 @@ const SETTINGS_SECTIONS: {
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
{ id: "claude-home", labelKey: "claudeHome.title", Icon: FolderOpen },
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
{
id: "remote-sources",
@@ -151,44 +139,6 @@ const SETTINGS_SECTIONS: {
// ─── Helpers ───
interface EditRow {
model_pattern: string;
display_name: string;
input_per_mtok: string;
output_per_mtok: string;
cache_read_per_mtok: string;
cache_write_per_mtok: string;
cache_write_1h_per_mtok: string;
fast_input_per_mtok: string;
fast_output_per_mtok: string;
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
// values are ignored). Generic: any model can carry a promo window.
intro_until: string;
intro_input_per_mtok: string;
intro_output_per_mtok: string;
intro_cache_read_per_mtok: string;
intro_cache_write_per_mtok: string;
intro_cache_write_1h_per_mtok: string;
}
const emptyRow: EditRow = {
model_pattern: "",
display_name: "",
input_per_mtok: "0",
output_per_mtok: "0",
cache_read_per_mtok: "0",
cache_write_per_mtok: "0",
cache_write_1h_per_mtok: "0",
fast_input_per_mtok: "0",
fast_output_per_mtok: "0",
intro_until: "",
intro_input_per_mtok: "0",
intro_output_per_mtok: "0",
intro_cache_read_per_mtok: "0",
intro_cache_write_per_mtok: "0",
intro_cache_write_1h_per_mtok: "0",
};
interface SystemInfo {
db: { path: string; size: number; counts: Record<string, number> };
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
@@ -201,19 +151,6 @@ interface SystemInfo {
};
}
function formatTimestamp(iso: string): string {
const normalized =
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
const d = new Date(normalized);
return d.toLocaleString(getCurrentLocale(), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -303,121 +240,11 @@ function Toggle({
);
}
/**
* Info popover for the Model Pricing section. Hover or focus the icon to see a
* three-section explanation: how prices are applied, how pattern matching
* works, and a reminder that prices must be edited manually when Anthropic
* publishes new rates. All copy is i18n-driven (settings.pricing.tooltip.*).
*
* The popover is fixed-positioned and clamped to the viewport so it never
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
* the Workflows stat tooltips.
*/
function PricingInfoTooltip() {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const positionPopover = useCallback(() => {
const btn = buttonRef.current;
const pop = popoverRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const w = pop?.offsetWidth ?? 320;
const h = pop?.offsetHeight ?? 240;
const margin = 8;
let left = r.right - w; // right-align with the icon
if (left < margin) left = margin;
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
let top = r.bottom + 8;
if (top + h > window.innerHeight - margin) {
top = Math.max(margin, r.top - h - 8);
}
setPos({ left, top });
}, []);
useEffect(() => {
if (!open) return;
positionPopover();
const onScroll = () => positionPopover();
const onResize = () => positionPopover();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
const raf = requestAnimationFrame(positionPopover);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, positionPopover]);
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("pricing.tooltip.title")}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-3.5 h-3.5" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }}
>
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.howItWorks")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.howItWorksBody")}
</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.patternsTitle")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.patternsBody")}
</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.manualUpdates")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.manualUpdatesBody")}
</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")}
</p>
<p className="text-fg-secondary leading-snug">{t("pricing.tooltip.apiPricingBody")}</p>
</div>
)}
</>
);
}
// ─── Main component ───
export function Settings() {
const { t } = useTranslation("settings");
const [pricing, setPricing] = useState<ModelPricing[]>([]);
const [loading, setLoading] = useState(true);
const [editingPattern, setEditingPattern] = useState<string | null>(null);
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [totalCost, setTotalCost] = useState<number | null>(null);
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
@@ -437,11 +264,7 @@ export function Settings() {
const [abandonHours, setAbandonHours] = useState("24");
const [purgeDays, setPurgeDays] = useState("90");
const [gcDryRun, setGcDryRun] = useState(false);
const [claudeHome, setClaudeHomeState] = useState("");
const [claudeHomeInput, setClaudeHomeInput] = useState("");
const [claudeHomeSaving, setClaudeHomeSaving] = useState(false);
const [claudeHomeError, setClaudeHomeError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<string>("pricing");
const [activeSection, setActiveSection] = useState<string>("appearance");
const tocRef = useRef<HTMLDivElement | null>(null);
const [tocOverflow, setTocOverflow] = useState({ left: false, right: false });
@@ -501,17 +324,9 @@ export function Settings() {
const load = useCallback(async () => {
try {
const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([
api.pricing.list(),
api.pricing.totalCost(),
api.settings.info(),
api.settings.claudeHome.get(),
]);
setPricing(pricingRes.pricing);
const [costRes, infoRes] = await Promise.all([api.pricing.totalCost(), api.settings.info()]);
setTotalCost(costRes.total_cost);
setSysInfo(infoRes);
setClaudeHomeState(claudeHomeRes.claude_home);
setClaudeHomeInput(claudeHomeRes.claude_home);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
@@ -580,91 +395,6 @@ export function Settings() {
}
};
const startEdit = (rule: ModelPricing) => {
setAdding(false);
setEditingPattern(rule.model_pattern);
setEditRow({
model_pattern: rule.model_pattern,
display_name: rule.display_name,
input_per_mtok: String(rule.input_per_mtok),
output_per_mtok: String(rule.output_per_mtok),
cache_read_per_mtok: String(rule.cache_read_per_mtok),
cache_write_per_mtok: String(rule.cache_write_per_mtok),
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
fast_input_per_mtok: String(rule.fast_input_per_mtok),
fast_output_per_mtok: String(rule.fast_output_per_mtok),
intro_until: rule.intro_until ?? "",
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
});
};
const startAdd = () => {
setEditingPattern(null);
setAdding(true);
setEditRow({ ...emptyRow });
};
const cancelEdit = () => {
setEditingPattern(null);
setAdding(false);
setError(null);
};
const saveEdit = async () => {
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
setError(t("pricing.validationRequired"));
return;
}
const introUntil = editRow.intro_until.trim();
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
setError(t("pricing.introUntilInvalid"));
return;
}
setSaving(true);
setError(null);
try {
await api.pricing.upsert({
model_pattern: editRow.model_pattern.trim(),
display_name: editRow.display_name.trim(),
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
// Always send the intro block so the UI is authoritative for it: an
// empty date clears the promo, a valid date persists the intro rates.
intro_until: introUntil || null,
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
});
setEditingPattern(null);
setAdding(false);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally {
setSaving(false);
}
};
const deleteRule = async (pattern: string) => {
try {
await api.pricing.delete(pattern);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
}
};
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(null);
@@ -693,30 +423,12 @@ export function Settings() {
return t("danger.clearedResult", { count: total });
});
const handleReinstallHooks = () =>
runAction("hooks", async () => {
const res = await api.settings.reinstallHooks();
return res.ok ? t("hooks.success") : t("hooks.failed");
});
const handleSkillsInstall = () =>
runAction("skills-install", async () => {
const res = await api.settings.skillsInstall();
return t("lanesSection.installSkillSuccess", { path: res.path });
});
const handleGc = () =>
runAction("gc", async () => {
const res = await api.settings.gc({ dryRun: gcDryRun });
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
});
const handleResetPricing = () =>
runAction("reset-pricing", async () => {
const res = await api.settings.resetPricing();
return t("pricing.resetResult", { count: res.pricing.length });
});
const handleCleanup = () =>
runAction("cleanup", async () => {
const params: { abandon_hours?: number; purge_days?: number } = {};
@@ -734,188 +446,6 @@ export function Settings() {
return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean");
});
const handleSaveClaudeHome = async () => {
if (claudeHomeInput === claudeHome) return;
setClaudeHomeSaving(true);
setClaudeHomeError(null);
try {
const res = await api.settings.claudeHome.set(claudeHomeInput);
setClaudeHomeState(res.claude_home);
setClaudeHomeInput(res.claude_home);
} catch (err) {
setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed"));
} finally {
setClaudeHomeSaving(false);
}
};
const lastUpdated =
pricing.length > 0
? pricing.reduce(
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
pricing[0]!.updated_at
)
: null;
const isEditing = editingPattern !== null || adding;
const renderEditCells = () => (
<>
<td className="px-4 py-3">
<input
type="text"
value={editRow.model_pattern}
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
placeholder={t("pricing.patternPlaceholder")}
disabled={editingPattern !== null}
className="input w-full text-sm font-mono disabled:opacity-50"
autoFocus={adding}
/>
</td>
<td className="px-4 py-3">
<input
type="text"
value={editRow.display_name}
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
placeholder={t("pricing.namePlaceholder")}
className="input w-full text-sm"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_read_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_1h_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={saveEdit}
disabled={saving}
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
title={t("common:save")}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
title={t("common:cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
);
// Second edit row: the time-limited introductory-rate block. Rendered under
// the standard-rate cells whenever a row is being edited/added. Leaving the
// date empty means "no promo" — the rate inputs are then ignored. This is the
// ONLY place intro rates are entered, and it works for any model pattern (not
// just Sonnet 5), so a future model with a launch promo needs no code change.
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
{t(labelKey)}
</span>
<input
type={opts.date ? "text" : "number"}
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
value={editRow[key]}
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
/>
</label>
);
const renderIntroEditRow = () => (
<tr className="bg-surface-3">
<td colSpan={10} className="px-4 pb-3 pt-2">
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")}
</span>
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
</div>
<div className="flex flex-wrap items-end gap-3">
{introField("intro_until", "pricing.introUntil", { date: true })}
{introField("intro_input_per_mtok", "common:token.input")}
{introField("intro_output_per_mtok", "common:token.output")}
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
</div>
</div>
</td>
</tr>
);
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
@@ -1056,6 +586,12 @@ export function Settings() {
)}
</nav>
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{/* Cost summary card */}
<div className="card p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
@@ -1087,264 +623,6 @@ export function Settings() {
<AppearanceSettings />
{/* ─── MODEL PRICING ─── */}
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-fg-muted" />
{t("pricing.title")}
<PricingInfoTooltip />
</h3>
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
confirmAction === "reset-pricing"
? handleResetPricing()
: setConfirmAction("reset-pricing")
}
disabled={isEditing || actionLoading !== null}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmAction === "reset-pricing"
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
}`}
>
<RotateCcw className="w-3 h-3" />
{confirmAction === "reset-pricing"
? t("pricing.resetConfirm")
: t("pricing.resetDefaults")}
</button>
<button
onClick={startAdd}
disabled={isEditing}
className="btn-primary text-xs disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
</button>
</div>
</div>
{error && (
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{actionBanner(["reset-pricing"])}
<div className="card overflow-x-auto mt-4">
<table className="w-full min-w-[1000px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")}
</th>
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pricing.map((rule) =>
editingPattern === rule.model_pattern ? (
<Fragment key={rule.model_pattern}>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</Fragment>
) : (
<tr
key={rule.model_pattern}
className="hover:bg-surface-4 transition-colors group"
>
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
until: rule.intro_until,
})}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
)}
{adding && (
<>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</>
)}
</tbody>
</table>
</div>
{lastUpdated && (
<p className="text-xs text-fg-muted mt-3">
{t("pricing.lastUpdated")}
{formatTimestamp(lastUpdated)}
</p>
)}
</section>
{/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{sysInfo?.hooks.installed ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
</span>
)}
</div>
<button
onClick={handleReinstallHooks}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "hooks" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("hooks.reinstall")}
</button>
</div>
{actionBanner(["hooks"])}
{sysInfo && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => (
<div
key={hook}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{active ? (
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
) : (
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
)}
<span className="text-fg-secondary truncate">{hook}</span>
</div>
))}
</div>
<p className="text-[11px] text-fg-muted font-mono truncate">{sysInfo.hooks.path}</p>
</>
)}
</div>
</section>
{/* ─── LANES ─── */}
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
@@ -1354,23 +632,6 @@ export function Settings() {
<p className="text-xs text-fg-muted mb-4">{t("lanesSection.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<span className="text-xs text-fg-secondary">{t("lanesSection.installSkill")}</span>
<button
onClick={handleSkillsInstall}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "skills-install" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("lanesSection.installSkill")}
</button>
</div>
{actionBanner(["skills-install"])}
<div className="flex items-center justify-between flex-wrap gap-3">
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
@@ -1397,44 +658,6 @@ export function Settings() {
</div>
</section>
{/* ─── CLAUDE HOME ─── */}
<section id="claude-home" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{t("claudeHome.title")}
</h3>
<p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</p>
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
value={claudeHomeInput}
onChange={(e) => {
setClaudeHomeInput(e.target.value);
setClaudeHomeError(null);
}}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
placeholder={t("claudeHome.placeholder")}
/>
<button
onClick={handleSaveClaudeHome}
disabled={claudeHomeSaving || claudeHomeInput === claudeHome}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
</button>
</div>
{claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
{claudeHome && (
<p className="text-xs text-fg-muted">
{t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
</p>
)}
</div>
</section>
{/* ─── IMPORT HISTORY ─── */}
<section id="import" className="scroll-mt-24">
<ImportHistory />
+86 -12
View File
@@ -1,24 +1,42 @@
/**
* @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) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, 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 have their own; every other installed, enabled catalog tool gets the generic `AiToolConfig` tab, Antigravity's with its hooks card) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import {
Bot,
Boxes,
KeyRound,
Orbit,
Puzzle,
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 { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolsModal } from "../components/settings/AiToolsModal";
interface ToolTab {
id: string;
labelKey: string;
labelKey?: string;
/** A catalog tool's own label, used instead of `labelKey`. */
label?: string;
Icon: ComponentType<{ className?: string }>;
Panel: ComponentType;
Panel?: ComponentType;
/** Set on a generic catalog-tool tab: rendered as `<AiToolConfig toolId>`. */
toolId?: string;
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
aiTool?: string;
}
const DASHBOARD_TAB: ToolTab = {
@@ -31,8 +49,20 @@ const DASHBOARD_TAB: ToolTab = {
/** Add a tool here to give it a tab; that is the whole extension point. */
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: "claude-code",
labelKey: "tools.claudeCode",
Icon: Boxes,
Panel: CcConfig,
aiTool: "claude",
},
{
id: "codex",
labelKey: "tools.codex",
Icon: SquareTerminal,
Panel: CodexConfig,
aiTool: "codex",
},
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{
id: "git-credentials",
@@ -50,7 +80,35 @@ export function SettingsHub() {
const { t } = useTranslation("settings");
const [params, setParams] = useSearchParams();
const requested = params.get("tool");
const active = TOOL_TABS.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const aiTools = useAiTools();
const fixed = TOOL_TABS.filter(
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
// Every other installed, enabled tool, after Codex; a fixed tab's id wins.
const catalogTabs: ToolTab[] = aiTools.tools
.filter((x) => x.installed && x.enabled && !x.launcher && !fixed.some((f) => f.id === x.id))
.map((x) => ({
id: x.id,
label: x.label,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
toolId: x.id,
}));
const at = fixed.findIndex((tab) => tab.id === "ai-router");
const tabs = [...fixed.slice(0, at), ...catalogTabs, ...fixed.slice(at)];
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation();
const navigate = useNavigate();
const [aiToolsOpen, setAiToolsOpen] = useState(false);
useEffect(() => {
if (location.hash === "#ai-tools") setAiToolsOpen(true);
}, [location.hash]);
const closeAiTools = useCallback(() => {
setAiToolsOpen(false);
// Drop the deep link, or a reload would open the modal again.
if (location.hash) navigate({ search: location.search }, { replace: true });
}, [location.hash, location.search, navigate]);
return (
<div className="space-y-6">
@@ -58,14 +116,30 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
tabs={tabs.map(({ id, labelKey, label, Icon }) => ({
id,
Icon,
label: t(labelKey),
label: label ?? t(labelKey ?? id),
}))}
actions={
<button
type="button"
onClick={() => setAiToolsOpen(true)}
aria-haspopup="dialog"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Bot className="h-3.5 w-3.5" />
{t("aiTools.title")}
</button>
}
/>
<active.Panel />
{active.toolId ? (
<AiToolConfig key={active.toolId} toolId={active.toolId} />
) : (
active.Panel && <active.Panel />
)}
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
</div>
);
}
+81 -47
View File
@@ -56,7 +56,7 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import {
@@ -175,7 +175,7 @@ function ConsoleArea({
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
@@ -296,7 +296,7 @@ export function Workspace() {
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<Record<
LaneTool,
string,
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
@@ -394,13 +394,25 @@ export function Workspace() {
);
}, [lanes, searchParams, setSearchParams]);
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
// installed since the page loaded clears its missing-binary banner. Claude is
// always probed: the console's no-lane state runs it.
const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")]))
.sort()
.join(",");
useEffect(() => {
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
const tools = laneToolsKey.split(",");
Promise.all(tools.map((tool) => api.run.binary(tool)))
.then((found) =>
setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!])))
)
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// a CLI is missing from PATH — leave the probe unresolved rather than
// showing a misleading "binary missing" banner for an unrelated fault.
.catch(() => undefined);
}, [laneToolsKey]);
useEffect(() => {
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
@@ -489,7 +501,9 @@ export function Workspace() {
.then((r) => setActiveRuns(keepIfSame(r)))
.catch(() => undefined);
api.sessions
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
// Attach/resume go through `claude`, so a Codex session is not offered
// (filtered by the server, before its limit).
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" })
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
.catch(() => undefined);
}, []);
@@ -523,6 +537,10 @@ export function Workspace() {
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks),
// and only while a template is installed — none ships, so it is dormant.
const noPipeline =
!!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0);
// Feature list follows the selected lane, resets the viewer on lane switch.
useEffect(() => {
@@ -815,40 +833,54 @@ export function Workspace() {
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{/* A lane with no pipeline (non-claude, or no template
installed): no time-at-stage, no pipeline or feature
picker, no map. */}
{!noPipeline && (
<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>
{!noPipeline && (
<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>
)}
{/* `agy` has no endpoint to point at a router. */}
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{features.length > 0 && (
{!noPipeline && features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
@@ -867,7 +899,7 @@ export function Workspace() {
</>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (agents install, mcp sync, sync-base check)
{/* Per-lane actions (mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
<button
@@ -908,19 +940,21 @@ export function Workspace() {
onSelectLane={setSelectedLaneId}
/>
</div>
<div className="mb-3">
<PipelineMap
nodes={
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{!noPipeline && (
<div className="mb-3">
<PipelineMap
nodes={
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
)}
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
@@ -0,0 +1,141 @@
/**
* @file AiToolConfig.test.tsx
* @description The generic per-tool Settings tab: the tool's path, homepage
* and an Update link into the AI Tools modal; its session count, cost and
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
* catalog-declared config files, edited over the sha they were opened at with
* the server's refusal shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
vi.mock("../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn(),
update: vi.fn(),
models: vi.fn(),
usage: vi.fn(),
config: vi.fn(),
readConfig: vi.fn(),
saveConfig: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
lanes: { list: vi.fn() },
},
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
id: "gemini",
label: "Gemini",
command: "gemini",
homepageUrl: "https://github.com/google-gemini/gemini-cli",
installed: true,
path: "/usr/bin/gemini",
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
defaultModel: null,
modelVendor: "google",
configFiles: 2,
};
beforeEach(() => {
__resetAiTools();
const view = { default: "claude", tools: [GEMINI] };
vi.mocked(api.aiTools.list)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.update)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.models)
.mockReset()
.mockResolvedValue({
flag: true,
models: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
defaultModel: null,
});
vi.mocked(api.aiTools.usage).mockReset().mockResolvedValue({ sessions: 3, cost: 1.5 });
vi.mocked(api.aiTools.config)
.mockReset()
.mockResolvedValue({
files: [
{ index: 0, path: "/h/.gemini/settings.json", format: "json", exists: true, size: 2 },
{ index: 1, path: "/h/.gemini/GEMINI.md", format: "text", exists: false, size: null },
],
});
vi.mocked(api.aiTools.readConfig)
.mockReset()
.mockResolvedValue({ path: "/h/.gemini/settings.json", format: "json", text: "{}", sha: "s1" });
vi.mocked(api.aiTools.saveConfig).mockReset();
vi.mocked(api.lanes.list)
.mockReset()
.mockResolvedValue({
lanes: [
{ id: 7, tool: "gemini", title: "web", cwd: "/w" },
{ id: 8, tool: "claude", title: "other", cwd: "/o" },
],
} as never);
});
const renderTab = () =>
render(
<MemoryRouter>
<AiToolConfig toolId="gemini" />
</MemoryRouter>
);
describe("AiToolConfig", () => {
it("shows the tool, its usage, its lanes and its vendor's pricing", async () => {
renderTab();
expect(await screen.findByText("/usr/bin/gemini")).toBeInTheDocument();
expect(await screen.findByText(/3 sessions/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "/workspace?lane=7");
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
"href",
"/settings?tool=gemini#ai-tools"
);
expect(screen.getByText("pricing:google")).toBeInTheDocument();
});
it("saves the default model", async () => {
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() =>
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { defaultModel: "gemini-3.1-pro" })
);
});
it("edits a config file over the sha it opened, and shows a refusal", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(new Error("not valid JSON"));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
expect(screen.getByRole("button", { name: "/h/.gemini/GEMINI.md" })).toBeDisabled();
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{nope" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
});
@@ -46,6 +46,27 @@ const codexConfigMock = vi.hoisted(() => {
};
});
const codexHomeMock = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
const codexHooksMock = vi.hoisted(() => ({
get: vi.fn(() =>
Promise.resolve({
supported: true,
readable: true,
path: "/home/.codex/hooks.json",
script: "/home/.codex/agenthub-hook.sh",
installed: false,
trusted: false,
events: {},
})
),
install: vi.fn(),
remove: vi.fn(),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -53,6 +74,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
...(actual.api as Record<string, unknown>),
codexConfig: codexConfigMock,
settings: {
...((actual.api as { settings: Record<string, unknown> }).settings ?? {}),
codexHome: codexHomeMock,
codexHooks: codexHooksMock,
},
},
};
});
@@ -97,6 +123,34 @@ afterEach(() => {
});
describe("CodexConfig", () => {
it("edits CODEX_HOME on the Overview and reports lanes whose router layer failed", async () => {
stubCodexApi({});
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
codexHomeMock.set.mockResolvedValue({
ok: true,
codex_home: "/work/.codex",
env_owned: false,
routers: { updated: 1, failed: ["4 (api): EDIR"] },
});
render(<CodexConfig />);
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
// No AgentHub sub-tab: its cards sit under the Overview, the page's first tab.
expect(screen.queryByRole("tab", { name: /agenthub/i })).toBeNull();
const input = await screen.findByDisplayValue("/home/.codex");
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
await userEvent.clear(input);
await userEvent.type(input, "/work/.codex");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex");
expect(await screen.findByText(/4 \(api\): EDIR/)).toBeInTheDocument();
// Every list lives under CODEX_HOME, so a new one re-reads them.
expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls);
});
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
@@ -41,7 +41,7 @@ const lane = (id: number, title: string): LaneFixture => ({
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -205,10 +205,10 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(await screen.findByDisplayValue("Native (Claude)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await user.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
@@ -216,7 +216,7 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
// Back to the lane that was never switched. It is still native server-side,
// so the picker must say so — the row is one instance shared by every lane.
await selectLane(user, "Lane A");
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
// And nothing may have been written to lane A on the way.
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
});
@@ -58,6 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
File diff suppressed because it is too large Load Diff
@@ -590,6 +590,38 @@ describe("screen snapshots", () => {
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
describe("Claude Code's AgentHub settings", () => {
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
const dashboard = render(
<MemoryRouter initialEntries={["/settings"]}>
<Settings />
</MemoryRouter>
);
await settle();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(dashboard.container.querySelector(`section#${id}`)).toBeNull();
}
dashboard.unmount();
const { container } = render(
<MemoryRouter initialEntries={["/cc-config"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
fireEvent.click(screen.getByTestId("cc-tab-skills"));
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
});
});
// The page opens on Overview; the plugin cards live behind the Plugins tab.
async function openPluginsTab() {
render(
+42 -9
View File
@@ -135,7 +135,7 @@ POST /api/lanes/ensure
```
```json
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature", "tool": "codex" }
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "default", "tool": "codex" }
```
`cwd` is optional — when missing or empty the server falls back to
@@ -192,14 +192,22 @@ slugified from its title once, at creation.
`pipeline` is patchable, which is how a lane moves between templates after
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
to the default template when READING, so an unvalidated write would be
rejected with `400 EBADPIPELINE` rather than stored (`default` is always
accepted): `getPipeline` falls back to the default template when READING, so an unvalidated write would be
accepted and then silently render the wrong map forever. Same 400 treatment as
`EBADKIND`. Switching a template re-resolves the lane's existing declared
`stage` string against the new node list — a stage the old template knew may
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
until the next `agenthub stage`.
**Pipelines are dormant until a template is installed.** AgentHub ships no
pipeline template; templates come only from `DASHBOARD_PIPELINES_DIR` (see
`docs/LANES.md#custom-pipeline-templates`). With none installed, every lane is
on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
`GET /api/lanes/pipelines` returns `{ "pipelines": [] }`, and
`POST /api/lanes/:id/stage` answers
`400 { "error": { "code": "ENOPIPELINE", "message": "no pipeline template is installed, so this lane has no stages to declare (drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)", "stages": [] } }`.
#### Provision a managed worktree
```http
@@ -586,8 +594,8 @@ cannot collide on `index.lock`.
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
@@ -1038,7 +1046,7 @@ every number. `status` filters by session lifecycle, same convention as
```json
{
"skills": [
{ "name": "agenthub:ship-feature", "plugin": "agenthub", "count": 12, "sessions": 5 },
{ "name": "agenthub:repo-onboarding", "plugin": "agenthub", "count": 12, "sessions": 5 },
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
],
"plugins": [
@@ -2061,20 +2069,22 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary?tool= { found, path } for the `claude` or `codex` binary
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
```
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
**`GET /api/run/binary`:** Probes PATH for a lane tool's CLI, looked up in the AI Tools catalog (`?tool=<id>`, default `claude`; an unknown id is `400 EBADTOOL`). Always a fresh look, not the catalog's cached detection. Returns `{ found: bool, path: string | null }`.
**`bypass: true`** (both `POST /api/run` and `POST /api/lanes/:id/start`) appends the lane tool's catalog bypass args/env for that run only; Codex's replaces `-s`/`-a`, Claude has none. A lane whose tool is no longer in the catalog answers `400 ENOTOOL`.
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
@@ -2086,6 +2096,29 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
---
### AI Tools
```
GET /api/ai-tools Catalog + detection — { tools: [...], default }
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced
GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
GET /api/ai-tools/:id/config/:i { path, format, text, sha }
PUT /api/ai-tools/:id/config/:i Body: { text, baseSha } → { sha, backup }
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
GET /api/ai-tools/job The current or last job, or null
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
```
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`.
### AI Router profiles
```
+5 -10
View File
@@ -232,22 +232,17 @@ A lane is a durable unit of parallel agent work — one working directory, many
| Command | Description |
| ------- | ----------- |
| `agenthub lanes` | List lanes with stage, status, liveness and progress |
| `agenthub lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
| `agenthub lanes add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a lane; `--tool` is an AI Tools id (default `claude`) |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new lane |
| `agenthub lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
| `agenthub lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
| `agenthub ship-full "<requirement>" [<id>] [--print]` | **The 16-stage lane pipeline.** Puts the lane on the `ship-feature` template and starts a `claude` session running its skill (`/agenthub:ship-feature-lane`): implement → gates → e2e → review → local QC → senior GO/NO-GO → push branch + open PR, then watches the PR until a human merges it. Use when the work should end in a reviewable PR |
| `agenthub ship-quick "<requirement>" [<id>] [--print]` | **The 8-stage in-repo pipeline.** Puts the lane on the `default` template and runs its skill (`/agenthub:ship-feature`): plan → implement → tests → review → gate → ship. No PR, no browser QC. Use for work that stays in the working copy |
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. With no pipeline template installed (AgentHub ships none — see [Custom pipeline templates](LANES.md#custom-pipeline-templates)) it answers `400 ENOPIPELINE`. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
| `agenthub feature list [<id>]` | List every feature this lane has activated, archived or live |
| `agenthub feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
| `agenthub feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
`agenthub ship-full` / `agenthub ship-quick` both resolve the lane from the working directory (or take a lane id), pass the requirement to `claude` as one argv element — never through a shell — and accept `--print`, which shows the exact command, switches no template and runs nothing. The skill string itself lives in the template's `skill` field; a template without one makes the command refuse rather than guess.
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.agenthub/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
| Command | Description |
@@ -264,7 +259,7 @@ A lane is a durable unit of parallel agent work — one working directory, many
Omit `<id>` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub lanes logs web --tail 4096` addresses the lane by directory, not lane 4096.
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool claude|codex`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Because a lane's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
+49 -4
View File
@@ -66,6 +66,7 @@ graph TB
**Database Location:**
- **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
- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `<CLAUDE_HOME>/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → Overview first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone
- **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.
@@ -171,7 +172,7 @@ graph LR
Tracks Claude Code sessions (one per CLI invocation or background task). Schema mirrors `server/db.js`.
> **Cursor (informational):** Rows imported from `~/.claude` JSONL transcripts may also represent **Cursor** agent sessions — Cursor happens to use the same on-disk layout as Claude Code. The schema does not record which app created a session.
> **Cursor (informational):** Rows imported from `~/.claude` JSONL transcripts may also represent **Cursor** agent sessions — Cursor happens to use the same on-disk layout as Claude Code, so such a row still reads `tool = 'claude'`.
```sql
CREATE TABLE sessions (
@@ -188,7 +189,8 @@ CREATE TABLE sessions (
awaiting_input_since TEXT, -- NULL unless Waiting
awaiting_reason TEXT, -- notification|stop|session_start|interrupted, or NULL
transcript_path TEXT, -- absolute path to JSONL transcript
source TEXT NOT NULL DEFAULT 'local' -- data source: 'local' or a remote_sources.id
source TEXT NOT NULL DEFAULT 'local', -- data source: 'local' or a remote_sources.id
tool TEXT NOT NULL DEFAULT 'claude' -- 'claude', 'codex' or 'antigravity'
);
```
@@ -200,7 +202,7 @@ CREATE TABLE sessions (
| `name` | TEXT | YES | Human-readable label. Synced from the transcript title by `routes/hooks.js` (and the 15 s watchdog) on every event: the `custom-title` line (`/rename`, `claude -n`, picker `Ctrl+R`) always wins, otherwise the auto-generated `ai-title` fills a placeholder/auto name, otherwise the session's first user prompt (60-char label) fills it. Falls back to `Session <id8>` |
| `status` | TEXT | NO | `active`, `completed`, `error`, or `abandoned` (CHECK-constrained). Besides the `SessionEnd` hook, the 15 s watchdog's **liveness reap** also lands `active` → `completed` when no running `claude` process has the session's `cwd` (a `SessionEnd` lost while the dashboard was down); gated by `DASHBOARD_LIVENESS_IDLE_SECONDS`, disabled via `DASHBOARD_LIVENESS_PROBE=0`. Sessions with a non-`local` `source` (Remote Data Sources) are exempt from the reap and both stale sweeps — their status is reconciled from the SSH mirror by `remote-sync.js` instead |
| `cwd` | TEXT | YES | Working directory the CLI was launched from |
| `model` | TEXT | YES | Claude model ID (e.g. `claude-opus-4-7`) |
| `model` | TEXT | YES | Model ID as the CLI reports it — a Claude id (e.g. `claude-opus-4-7`), an OpenAI one for a Codex session (`gpt-…`), a Gemini one for an Antigravity session (`gemini-…`) |
| `started_at` | TEXT | NO | ISO 8601 timestamp |
| `ended_at` | TEXT | YES | ISO 8601 timestamp on terminal transition |
| `metadata` | TEXT | YES | JSON blob for extras (turn duration totals, thinking blocks, …) |
@@ -209,6 +211,7 @@ CREATE TABLE sessions (
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a lane purge touch only the matching tool's rows (a purge takes the sessions of the lane's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
**Constraints:**
- `status` must be one of the four enum values
@@ -391,7 +394,7 @@ CREATE TABLE notifications (
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
@@ -413,6 +416,7 @@ CREATE TABLE model_pricing (
intro_cache_write_per_mtok REAL NOT NULL DEFAULT 0,
intro_cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0,
intro_until TEXT, -- promo cutoff YYYY-MM-DD, or NULL
source TEXT, -- 'sync' | 'user' | NULL (from before the column)
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
@@ -428,10 +432,13 @@ CREATE TABLE model_pricing (
| `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) |
| `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields |
| `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings |
| `source` | TEXT | YES | `sync` = kept at LiteLLM's rate by the automatic sync (shown **Auto** in Settings), `user` = edited in Settings and never touched by the sync, NULL = from before this column (adopted as `sync` only while it equals the list) |
| `updated_at` | TEXT | NO | ISO8601 timestamp of the last edit |
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.
`model_pricing_dismissed (model_pattern TEXT PRIMARY KEY)` holds the pattern of every rule deleted through `DELETE /api/pricing/:pattern`, so the sync never adds it back; a reset empties it for the scope it reseeds.
**Example default rules:**
| Pattern | Input | Output | Cache read | Fast in/out |
@@ -491,6 +498,42 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
---
### codex_rollouts
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
```sql
CREATE TABLE codex_rollouts (
path TEXT PRIMARY KEY, -- absolute journal path
session_id TEXT NOT NULL, -- ROOT session (a sub-agent thread's parent)
thread_id TEXT NOT NULL, -- the journal's own thread
size INTEGER NOT NULL DEFAULT 0,
mtime_ms INTEGER NOT NULL DEFAULT 0,
offset INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT '{}', -- JSON: meta, current model, last total, per-model usage sums
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_codex_rollouts_session ON codex_rollouts(session_id);
CREATE INDEX idx_codex_rollouts_thread ON codex_rollouts(thread_id);
```
A session's `token_usage` rows are the sum of `state.usage` over every row with its `session_id`, rewritten whole on every change (delete, then `stmts.setTokenUsage`), bucketed `standard`/`global`/`standard`.
### antigravity_conversations
Which Antigravity CLI conversation files `server/lib/antigravity-usage.js` has read (`~/.gemini/antigravity-cli/conversations/<conversation id>.db`, agy's own SQLite), at what mtime — the newer of the file's and its non-empty `-wal`'s (an empty one is what AgentHub's own read-only open leaves). A file whose mtime is unchanged is not opened again. One that is not agy's (`SQLITE_NOTADB`, `SQLITE_CORRUPT`, `no such table`) is recorded with `session_id` `''`, so it is tried again only once it changes or at the next server start (`startAntigravityUsagePoll` deletes the `''` rows); any other read failure (a busy file, AgentHub's own error) is recorded nowhere and retried on the next pass; a changed one is re-read whole (they are small) and its session's `token_usage` rewritten whole, per model (`gen_metadata`'s `ModelUsageStats`: input without cache reads, output with thinking, cache read, cache write). As with `codex_rollouts`, a session the user deleted stays gone until agy writes to that conversation again.
```sql
CREATE TABLE antigravity_conversations (
path TEXT PRIMARY KEY, -- absolute conversation file path
session_id TEXT NOT NULL, -- the file name's id = the hooks' conversationId; '' = not agy's file
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
---
## Indexes
### sessions Indexes
@@ -565,6 +608,8 @@ CREATE INDEX idx_notifications_session_id ON notifications(session_id);
## Migrations
Every migration in `server/db.js` runs at module load as a check-then-act (probe a column, `ALTER` when missing; rebuild a table whose old constraint is still there). Two dashboards may start on one database at the same instant, so the whole section — the legacy-DB copy, `CREATE TABLE IF NOT EXISTS`, every probe and rebuild, the pricing top-up — runs under a cross-process lock: a `BEGIN IMMEDIATE` on the separate file `<db>-migrate.lock` (busy timeout 120 s), committed just before the prepared statements are built. The lock is a separate file because several rebuilds open their own `BEGIN` and switch `foreign_keys` off, which a surrounding transaction on the main database would break. A process that dies holding it releases it with its file handle.
### Schema Versioning
```mermaid
+9 -2
View File
@@ -251,10 +251,17 @@ DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN
# Database
DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
# Ingestion polls (0 disables either one)
# Ingestion polls (0 disables any one of them)
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane stage detection
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle + Codex sessions/tokens
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
# Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
AGENTHUB_SETTINGS_FILE= # where dashboard-saved settings persist (default ~/.config/agenthub/settings.env);
# load order: real env > the checkout's .env > this file
# Logging
LOG_LEVEL=info # debug | info | warn | error
+3 -1
View File
@@ -585,7 +585,9 @@ function sendToServer(hookType, payload) {
> 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 two
> instances share `~/.claude/agenthub`).
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the lanes those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> browsers see a lane's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`.
>
> Dashboards with **different** databases (e.g. the packaged desktop app using
+14 -27
View File
@@ -2,7 +2,7 @@
This guide documents how localization works in the Agent Dashboard, including architecture, resources, runtime behavior, testing, and rollout.
**Supported languages:** English (`en`), Chinese (`zh`), Vietnamese (`vi`), Korean (`ko`)
**Supported languages:** English (`en`), Vietnamese (`vi`)
---
@@ -36,7 +36,7 @@ flowchart TB
```
**Key runtime facts**
- `supportedLngs`: `["en", "zh", "vi", "ko"]`
- `supportedLngs`: `["en", "vi"]`
- `fallbackLng`: `"en"`
- `nonExplicitSupportedLngs`: `true` (e.g. `vi-VN` resolves to `vi`)
- Detection order: `localStorage` → `navigator`
@@ -48,9 +48,7 @@ flowchart TB
Translation resources are stored per language and namespace:
- `client/src/i18n/locales/en/*.json`
- `client/src/i18n/locales/zh/*.json`
- `client/src/i18n/locales/vi/*.json`
- `client/src/i18n/locales/ko/*.json`
Active namespaces:
- `common`
@@ -71,8 +69,8 @@ erDiagram
KEY ||--o{ TRANSLATION : maps_to
LANGUAGE {
string code "en|zh|vi"
string locale "en-US|zh-CN|vi-VN|ko-KR"
string code "en|vi"
string locale "en-US|vi-VN"
}
NAMESPACE {
string name "common|nav|dashboard|..."
@@ -89,7 +87,7 @@ erDiagram
**Strategy notes**
- Keep namespace boundaries page/domain focused.
- Keep key parity across `en`, `zh`, `vi` files for the same namespace.
- Keep key parity across `en` and `vi` files for the same namespace.
- Keep fallback behavior deterministic by ensuring `en` is always complete.
---
@@ -116,7 +114,7 @@ Use stable semantic keys, not English sentence literals.
```mermaid
classDiagram
class I18nConfig {
+supportedLngs: ["en","zh","vi"]
+supportedLngs: ["en","vi"]
+fallbackLng: "en"
+defaultNS: "common"
+detectionOrder: ["localStorage","navigator"]
@@ -184,19 +182,11 @@ sequenceDiagram
stateDiagram-v2
[*] --> Detecting
Detecting --> Loaded_en: localStorage/navigator resolves en
Detecting --> Loaded_zh: localStorage/navigator resolves zh
Detecting --> Loaded_vi: localStorage/navigator resolves vi
Detecting --> Loaded_ko: localStorage/navigator resolves ko
Detecting --> Loaded_en: unsupported locale -> fallback en
Loaded_en --> Loaded_zh: user switches to zh
Loaded_en --> Loaded_vi: user switches to vi
Loaded_zh --> Loaded_en: user switches to en
Loaded_zh --> Loaded_vi: user switches to vi
Loaded_vi --> Loaded_en: user switches to en
Loaded_vi --> Loaded_zh: user switches to zh
Loaded_en --> Loaded_ko: user switches to ko
Loaded_ko --> Loaded_en: user switches to en
```
---
@@ -205,14 +195,13 @@ stateDiagram-v2
Formatting utilities are centralized in `client/src/lib/format.ts`.
- `en` → `en-US`
- `zh` → `zh-CN`
- `en` → `en-US` (also the default for any other language)
- `vi` → `vi-VN`
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting.
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Settings page (which shows raw patterns for pricing rule configuration).
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → Overview, Settings → Codex → Overview and Settings → Antigravity), which shows raw patterns for pricing rule configuration.
```mermaid
flowchart LR
@@ -220,10 +209,8 @@ flowchart LR
B --> C["getCurrentLanguage()"]
C --> D{"Language"}
D -->|en| E["Locale en-US"]
D -->|zh| F["Locale zh-CN"]
D -->|vi| G["Locale vi-VN"]
E --> H["toLocaleTimeString / toLocaleString"]
F --> H
G --> H
H --> I["Localized date/time/number output"]
```
@@ -248,10 +235,10 @@ npm run test:client
| Area | What to verify | Example |
|---|---|---|
| Resource parity | Same key coverage across `en/zh/vi/ko` | Missing key detection in CI |
| Resource parity | Same key coverage across `en/vi` | Missing key detection in CI |
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
| Date/number formatting | Locale-specific output shape | `zh-CN`, `vi-VN`, `ko-KR` formatting |
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
| Runtime switching | UI rerenders without reload | Sidebar language toggle |
---
@@ -261,7 +248,7 @@ npm run test:client
| Symptom | Likely cause | Resolution |
|---|---|---|
| UI stays in old language after switch | Cached key or stale component state | Confirm `i18n.changeLanguage(...)` is called and component uses `useTranslation` |
| Unexpected fallback to English | Unsupported locale code | Ensure code normalizes to `en|zh|vi|ko` and key exists in target namespace |
| Unexpected fallback to English | Unsupported locale code | Ensure code normalizes to `en|vi` and key exists in target namespace |
| Missing text on one page | Namespace file key missing | Add key to all language files for that namespace |
| Date/time looks wrong | Locale mapping or timezone parse issue | Verify `getCurrentLocale()` and `parseDate()` behavior |
| Inconsistent term translation | Manual translation drift | Enforce glossary and update locale tests |
@@ -278,7 +265,7 @@ gantt
section Resource Preparation
Lock key inventory :a1, 2026-01-01, 3d
Fill en/zh/vi/ko namespace files :a2, after a1, 5d
Fill en/vi namespace files :a2, after a1, 5d
section Runtime Integration
Wire detection + persistence :b1, after a2, 2d
@@ -295,10 +282,10 @@ gantt
```
### Operational checklist
- [ ] Confirm all namespaces exist for `en`, `zh`, `vi`, `ko`
- [ ] Confirm all namespaces exist for `en` and `vi`
- [ ] Confirm key parity across all locale JSON files
- [ ] Confirm language switching works in collapsed and expanded sidebar modes
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `zh-CN`)
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
- [ ] Confirm date/time/currency formatting for all supported languages
- [ ] Confirm client tests pass before release
- [ ] Confirm docs references are updated (`README`, `ARCHITECTURE`, `docs/README`)
+180 -437
View File
@@ -32,9 +32,9 @@ agenthub lanes add --repo /path/to/repo --title "My Feature" --base main --slug
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
Adopting your main repo (`agenthub lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
Adopting your main repo (`agenthub lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `agenthub lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub lanes agents install`, and `agenthub lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `agenthub lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub lanes mcp sync`, and the AI Router pick. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
### The "+ Add lane" modal
@@ -96,7 +96,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
### How Codex arguments map
@@ -116,39 +116,16 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
- **Cost** surface — no per-run or per-model pricing data
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `agenthub stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**These are not bugs; they reflect Codex's architecture.** Liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**Stage detection is not in that list.** It does work for a codex lane, from
Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
lane a written detection there also **advances the lane's declared `stage`** —
see "Stage detection" below. What a codex lane cannot get for free is
*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never
`done`, because only the session itself can say what it actually finished. The
run console never pre-fills a Claude slash command into a codex pane (Codex
would read `/agenthub:ship-feature-lane` as prose), so a codex session declares
evidence only when something tells it to. Three things do:
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
first prompt with a plain-text list of `agenthub stage <stage> --tool codex`
calls to make, one per node of the lane's own pipeline template (built from
`Lane.pipeline_nodes`, so it always matches whatever template is in use) —
the same up-front priming a Claude lane gets from its pre-filled
`/agenthub:ship-feature-lane` skill command, just as plain text since Codex has
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
- **An `AGENTS.md` block.** `agenthub lanes agents install` on a codex lane writes
a marker-delimited `<!-- agenthub:stage:start -->` section into
`<lane cwd>/AGENTS.md` — the one instruction channel Codex actually reads,
since it never loads `.claude/agents/`. Appended, never overwriting your own
prose, and a reinstall replaces exactly that block. (On a `claude` lane the
same command still copies the two agent templates instead.)
- **A missing-evidence nudge.** `server/lib/codex-nudge.js` sweeps every 60s
(`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose
current stage has been held for at least two minutes with no evidence
recorded, and types a one-line `[agenthub] ... run: agenthub stage ... --evidence
"..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
skipped if the pane is at a bare shell prompt). It never writes `stage`,
`stages` or `detected_stage` itself — the evidence that results is a real
declaration the session made.
**Codex lanes carry no pipeline.** Nothing infers their stage and nothing types
a stage reminder into their pane. The one thing AgentHub reads from Codex is
its running/idle status — see "Stage detection" for the journal poll that
mirrors it. Every server start also wipes what older builds left behind on a
codex lane — its stage columns, stage log (`lane_stage_events`) and feature
records (`lane_features`) — and takes the old
`<!-- agenthub:stage:start -->` block back out of its `AGENTS.md` (deleting
the file if nothing else is in it).
## Destructive lane actions
@@ -187,7 +164,7 @@ These checks run unconditionally inside `resetWorktree`/`removeWorktree` themsel
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits.
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories, recorded by the lane's own `tool` — purging a Claude lane leaves a Codex lane's sessions at the same directory alone. No git operation, no worktree change; not gated on unpushed commits.
### The preflight contract
@@ -211,7 +188,7 @@ Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` objec
### Environment variables
- **`LANES_ROOT`** (default `~/.claude/agenthub-lanes`) — the directory managed worktrees are provisioned under; also the boundary that safety check 2 resolves paths against.
- **`LANE_DEAD_SEC`** (default `300`) — seconds of silence before a lane whose stage matches `/watch|poll/i` flips from `active` to `dead` liveness.
- **`LANE_DEAD_SEC`** (default `300`) — seconds of silence before a lane whose stage matches `/watch|poll/i` (or whose status is `running`) flips from `active` to `dead` liveness. Silence is counted from the newer of the lane session's last event and the start of the lane's current run (`dashboard_runs.started_at`), so a run started a moment ago is never judged by the previous session's quiet. A lane no hook ever binds a session to (a catalog tool, Codex without its hooks) has no silence to measure and stays `active`.
- **`LANE_BASE_BRANCH`** (default `main`) — the base branch a new managed worktree resolves against when a request omits `base`. An explicit `--base <branch>` (or request body `base`) always wins over this default.
- **`LANE_BRANCH_PREFIX`** (default `feat/`) — the prefix used to build a new managed worktree's feature branch name (`<prefix><slug>`).
@@ -417,8 +394,8 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, 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 every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **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.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, 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 every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **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 Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
- **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. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **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 terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the lane strip's selection — there is no per-pane lane picker — and each pane owns its own tmux session on that lane: pane 0 is the lane's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane lane id from an older build is ignored.
@@ -471,7 +448,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
}
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (an AI Tools catalog id — `claude`, `codex`, `antigravity`, `gemini`, … — default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one lane per tool. An unknown id answers `400 EBADTOOL`; creating a NEW lane for a tool disabled in Settings → AI Tools answers `400 EDISABLED` (an existing lane is still found). This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
@@ -652,13 +629,18 @@ only one that answers "show me the code" rather than "show me the change"):
one green with `U` (`A` added, `D` deleted and struck through, `R`
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`). Its **Edit** button
(any range but a commit) turns that view into a text editor: **Save** (or
Opening a file entry shows the whole file (`/git/file`) for reading, at every
panel width. **Clicking its text** (or Enter on it; any range but a commit)
turns that view into a text editor with the caret on the clicked line and
the scroll position kept — a click that only ends a drag-selection does not,
so copying never drops you into the editor. **Cancel** returns to reading.
From a diff, which has no whole text to click, an **Edit** button does the
same once the file has loaded. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
line endings and final newline. A file the editor cannot take (binary, or
over the 1 MB view cap) says so where the Edit button would be.
over the 1 MB view cap) says so in the file's header.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
@@ -670,14 +652,18 @@ only one that answers "show me the code" rather than "show me the change"):
a file that appears later is open by default; a lane switch clears it.
Each file's header stays **pinned** at the top while its diff scrolls past,
and carries the same stage / unstage / discard buttons as its overview row.
On a file with unstaged changes every hunk gets an editor-style **review
bar**, hung just under its last changed line and pinned to the visible
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
line to the top of the ONE scroller, below the sticky file header — a
On a file with unstaged changes every **change block** — each run of
consecutive `+`/`-` lines, not each `@@` hunk: git merges edits up to six
lines apart into one hunk, and one bar for all of them acted on every edit
at once (issue #50) — gets an editor-style **review bar**, hung just under
its last changed line and pinned to the visible right edge: `^`/`v` to walk
the blocks (each brings the next/previous block's first line to the top of
the ONE scroller, below the sticky file header — a
review diff has no scroll box of its own unless it is large enough to
window, above 500 rows), `n of N`, **Undo**
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
The single-file view offers the same bar.
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`),
both sent with the hunk's `@@` line plus the block's index, so they touch
that block alone. The single-file view offers the same bar.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -759,7 +745,9 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
## Pipeline stages and the five node states
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
> **Pipelines are dormant.** AgentHub ships no pipeline template. Until you install one (drop a template JSON into `DASHBOARD_PIPELINES_DIR` — see "Custom pipeline templates" below), every lane sits on an empty `default` pipeline: no nodes, progress `0`. `GET /api/lanes/pipelines` returns `{"pipelines": []}`, `agenthub stage <x>` (and `POST /api/lanes/:id/stage`) answers `400 ENOPIPELINE`, and the Workspace hides the time-on-stage timer, the pipeline and feature pickers and the PipelineMap — the lane tile shows its tool tag instead of a stage chip. Install a template and all of it comes back; a template with id `default` becomes every lane's default. Further development of pipelines is planned. The rest of this section, and the stage sections after it, describe a lane on an installed template.
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below).
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the lane's detail header in Workspace.
@@ -767,63 +755,31 @@ For a lane that already exists, switch it from the terminal:
```bash
agenthub lanes pipeline # which template this lane uses, and what else exists
agenthub lanes pipeline ship-feature # switch it
agenthub lanes pipeline ml-training # switch it (any installed template id)
```
or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
**The template names the skill that drives it.** `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. It rides along on every lane payload as `pipeline_skill`, and from a terminal there is one command per depth of work — the command you type picks the pipeline, so no `agenthub lanes pipeline` step first:
```bash
cd ~/lanes/myrepo__feat-export
# 16 stages: ends in a published PR, watched until a human merges it
agenthub ship-full "add an export button"
# → lane switched to `ship-feature`, then: claude "/agenthub:ship-feature-lane add an export button"
# 8 stages: in-repo feature work, stops at review/gate — no PR, no browser QC
agenthub ship-quick "fix the CSV separator"
# → lane switched to `default`, then: claude "/agenthub:ship-feature fix the CSV separator"
agenthub ship-full "add an export button" --print # show the command, switch nothing, run nothing
```
| | `agenthub ship-full` | `agenthub ship-quick` |
|---|---|---|
| Template | `ship-feature` (16 nodes) | `default` (8 nodes) |
| Skill | `/agenthub:ship-feature-lane` | `/agenthub:ship-feature` |
| Ends at | PR open on `development`, watched until merged | `ship` in the working copy |
| Includes | unit-test agent, CI gates, e2e, code review, local browser QC, senior GO/NO-GO | plan, implement, tests, review, gate |
| Use when | the work should reach reviewers | the work stays local |
The lane is resolved from the working directory, so no id is needed (pass one to target another lane). Each command puts the lane on its own template *before* spawning, so the dashboard renders the right node count from the first stage rather than after the skill self-corrects; `--print` switches nothing. The requirement is handed to `claude` as ONE argv element — never through a shell — so quotes, `$` and `;` in it are text. A template with no `skill` field makes the command refuse rather than guess.
A template may name the slash command that drives it (its optional `skill` field). It rides along on every lane payload as `pipeline_skill`; the dashboard never executes it.
### Running ONE node
`agenthub ship-full` / `agenthub ship-quick` hand the whole pipeline to a skill. When you only want one step — re-run the CI gate, redo the brainstorm, open the PR by hand — run that node's own `command`:
When you only want one step — re-run a CI gate, redo a brainstorm — run that node's own `command`:
```bash
agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node intake # → claude "/superpowers:brainstorming"
agenthub lanes node intake the export button # extra words are appended to the prompt
agenthub lanes node review # → claude "/code-review"
agenthub lanes node gates # → agenthub lanes hook ci-gate
agenthub lanes node pr-open --print # show the argv, run nothing
agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node <node-id> # run that node's command in the lane's cwd
agenthub lanes node <node-id> extra words # extra words are appended to the prompt
agenthub lanes node <node-id> --print # show the argv, run nothing
```
Both shipped templates give **every** node a command, so any of the 16 (or 8) is runnable. A node starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`.
The node is matched by **id or alias**, so one name works on either template: `default` calls the step `implement` and `ship-feature` calls it `implementing`, and `agenthub lanes node implement` finds it on both.
**From inside a Claude session, use the slash commands instead.** The plugin ships one per node — `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` — plus `/ship-full` and `/ship-quick` for the whole pipeline. Each resolves the node's command through `agenthub lanes node <id> --print` and runs it *in the current session*: a slash-command node is invoked directly (never as a nested `claude` process), anything else runs as a shell argv in the lane's cwd. Extra words are appended to the prompt: `/intake the export button`.
A node is runnable when its template gives it a `command`. A command starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`. The node is matched by **id or alias**.
Two deliberate limits:
- **The command is split on whitespace and spawned without a shell.** A template is data — files anyone can drop into `DASHBOARD_PIPELINES_DIR` — so its `command` is never interpreted as shell. A node needing a pipe or a redirect names a script that contains them.
- **Running a node does not declare its stage.** A command executing is not proof the node passed — the same reason the run console never writes a lane's stage. Declare with `agenthub stage <id>` when the work is actually done. (The exception is a node whose command *is* `agenthub stage <id>`: there the declaration is the whole step.)
**The template also decides what the Workspace run console pre-fills.** A template may name the slash command that drives it — `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. That string rides along on every lane payload as `pipeline_skill`, and the run console starts its prompt box with it (plus a trailing space) so you only type the requirement: pick the 16-node template and Run starts the 16-step skill, pick the 8-node one and it starts the 8-step skill. The dashboard never executes it — it is text in a prompt box, exactly as if you had typed it. A prompt you have already edited is never overwritten by a later template switch, and a template with no `skill` field pre-fills nothing.
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `agenthub stage` fixes it.
### Aggregate pass rates across lanes
@@ -857,7 +813,7 @@ one feature's work reads `implement → tests → implement → tests` as the se
edits, runs the suite, and edits again — and when any backwards move opened a
run, a single feature was recorded as five or six of them. A real pass ends
where the session says it ends: a backwards move only starts a new run when it
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
is declared (`agenthub stage`). A
detection that moves backwards continues the run in flight. Reaching the last
node still closes the run for both — work after `done` is new work by anyone's
reading. On this repo's own lane the rule took a 26-run history down to 5, which
@@ -872,8 +828,6 @@ offering every other lane even while the counts show only one.
`agenthub lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`agenthub-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
**In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `agenthub lanes pipeline ship-feature` and `ship-feature` runs `agenthub lanes pipeline default` before their first `agenthub stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template.
The dashboard renders every node in the pipeline in one of five **states**:
| State | Color | Meaning |
@@ -889,13 +843,13 @@ The dashboard renders every node in the pipeline in one of five **states**:
A stage in this state was **claimed but not proven**. Two scenarios:
1. **Declared but no artifact left.** A skill called `agenthub stage review` without `--evidence`, claiming the stage happened but leaving no proof that work occurred.
2. **Implicitly skipped.** The lane jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state.
2. **Implicitly skipped.** On a template whose nodes start `intake`, `plan`, `implement`, the lane jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state.
In both cases, the stage is not **done** — it's incomplete or untested. Amber signals that the agent claimed to have done this work but the dashboard has no evidence to show it was actually completed. This matters in a pipeline: if your gate checks `done` not `passed-no-evidence`, a lane stuck in amber will block until evidence is provided.
## Reporting a stage
`agenthub stage` is not skill-only. Any Claude session working inside an adopted lane's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A lane worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at (often `intake`), regardless of how much real work happens.
`agenthub stage` is not skill-only. Any Claude session working inside an adopted lane's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A lane worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at, regardless of how much real work happens. With no pipeline template installed there is nothing to declare: the command answers `400 ENOPIPELINE`.
When a skill — or a plain chat session — reaches a significant milestone, it reports the current stage to the dashboard using `agenthub stage`:
@@ -912,23 +866,14 @@ This command:
### Full command signature
```bash
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
```
**Parameters:**
- `<stage>` (required): the pipeline stage name or alias. The default pipeline aliases are:
- `intake` / `assigned` / `claimed` / `start`
- `plan` / `planning` / `brainstorm` / `design`
- `implement` / `implementing` / `coding` / `build`
- `tests` / `testing` / `unit` / `gates` / `pre-push-gate`
- `review` / `reviewing` / `code-review` / `self-review`
- `gate` / `verify` / `verification` / `sr-gate` / `gate-blocked`
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
- `done` / `complete` / `completed` / `merged`
Aliases are **per template** — the `ship-feature` pipeline has its own set
(see "Pipeline template: ship-feature" below). A name matching no node and no
- `<stage>` (required): a node id or one of its aliases, as the lane's
pipeline template defines them (see "Custom template format"). Aliases are
**per template**. A name matching no node and no
alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing:
no node renders `current` and progress reads `0`. The CLI prints a warning to
stderr and still exits `0` in that case — a typo must not break a
@@ -945,7 +890,7 @@ agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--sta
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
- `--tool claude|codex` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--tool <id>` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--status <s>` (optional): the lane's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report.
@@ -985,8 +930,7 @@ Such keys only exist on lanes that predate validation: `POST
/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline
with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve,
so this catches typos and names borrowed from another template — `agenthub
stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to
be stored, warned about on stderr, and then counted nowhere.
stage test` on a template whose CI node is `gates` — which used to be stored, warned about on stderr, and then counted nowhere.
Every declaration is also appended to `lane_stage_events`, the append-only log
the pass-rate report reads. `lanes.stages` describes the pass in flight and is
@@ -1009,7 +953,7 @@ activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in
carries it (`tmux new-session -e`, or an assignment prefix when the argv is
typed into a pane already at a shell prompt). A session inside a
dashboard-started pane therefore never has to guess.
3. `--tool claude|codex`, then the harness's own marker: Claude Code exports
3. `--tool <id>`, then the harness's own marker: Claude Code exports
`CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`, so a Claude session identifies itself
for free. **Codex has no equivalent marker** (it only reads `CODEX_HOME`, and
sets `CODEX_SANDBOX*` under a sandbox policy), so a codex session outside a
@@ -1051,14 +995,11 @@ agenthub stage tests --evidence "Pre-push gate: all linters passing" --result pa
## Custom pipeline templates
The default pipeline is suitable for feature work: `intake` → `plan` → `implement` → `tests` → `review` → `gate` → `ship` → `done`. For different workflows (data pipelines, research phases, deployment stages), you can provide custom templates.
AgentHub ships no template, so a custom template is the only way to turn the pipeline map, stage reporting and stage detection on. A template models whatever workflow you run — feature work, data pipelines, research phases, deployment stages.
### Where templates come from
1. **Built-in:** `server/data/pipelines/default.json` (always available)
2. **User override:** directory specified by the `DASHBOARD_PIPELINES_DIR` environment variable
If both exist, **files in the user directory override built-ins** with the same `id`.
Every `*.json` file in the directory named by the `DASHBOARD_PIPELINES_DIR` environment variable is loaded once at server start; a malformed file is skipped, never fatal. A template whose `id` is `default` becomes the pipeline of every lane that names no other.
### Custom template format
@@ -1083,14 +1024,15 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelin
- **id** (string, required): unique identifier for this template
- **name** (string, optional): display name shown in the UI
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/agenthub:ship-feature-lane"`). The Workspace run console pre-fills its prompt with it for lanes on this template; omit it and the prompt starts empty. Never executed by the dashboard.
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/my-training-skill"`). Carried on every lane payload as `pipeline_skill`. Never executed by the dashboard.
- **nodes** (array, required): list of pipeline stages, in order
- **id** (string): stage identifier (used in `agenthub stage`)
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
- **icon** (string, optional): unicode emoji or single character shown before the stage name
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/superpowers:writing-plans"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; both shipped templates fill every node, using `"agenthub stage <id>"` for the nodes whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`)
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the lane sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node.
- **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live".
Then set the environment variable:
@@ -1104,7 +1046,7 @@ When you create a new lane, you can optionally specify which pipeline to use:
agenthub lanes add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
```
(The pipeline is selected at creation time. Lanes created without `--pipeline` default to the `default` pipeline.)
(The pipeline is selected at creation time. Lanes created without `--pipeline` default to the `default` pipeline — yours if you installed one with that id, otherwise the empty one.)
## Per-feature state and archive
@@ -1164,72 +1106,66 @@ also **infers** a stage from the tool events every hook already delivers
un-instrumented sessions that never call `agenthub stage` — it fills in a lane's
pipeline map without requiring every skill to be rewritten.
Two event sources feed the identical matcher, one per tool:
The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`,
on every hook, live — so it only ever sees `claude` lanes.
| Lane `tool` | Source | Cadence |
| --- | --- | --- |
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
`touchLaneFromHook` resolves the lane by `(cwd, tool)` — a Claude hook never
touches a `codex` lane sharing the same directory, nor a Codex hook the
`claude` one — and runs detection for `claude` lanes only. Codex lanes carry no
pipeline, so nothing detects or advances their stage. Codex's own hooks are
opt-in (Settings → Codex → Overview → **Codex hooks**; Codex then asks you to
trust them): with them a codex lane gets `running`/`idle` as each turn starts
and ends, and "Codex needs your permission: …" as its `needs_action` while
Codex waits on an approval. A sub-agent thread's tool calls are folded onto
the root session but move nothing: they neither clear `needs_action` nor set
the lane `running` — see ARCHITECTURE.md, **Codex hooks**.
Codex sessions and their token usage are ingested from these same journals by
`server/lib/codex-usage.js` (see ARCHITECTURE.md); that never touches a lane.
`server/lib/codex-ingest.js` only mirrors a codex lane's `status` between
`idle` and `running`, and skips a lane whose hooks are current (an event for
its session in the last two minutes — the hooks already report each turn's
start and end): every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
reads; it is editable in Settings → Codex → Overview, and a save re-writes each
codex lane's AI Router layer into the new home — a pane already running keeps
the old one until it restarts), reads each thread's `cwd` from the
`session_meta` on the file's first line, and calls the owning lane `running`
while that journal's mtime is under two minutes old — and `idle` once it is
older **or in the future** (a clock stepped back makes a raw `now - mtime`
negative, which would otherwise read as live). It uses the identical guard hook
ingestion uses (only a lane with no `run_id`, and only from `idle`/`running` —
never from `provisioning` or `failed`) and writes nothing else: not `stage`,
not `notes`, not `slot`/`ports`.
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
`codex-ingest.js` is the codex-side equivalent: it tails
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
shape a Claude hook delivers:
An `antigravity` lane (Antigravity's `agy` CLI) also carries no pipeline.
| Codex tool call | Mapped to |
| --- | --- |
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
| `update_plan` | `TaskCreate` |
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
Everything past that point is shared: the same templates, the same
forward-only/declared-wins rules, the same amber-never-green boundary. Three
codex-only details:
- **A written detection also advances the lane's declared `stage`.** This is
the one place in AgentHub where an inference moves the blue `current` ring, and
it exists because nothing else can: a codex session has no hook and rarely
calls `agenthub stage` on its own, so without it the card sits at `idle` forever
while the amber badge is the only thing that ever moves. It is gated on
`recordDetection` having *written*, so it inherits that function's
forward-only, declared-wins and TTL guards unchanged — a detection behind the
declared stage still advances nothing. The `setStage` call passes **no
evidence**, so every node it moves through renders `passed-no-evidence`
(amber), never `done`: a detection sees a tool call, never its outcome. And
it passes `keepDetection: true`, the sole exception to `setStage`'s clearing
of the detection columns — here the detection is the *reason* for the
declaration, so the card keeps showing which command caused it rather than
looking like a claim the agent made. The last node of every template carries
no `detect` rules, so this can never auto-declare a lane `done`.
- Only bytes appended since the last pass are read. On the first sight of a
journal only its recent tail is read, and nothing at all when that file is
already idle — replaying a thread that finished days ago would paint stages
for work nobody is doing. "Idle" means the journal's mtime is more than two
minutes old **or in the future**: a file that claims to be from the future
(clock stepped back, NTP correction, a restore that preserved timestamps)
makes a raw `now - mtime` negative, which would otherwise read as "written
milliseconds ago" and replay a finished thread as live work.
- The same pass mirrors the lane's `status` between `idle` and `running` from
the journal's mtime, under the identical guard hook ingestion uses (only a
lane with no `run_id`, and only from `idle`/`running` — never from
`provisioning` or `failed`). It writes nothing else: not `stage`, not
`notes`, not `slot`/`ports`.
**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
but can never supply evidence, so every node a codex lane passes through stays
amber until the session itself runs `agenthub stage <node> --evidence "..."`. AgentHub
cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
the working copy is the only lever — `agenthub lanes agents install` writes the
marker-delimited block for you (see "Installing the QC/gate agents"). Note that
from inside a codex session `agenthub stage` needs to know which lane it is: a pane
the dashboard started carries `AGENTHUB_LANE_ID`, and anywhere else `--tool codex`
disambiguates (see "Which lane a CLI command addresses" below).
**Antigravity hooks** are opt-in: Settings → Antigravity → **Antigravity
hooks**, and agy asks for no trust.
- **What they add:** the lane turns `running` when a run starts (a prompt, or a
PostToolUse) and `idle` on its Stop. A PreInvocation starts a turn (a `UserPromptSubmit`) when it is the
run's first, follows a Stop, has no event before it, lands on a session that
is not `active`, or comes more than two minutes after the last event; inside
a turn a model call follows a hook within seconds, and is dropped.
- **Stop:** one with `fullyIdle: false` (background work still running) is
dropped; one carrying a non-empty `error`, or a `terminationReason` matching
`error`/`fail`, ends the turn as an error.
- **No `needs_action`:** agy has no permission hook, so the lane never gets
one.
- **A session gone quiet:** agy fires no hook when it exits, nor when a run
ends on an API error or a permission refused in headless mode, so
`server/lib/antigravity-usage.js` marks the session completed (its agents
too, waiting flags cleared) and sets its lane `idle` once it has been quiet
for ten minutes when it has hook events — a long tool call writes nothing,
and after a Stop agy may still be waiting for the user — or two minutes when
it has none. Its activity is the newest of the conversation file (and a
non-empty `-wal`), its transcript and its last hook event. This uses the same
guard, so it happens only for a lane with no `run_id`, and only from
`running`.
- **Where sessions come from:** agy's own conversation files,
`~/.gemini/antigravity-cli/conversations/<id>.db`, with or without the hooks
(see ARCHITECTURE.md, **Antigravity Sessions and Hooks**).
### The evidence boundary
@@ -1258,9 +1194,8 @@ leads the declared one (see "Viewing lanes" below), and `LaneStripCard.tsx`'s
and the browser never disagree about which one is the headline. Both locate the
declaration by the node the server already resolved it onto (the one whose
`state` is `current`), never by looking `lane.stage` up as a node id: most of
what the ship-feature skill declares is an ALIAS (`tests`, `booting`, `ship`,
`gate-blocked`), which is not an id, and an id lookup scores it -1 — every
detection, however far behind, would then read as leading.
what a skill declares may be an ALIAS, which is not an id, and an id lookup
scores it -1 — every detection, however far behind, would then read as leading.
### Which lane a signal is credited to
@@ -1303,7 +1238,7 @@ contain whole code blocks:
Each rule is tested against these fields **one at a time**, never against a join
of them. An `Agent` event really carries `description` + `prompt` +
`subagent_type` together, so a rule anchored to the agent's identity —
`{"tool": "Agent", "match": "^Explore$"}` in `ship-feature.json` — would never
`{"tool": "Agent", "match": "^Explore$"}` — would never
fire against a concatenation of all three, and a rule could otherwise match
across the seam between two unrelated fields. Write anchored rules against the
one field that identifies the tool call.
@@ -1319,18 +1254,6 @@ to the flattened input. Either way the result is capped at 120 characters
lane's tooltip sometimes shows a file path or a skill name rather than a shell
command: it's whichever of the fields above the matching rule's `tool` carried.
### Superpowers skill invocations
The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
`ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
workflow skill names they expect (`brainstorming`, `writing-plans`,
`executing-plans`/`subagent-driven-development`, Claude Code's own
`code-review` plus `requesting-code-review`/`receiving-code-review`,
`finishing-a-development-branch`). Invoking one of
these skills is a much stronger signal than a matched Bash command, but it is
still detection, not declaration — it renders dashed amber and never `done`,
same as every other detected stage.
Detection only ever attributes to a lane whose `cwd` matches the hook's
session `cwd` (see "Which lane a signal is credited to" above). A session
working directly in a source repo that was never itself adopted as a lane —
@@ -1370,90 +1293,36 @@ read per call, so no restart is needed for a test harness that sets it.
### Where the rules live
Detection rules are not code — they live in the pipeline template itself, as
a `detect` array on each node. The built-in `default.json`
(`server/data/pipelines/default.json`) ships these rules today, node by node:
a `detect` array on each node. AgentHub ships no template, so no rule fires
until you install one.
| Node | Detect rules |
|---|---|
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
| `gate` | `Skill` matching `verification-before-completion`; `Agent` matching `senior-gate-reviewer\|Reality Checker` — entering the gate, never its verdict |
| `ship` | `Bash` matching a `git push` **invocation** — `git`, then option groups, then `push` as the first non-flag token — or `gh … pr create` |
| `done` | none — see below |
A few lessons from rules that ran against real sessions, worth keeping when you
write your own:
**`git diff` is deliberately NOT a review signal.** It was, and measured on a
real session four `git diff --stat` runs — used to verify a generated file —
outranked 81 test runs and 30 edits and pinned the lane at `review`, because
`detect()` takes the LAST matching node and `recordDetection` is forward-only.
Reading a diff is constant background activity, so it carries no stage
information. `gh pr diff` stays: that one really is a review act.
- **Detection is a safety net, not the mechanism.** Because `recordDetection`
is forward-only AND never overrides a higher declared stage, a fix-loop
re-entry that drops back a node is invisible to detection — only an
`agenthub stage` declaration moves the lane back down. What detection adds is
the stage an agent forgot to declare and the heartbeat that keeps a working
lane from reading STALLED.
- **Give a terminal node (`done`, `merged`) no rule.** Inference must never
reach a terminal state on its own; that would let a heuristic assert an
outcome instead of observing activity. A rule on a gate node should detect
that the gate *activity* started, never its verdict.
- **`git diff` is not a review signal.** Measured on a real session, four
`git diff --stat` runs — used to verify a generated file — outranked 81 test
runs and 30 edits and pinned the lane at `review`, because `detect()` takes
the LAST matching node and detection is forward-only. Reading a diff is
constant background activity; `gh pr diff` really is a review act.
- **Match a `git push` *invocation*, not the word `push`.** `git[^;&|]*push`
misses `git -c credential.helper='!f() { … }; f' push` (a `;` inside a quoted
value), and `git[\s\S]*push` over-matches `git log --oneline -1 && echo "… push …"`.
Consume option groups explicitly, treating a quoted option value as one unit,
then require `push` as the first non-flag token — that accepts `git push` and
`git -c core.hooksPath=/dev/null push` while rejecting `git commit -m "don't push"`,
`docker push` and `npm run push-docs`.
**The `ship` pattern matches a git *invocation*, not the word `push`.** Two
weaker forms were tried and both failed against real commands:
- `git[^;&|]*push` (same shell segment) **misses** this repo's own push, because
`git -c credential.helper='!f() { … }; f' push` puts a `;` between the two
inside a quoted value. Six real pushes went undetected that way.
- `git[\s\S]*push` (anywhere after `git`) **over-matches**, and did so on a live
lane within minutes: `git log --oneline -1 && echo "… push …"` pinned the lane
at `ship`. Since `ship` sits near the end of the pipeline and detection is
forward-only, that single over-read stuck — the same failure `git diff` caused
for `review`.
So the pattern consumes option groups explicitly, treating a quoted option value
as one unit, and then requires `push` to be the first non-flag token. That
accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
`docker push`, and `npm run push-docs`.
The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
its own rules, aimed at the commands and Superpowers skills its driving skill
actually runs:
| Node | Detect rules |
|---|---|
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `agenthub … feature activate`; `Agent` matching subagent `Explore` |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md`; `Agent` matching `Plan\|Software Architect`; `TaskCreate` (any) |
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
| `gates` | `Bash` matching `agenthub … hook ci-gate` or `agenthub … sync-base --check` |
| `e2e-feature` | `Bash` matching `agenthub … up --qc` or `agenthub … hook e2e` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind) |
| `qc` | `Agent` matching `qc-local\|Evidence Collector\|Accessibility Auditor`; `Bash` matching `agenthub … lanes proof-link` |
| `gate` | `Agent` matching `senior-gate-reviewer\|Reality Checker`; `Skill` matching `verification-before-completion` |
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
| `pr-open` | `Bash` matching `gh … pr create` |
| `watching-pr` | `Bash` matching `gh … pr view` |
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
Two rules are deliberately absent from this template. There is **no `git diff`
rule on `review`**, for the reason the `default` template learned the hard way
below; the `code-review` skill invocation is the honest signal. And `merged` /
`done` carry no rule at all, because inference must never reach a terminal
state — a test pins that.
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
skill declares every one of these stages with `agenthub stage` itself. What
detection adds is the stage an agent forgot after a context compaction, the
heartbeat that keeps a working lane from reading STALLED, and coverage for a
lane running Superpowers skills without the driving skill at all. It cannot
substitute for the skill's own declarations: because `recordDetection` is
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
that drops back to `gates` is invisible to detection — only the skill's
`agenthub stage gates` moves the lane back down.
`done` in `default.json` deliberately has no rule — it is explicitly out of
scope for inference (see the task's "Out of scope" list): `done` is the one
state detection must never reach on its own, since inferring it would let a
heuristic assert an outcome instead of observing activity. `intake` and `gate`
now carry entry-activity rules (an `Explore` agent, a `senior-gate-reviewer`/
`Reality Checker` agent or `verification-before-completion` skill) — these
detect that the *activity* of exploring or gate-reviewing started, never a
gate's pass/fail verdict, so the "no outcome inference" rule still holds.
To add a rule, drop a template into the directory named by the
To add a rule, put a template in the directory named by the
`DASHBOARD_PIPELINES_DIR` environment variable (see "Custom pipeline
templates" above) with a `detect` array on the node you want to match:
@@ -1477,8 +1346,8 @@ Detection never rewinds a lane and never fights a declaration:
- **Declared beats detected.** A detection is written only when the lane's
*declared* stage index is strictly less than the detection's. A lane
already declared at `review` ignores an `implement` detection outright.
**One exception: a declaration on the pipeline's LAST node** (`done`, in both
built-in templates) stops vetoing. The pipeline is finished there, so a later
**One exception: a declaration on the pipeline's LAST node** (typically
`done`) stops vetoing. The pipeline is finished there, so a later
detection is new work rather than a stale rerun of what the claim covers —
without the exception a lane left at `done` rejects every detection for the
rest of its life, which reads as "auto-detect is broken". The exception
@@ -1699,51 +1568,9 @@ The lane card shows a lock badge when the lane **holds** a named lock (its `lane
- **Never delete the lock directory by hand.** A lock that outlives its holder (e.g. after a hard reboot) is cleaned up automatically — the next `acquire` on that name finds it stale (older than `LOCK_MAX_HOLD`) and breaks it. Deleting `LANES_ROOT/.locks/<name>/` yourself only races whoever else's `acquire` is about to do the same check safely.
- **Never shrink `LOCK_MAX_HOLD` to force through a wait.** The 300-second floor exists specifically to stop this mistake. If the true holder is gone, the 300-second minimum wait is the price of safety. If the true holder is still running (e.g., a build with a network stall), shortening the timeout from 2700 to 300 does not help — it converts a slow success into a premature timeout, leaving the lock held and every other lane blocked forever.
## The ship-feature-lane skill (E1)
## Lane tooling: QC boot, sync-base, MCP, integrations, CI
The **ship-feature-lane** skill is the ported Shipyard driving skill for autonomous end-to-end feature pipelines in a single AgentHub lane. It coordinates the entire development flow: implementation (TDD) → independent unit tests → pre-push CI gates + dev preflight → e2e testing → code review → local QC → senior gate authorization → PR push and publication → CI watch → final report.
**Every stage whose job is to judge the work runs in a fresh-context subagent, never in the driving session.** The plan debate (Stage 0b), unit tests (Stage 1b), the e2e run and its triage (Stage 3), code review (Stage 4), browser QC (Stage 6), the senior gate (Stage 7), and a re-triage of CI that stayed red after a fix (Stage 10) are each dispatched with the Agent tool to a subagent that starts with an empty context and receives only artifacts it can verify itself — working directory, lane spec, diff range, commands, and the other agents' reports verbatim, never the driving session's paraphrase of why a finding was fine. The stages the driving session keeps are the ones that *do* rather than judge: intake, implementation, the mechanical gates, publishing, the CI/PR watch, and applying every fix the judges ask for. `subagent_type: fork` is explicitly forbidden for these: a fork inherits the implementing session's context, so it inherits the belief that the code is already correct. The driving session implements and applies fixes; it never grades its own work. Stages 1b/3 use the generic `general-purpose` type (nothing to install). Stage 4 uses the generic `Code Reviewer` type as its catch-all, plus — in parallel, routed by which of `server/`, `client/` the diff touches — the plugin-qualified `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` domain reviewers (nothing to install; `agenthub:` addressing works from any lane). Stages 6/7 use the lane-installed `qc-local`/`senior-gate-reviewer` agents.
### Getting the skill (nothing to do, if you run the plugin)
The skill lives in `skills/ship-feature-lane/`, and the repo root **is** the `agenthub` plugin root (`"source": "./"`), so `skills/` is the plugin's own skills directory. Anyone with the `agenthub` plugin installed already has it in every session, from any lane's working directory, as **`/agenthub:ship-feature-lane`** — with the bare `/ship-feature-lane` working as an alias unless another skill or command claims that name.
Only a plain checkout without the plugin needs the fallback:
```bash
agenthub skills install
```
Copies `skills/ship-feature-lane/` into `~/.claude/skills/ship-feature-lane/` (overwrites on reinstall — run it again after pulling an update to the skill), which restores the bare `/ship-feature-lane` name. One-time per machine, same as `agenthub lanes agents install` is one-time per lane.
**Agents come from the same plugin, under two names.** `qc-local` and `senior-gate-reviewer` live in `agents/` at the repo root, so the plugin exposes them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`. `agenthub lanes agents install` copies those same files into the lane's own `.claude/agents/` — the highest-priority agent source — which is what makes the bare `subagent_type: qc-local` the skill prefers resolve inside the lane. Either name reaches the same definition; the skill falls back to the `agenthub:` form when the lane copy is missing.
### Invoking the skill
```bash
# Inside a lane's working directory:
/ship-feature-lane <requirement>
```
The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are **Stage 0 (Q&A intake)** and **merging the PR on GitHub**; everything else runs to completion or escalates with a `blocked` status visible on the dashboard. Progress is declared via `agenthub stage` commands at every step.
### Current status: stages 0–8, 10–12, 14 work; stage 9 deferred, stage 13 partially wired (CI-wait works, dev-QC doesn't); stages 6–7 agent-gated
**Stages implemented now:**
- **0-8**: Intake & frontloaded Q&A → plan → implementation → independent unit tests (1b) → gates+preflight → e2e on feature branch → code review → QC plan → QC → senior gate → publish PR
- **10-12**: CI watch on the PR → report → watch PR until merge (gated fix-loop re-entries on review feedback)
- **14**: Done
**Stages currently hardcoded skipped** (pending F's integrations):
- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely.
**Partially wired:**
- **13 (Post-merge verification)**: CI-wait half now runs for real via `agenthub lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean.
**Stages 6 and 7 are now unblocked** — run `agenthub lanes agents install` once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the `qc-local`/`senior-gate-reviewer` subagent type; install and re-run.
See [the skill text](../skills/ship-feature-lane/SKILL.md) for the full pipeline definition.
The commands below are primitives a driving session (or you) calls explicitly — AgentHub never sequences them.
### QC boot flag and profile integration
@@ -1766,7 +1593,7 @@ These environment variables are passed to the profile's `boot` hook during `agen
### Dev preflight and merge safety: sync-base
`agenthub lanes sync-base` is the ONE sanctioned merge in the pipeline — `origin/development` into a feature branch — used at Stages 2, 8, and 12. Three modes:
`agenthub lanes sync-base` is the ONE sanctioned merge a session runs from the CLI — `origin/development` into a feature branch. Three modes:
```bash
agenthub lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
@@ -1786,34 +1613,9 @@ GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
`MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
### Installing the QC/gate agents: agents install
Stage 6 (browser QC) and Stage 7 (senior GO/NO-GO gate) run as subagents — `qc-local` and `senior-gate-reviewer`. The plugin already ships them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`; installing them into the lane's own `.claude/agents/` is what makes the bare names work there:
```bash
agenthub lanes agents install
```
Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<lane>/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `agenthub lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a AgentHub upgrade ships updated templates — reinstalling overwrites, it doesn't merge).
**On a `codex` lane the same command installs something else.** Codex never
reads `.claude/agents/`, so copying agent definitions there would be dead
files. Instead it writes the stage-declaration block into
`<lane>/AGENTS.md` — the project doc Codex *does* load from the working
directory — delimited by `<!-- agenthub:stage:start -->` / `<!-- agenthub:stage:end -->`
so a reinstall replaces exactly that block and nothing else. The file is
appended to when it already exists (your own prose is preserved) and created
when it does not; `installed` comes back as `["AGENTS.md"]`. This is the lever
that gets a codex session to supply the *evidence* auto-advance cannot — see
"Stage detection".
**Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing.
**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text.
### Syncing MCP servers: mcp sync
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. `agenthub lanes mcp sync` gives it those:
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run e2e or browser QC. `agenthub lanes mcp sync` gives it those:
```bash
agenthub lanes mcp sync
@@ -1835,11 +1637,11 @@ 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. 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.
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. Nothing in AgentHub acts on an enabled `tracker` or `dev_qc` toggle. `ci_wait`'s consumer is `agenthub lanes ci status` (below). 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
Check GitHub Actions status for a commit SHA or branch ref — used by Stage 13's CI-wait loop:
Check GitHub Actions status for a commit SHA or branch ref — one shot, so a session waiting on CI polls it:
```bash
agenthub lanes ci status <branch-or-sha> [<id>]
@@ -1847,87 +1649,6 @@ agenthub lanes ci status <branch-or-sha> [<id>]
Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `<id>` parameter is optional and filters results to a specific lane if provided; if omitted, it's not needed.
### Pipeline template: ship-feature (16 node stages)
The skill uses the `ship-feature` pipeline template, which defines the following 16 stages (node IDs):
1. **intake** — frontloaded Q&A and feature activation
2. **plan** — investigation and plan debate
3. **implementing** — TDD implementation
4. **gates** — CI gates + preflight check
5. **e2e-feature** — run e2e on feature branch
6. **e2e-feature-passed** — e2e result gate
7. **review** — code review
8. **qc-plan** — bound QC scope
9. **qc** — browser QC (agent-gated until qc-local lands)
10. **gate** — senior GO/NO-GO gate (agent-gated until senior-gate-reviewer lands)
11. **publishing** — push branch + open/update PR
12. **pr-open** — PR open and published
13. **reported** — report posted
14. **watching-pr** — watch for comments, conflicts, merge
15. **merged** — PR merged, post-verify transition
16. **done** — pipeline complete
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
### More stage names than nodes
Each node carries aliases, and they do two different jobs. Some absorb a
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
the skill declares to say *why* the lane is sitting on a node, without adding a
node to the map.
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
read at a glance across many lanes at once, so it stays coarse; the stage name
is read one lane at a time, so it can be specific.
| Node | Near-miss aliases | Sub-states the skill declares |
|---|---|---|
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
| `plan` | `planning`, `brainstorm`, `design` | — |
| `implementing` | `implement`, `coding`, `build` | — |
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
| `e2e-feature-passed` | `e2e-passed` | — |
| `review` | `reviewing`, `code-review`, `self-review` | — |
| `qc-plan` | — | — |
| `qc` | — | — |
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
| `publishing` | `push` | — |
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
| `reported` | — | — |
| `watching-pr` | — | `pr-comment-fix` |
| `merged` | — | — |
| `done` | `complete`, `completed` | — |
50 names over 16 nodes. Every one of them has a **source**: the skill declares
it, or `default.json` uses it (an agent moving between pipelines will type
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
no source is not "flexibility" — it is a synonym someone imagined, and the
`agenthub stage` warning already catches a name that resolves to nothing, which is
better feedback than silently absorbing every plausible spelling. Twelve
sourceless aliases were written and then cut for exactly this reason.
Two constraints on this:
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
FIRST match, so a duplicate would silently resolve a declaration onto the
wrong node. A test asserts the whole template is collision-free.
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
declared string, and `stageRecords` resolves each key onto its node — so
declaring `migration-collision` and then `gates` leaves ONE record for that
node (the canonical `gates` one wins). A sub-state that needs to keep its own
`--evidence` separately has to be a real node, not an alias.
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
names into the surviving phases so old feature cards still render. AgentHub never
shipped them, so there is nothing to fold.
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
## Orchestration: what AgentHub does NOT do
**AgentHub does not chain, queue, retry, or evaluate gates.**
@@ -1979,3 +1700,25 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
- `server/routes/lanes.js` — REST API surface
- `bin/agenthub.js` — CLI commands for lanes and stage reporting
- `server/routes/hooks.js` — hook-to-lane binding by cwd, and where detection is invoked
## Lanes on any AI tool
A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-lane modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the lane tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
```json
{
"id": "antigravity",
"label": "Antigravity",
"detect": ["agy"],
"homepageUrl": "https://antigravity.google/docs/cli/overview",
"prompt": { "mode": "flag", "flag": "-i" },
"model": { "flag": "--model", "vendor": "google" },
"config": [{ "path": "~/.gemini/GEMINI.md" }],
"resume": ["--continue"],
"bypass": ["--dangerously-skip-permissions"]
}
```
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude lane has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.
+68 -80
View File
@@ -87,28 +87,22 @@ checkout unnecessary.
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
| `/agenthub-doctor`, `/agenthub-update`, `/agenthub-open` | `plugins/agenthub/commands/` |
| `/ship-full`, `/ship-quick` and one command per pipeline node (`/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/review`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/done`, …) | `plugins/agenthub/commands/`, one file per name |
| The skills (`ship-feature-lane`, `ship-feature`, `repo-onboarding`, `debug-live-issue`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
| The agents (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`) | `agents/` at the repo root, auto-discovered the same way — exposed as `agenthub:<name>` |
| The skills (`repo-onboarding`, `debug-live-issue`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
Plugin skills are namespaced: they are invoked as `/agenthub:<name>`, with the bare
`/<name>` working as an alias unless another skill or command claims that name.
This is why `skills/` sits at the repo root instead of `.claude/skills/` — the
latter only loads for a session whose cwd is inside this checkout, which is
useless for a lane working in a different repo. `agenthub skills install` survives
as the fallback for a plain checkout without the plugin.
useless for a lane working in a different repo.
**The agents ship the same way, from the same files.** `agents/` at the repo
root holds all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`,
`frontend-reviewer`), so the plugin exposes them as
`agenthub:<name>`. `agenthub lanes agents install` copies `qc-local` +
`senior-gate-reviewer` from that same directory into a lane's own
`.claude/agents/` — the highest-priority agent source — which is what makes the
bare `subagent_type: qc-local` in the pipeline skill resolve there. One source
file, two ways to reach it; the skill names the bare form first and falls back
to the `agenthub:` form. On a `codex` lane the same command installs the
`<!-- agenthub:stage:start -->` block in the lane's `AGENTS.md` instead — Codex
never reads `.claude/agents/`.
That is the whole plugin: the hooks, the CLI, the dashboard, three commands and
four skills. It ships **no subagents** and no pipeline template, and it neither
depends on nor installs any other plugin (Superpowers included). The pipeline
slash commands, the `ship-feature` / `ship-feature-lane` skills and the
`qc-local` / `senior-gate-reviewer` / `backend-reviewer` / `frontend-reviewer`
agents were removed; the stage/pipeline machinery they drove stays in the
dashboard, dormant until you install a template (see `docs/LANES.md` §
"Pipeline stages and the five node states").
Because the plugin ships the hooks itself, `npm run install-hooks` is not needed
for plugin users — and must not be run alongside it. Events carry no id, so two
@@ -137,10 +131,11 @@ waits on an install:
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, 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
`agenthub.service` unit — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
with no Claude Code session involved. Everywhere else (macOS, Windows, or
no systemd), spawn `server/index.js` detached, with `NODE_PATH` at the
with no Claude Code session involved. Everywhere else (Windows, no
systemd, a Mac reached only over SSH), spawn `server/index.js` detached, with `NODE_PATH` at the
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
`client-dist` — scoped to this session, same as before this fell back to
systemd.
@@ -190,6 +185,34 @@ 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.
### A build installed while the server kept running
The same record answers the opposite case. `claude plugin update` run from a
terminal (or with only the Claude desktop app open) starts **nothing**: no
SessionStart fires, so no bootstrap hands the server over, and the dashboard
keeps running the old directory — across reboots too, since
`agenthub.service` still names it and it still exists. `agenthub open` only
opens the browser.
So `getUpdatesStatus` (`server/lib/update-check.js`) asks `supersededBy` for
its own root: when a sibling build is the installed one, the status carries
`installed_not_running: {root, version}` and `update_available: true` — with
no network needed, so an unreachable forge can no longer hide it. The sidebar
reads **`<version>` installed, not running**, and the update modal's **Update
now** runs the usual chain; `scripts/plugin-update-helper.js` hands over to the
recorded install first (`supersededBy(oldRoot)`), falling back to the newest
sibling directory only when there is no record.
Separately, a plugin install whose release lookup failed (`release_check` not
`ok`) now reads **Could not check for updates** in the sidebar instead of
**Up to date** — a plugin install has no commit count to fall back on.
To hand over by hand without the dashboard:
```bash
node "$(python3 -c 'import json,os;d=json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")));print([e["installPath"] for k,v in d["plugins"].items() if k.startswith("agenthub@") for e in v][0])')/scripts/plugin-bootstrap.js" --force
```
### Pruning the plugin cache
Claude Code unpacks every update beside the last one and does not reliably
@@ -233,15 +256,26 @@ anyone logs in; when that needs elevated rights, it's skipped silently (check
`agenthub.service` unit not written by this bootstrap is left untouched, and the
server falls back to the old per-session detached spawn instead.
On macOS, Windows, or a Linux box without systemd, nothing changes: the
On macOS the same steps write a LaunchAgent instead
(`~/Library/LaunchAgents/io.smartgift.agenthub.plist`, `scripts/launchd-service.js`;
`scripts/service-manager.js` picks the one for the platform, and both export the
same API). `KeepAlive` plays `Restart=always`, `AbandonProcessGroup` keeps the
lane panes' tmux server alive across a restart, output goes to
`~/Library/Logs/agenthub.log`, and the hand-over restart is `launchctl bootout`
+ `bootstrap` (a `kickstart` would keep the old plist). If that bootstrap fails
and no server is left running, the bootstrap starts one detached. A LaunchAgent
needs the user's GUI login; without one (SSH only) `launchctl print gui/<uid>`
fails and the server falls back to the detached spawn below.
On Windows, a Linux box without systemd, or a Mac with no GUI login, the
server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always. That spawn inherits the Claude Code session's env, so
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
before anything else runs — otherwise tmux hands them to every lane pane. It is
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no
launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos).
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See
[INSTALL.md § macOS](../INSTALL.md#macos).
### Who may stop the server
@@ -304,66 +338,9 @@ courtesy of `Restart=always`.
| Command | Does |
|---|---|
| `/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-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), 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) |
| `/ship-quick <requirement>` | The same for the 8-node `default` template and `/agenthub:ship-feature` — stops at review/gate, no PR, no browser QC |
| One per pipeline node: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` | Run just that node's own `command`, resolved through `agenthub lanes node <id> --print` against whichever template the lane is on (matched by id **or alias**, so `/implement` works on `default`'s `implement` and `ship-feature`'s `implementing`). A slash-command node is invoked in the current session, never as a nested `claude`; anything else runs as a shell argv in the lane's cwd. None of them declares a stage |
#### The `Skill plugins` row
Derived from the loaded pipeline templates, not hardcoded: every `/plugin:skill`
string a template names — as the pipeline's own `skill` or as one node's
`command` — has to come from an installed, enabled plugin. Today that means
**`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`
drive step by step (`brainstorming`, `writing-plans`,
`test-driven-development`, `verification-before-completion`,
`finishing-a-development-branch`). The `review` node is the exception: it names
the bare `/code-review`, Claude Code's own review skill, which carries no
`plugin:` prefix and so requires nothing to be installed. Install it
with `claude plugin install superpowers@claude-plugins-official`.
The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work
without it, and only those two pipeline skills stop partway. A plugin listed but
explicitly disabled counts as missing — Claude Code will not resolve its skills.
The skills present as plain user skills under `~/.claude/skills/` count as
satisfied, so a hand-copied setup is not reported as broken. A custom template
in `DASHBOARD_PIPELINES_DIR` naming some other plugin's command is checked the
same way.
Both the derivation and the missing/satisfied rules live in
`server/lib/skill-plugins.js`, shared with the bootstrap below so the row and
the install can never disagree about what is missing.
#### Automatic install, and why it is allowlisted
`plugin-bootstrap.js` installs the missing plugins itself, at the very end of
its **detached worker** — never in the 10 s `SessionStart` hook, because the
install clones from the network. It runs `claude plugin install
superpowers@claude-plugins-official --scope user -y`; if that fails it registers
the marketplace (`claude plugin marketplace add anthropics/claude-plugins-official`)
and retries exactly once. Failure is logged with the manual command and nothing
throws — the dashboard, hooks and CLI do not need these plugins.
The result is recorded as `skillPluginsSettled` in the bootstrap's `state.json`,
which is also part of `fastPathOk()`: the check runs **once** per install, then
the hot path reads one flag. `/agenthub-update` (`--force`) is the retry.
`AGENTHUB_AUTO_INSTALL_PLUGINS=0` (or `false`/`off`) turns it off entirely.
An installed plugin only resolves in the **current** session after
`/reload-plugins`; a new session picks it up on its own. The log says so, since
the worker is detached and cannot tell the session that started it.
**Reporting is derived, installing is allowlisted.** The row above reads plugin
names out of the templates, which is right for a report. Installing from the
same source would not be: a template is a file anyone can drop into
`DASHBOARD_PIPELINES_DIR`, so it would turn "edit a JSON file" into "run this
vendor's plugin install command on the next session". Auto-install therefore
consults `AUTO_INSTALLABLE`, a fixed map in `server/lib/skill-plugins.js`, and
skips every other requirement — visible in the doctor, never installed. Adding
an entry there is a decision to install someone else's code on a user's machine
without asking; it belongs in a review, not in a config file.
### Where things live
@@ -378,6 +355,17 @@ default keeps a plugin-run server and a developer's `npm run dev` server on one
data directory, where `ingestGroupKey` already deduplicates hook ingest to a
single port.
Settings the dashboard saves (CLAUDE_HOME, CODEX_HOME, and the data dir that a
CLAUDE_HOME change pins) go to `~/.config/agenthub/settings.env`
(`$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`), never
to a `.env` inside the plugin cache, which every update replaces. The server
reads it at startup. Precedence is real environment, then the checkout's own
`.env`, then that file — a dev checkout whose `.env` names its own
`DASHBOARD_DATA_DIR` must not be pointed at the live database by a pin saved
from the plugin dashboard. A variable the real environment or that `.env` sets
(a systemd unit's `Environment=`, say) wins at every start, and the settings
page warns about it.
### Uninstalling
`claude plugin uninstall agenthub@agenthub` only removes
@@ -0,0 +1,89 @@
# AI Tools A1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** AgentHub knows ~38 AI coding CLIs from a JSON catalog, detects which are installed, lets the user enable/disable them and pick a default, and runs a lane on any of them.
**Architecture:** One server module `server/lib/ai-tools.js` owns catalog load/validation, PATH detection and the enable/default state file; `server/lib/shell-path.js` hydrates `process.env.PATH` from the login shell. `pty-run.js` keeps its Claude/Codex builders (`launcher`) and gains one generic catalog builder. The client reads everything through one hook, `useAiTools`.
**Tech Stack:** Node/Express + better-sqlite3 (server, `node:test`), React + Vite + vitest (client), tmux.
**Spec:** `docs/superpowers/specs/2026-09-28-ai-tools-a1-design.md`
## Global Constraints
- Catalog id: `^[a-z0-9][a-z0-9-]{0,39}$`; `detect`/`requires` entries are bare names, no `/`.
- `launcher` is accepted only in built-in files (`"claude"|"codex"`).
- Args and env only ever come from the catalog; the prompt is ONE argv element or a tmux paste buffer; never a shell string.
- Bypass args are emitted only when the run request carries `bypass: true`; never remembered.
- State file: `<getDataDir()>/ai-tools.json` = `{ "disabled": [], "default": null }`.
- PATH hydration: `$SHELL` (fallback `/bin/bash`) `-ilc`, delimiters, 5 s timeout, stdin ignored, after listen, off with `AGENTHUB_SHELL_PATH=0`.
- `routerCapable` = `claude`, `codex` only.
- Every new source file starts with the header ending `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
- Server tests: `npm run test:server`; client: `cd client && NODE_ENV=test npx vitest run`; commits: `NODE_ENV=test git commit` (hook runs both suites, long timeout).
- Never push without an explicit ask.
---
### Task 1: Catalog, detection, state (`server/lib/ai-tools.js`)
**Files:**
- Create: `server/lib/ai-tools.js`, `server/data/ai-tools/*.json` (38 files), `server/data/ai-tools/README.md`
- Test: `server/__tests__/ai-tools.test.js`
**Interfaces (Produces):**
- `list(): Tool[]` — catalog order: `claude`, `codex`, then by label.
- `get(id): Tool|null`, `has(id): boolean`
- `detect(id): {installed, path, command}` (cached), `refresh()` clears the cache and re-reads the catalog.
- `readState(): {disabled: string[], default: string|null}`, `isEnabled(id)`, `setEnabled(id, bool)`, `setDefault(id)` (throws `ENOTOOL` / `EBADDEFAULT`), `effectiveDefault(): string|null`
- `view(): {tools: ToolView[], default}` — the GET body.
- `ROUTER_CAPABLE = new Set(["claude","codex"])`
- `__setCatalogDirs([...])` test seam (built-in dir, custom dir).
- `Tool = {id,label,detect,requires,args,homepageUrl,faviconDomain,prompt,resume,bypass,bypassEnv,launcher,source}`.
- [ ] Write failing tests: bad id skipped; `/` in detect skipped; non-array args skipped; `launcher` in custom file skipped; custom id replaces built-in; detection over a temp PATH with an executable and a non-executable file; `requires` missing ⇒ not installed; enable/default rules (default falls back to claude, then first installed enabled; disabling the default clears it; `EBADDEFAULT` for not-installed/disabled; `ENOTOOL` unknown).
- [ ] Implement; write the 38 JSON files from the Orca table (spec §1 mapping); README attribution (Orca MIT).
- [ ] `npm run test:server` green.
### Task 2: PATH hydration (`server/lib/shell-path.js`)
**Interfaces:** `hydrateShellPath({spawn?, shell?, timeoutMs?}) : Promise<boolean>` — merges new segments into `process.env.PATH`, returns whether it ran.
- [ ] Tests with an injected `spawn` (execFile-like): delimiters with noisy rc output, timeout, spawn error, `AGENTHUB_SHELL_PATH=0`, no duplicate segments.
- [ ] Implement with `child_process.execFile(shell, ["-ilc", "printf '__AGENTHUB_PATH__%s__AGENTHUB_PATH__' \"$PATH\""], {timeout:5000, stdio ignore stdin})`.
### Task 3: Routes `/api/ai-tools`
**Files:** Create `server/routes/ai-tools.js`, `server/openapi-extra/ai-tools.js`; modify `server/index.js` (mount + hydrate after listen), `server/openapi-extra.js`, `server/routes/run.js` (binary probe via catalog).
- `GET /` → `view()`; `POST /refresh` → hydrate, `refresh()`, `view()`; `PUT /:id {enabled?, default?:true}`. Mutations behind `sameOriginGuard`.
- [ ] Route test (pattern: `git-credentials-route.test.js`): GET shape, PUT enable/disable, default errors, `/api/run/binary?tool=nope` 400, `?tool=antigravity` resolves through catalog.
### Task 4: Lanes and spawn on any tool
**Files:** `server/lib/lanes.js`, `server/routes/lanes.js`, `server/lib/pty-run.js`, `server/lib/tmux.js` (`pasteText`), `server/lib/dashboard-runs.js`, `server/lib/router-apply.js`, `server/lib/ai-routers.js` (builtin label "Native"), `server/routes/run.js` (forward sandbox/approval/resumeLast/bypass), `bin/agenthub.js` (`lanes add --tool`).
- `VALID_TOOLS` ⇒ `aiTools.has`; lane creation on a disabled tool ⇒ 400 `EDISABLED`.
- `buildCatalogArgv(tool, {resumeLast, bypass, initialPrompt}, bin)` → `[bin, ...args, ...resume?, ...bypass?, ...prompt]`; `after-start` ⇒ `setTimeout(delayMs)` then `tmux.pasteText(id, prompt)` if the session still exists.
- Every spawn env carries `PATH: process.env.PATH`; catalog tools start by `detect().path` (fallback first `detect` name); `bypassEnv` merged when `bypass`.
- Codex: `bypass` ⇒ catalog codex bypass args, and `-s`/`-a` dropped.
- Unknown tool ⇒ `ENOTOOL` at spawn.
- router-apply: non-`routerCapable` accepts only native/builtin.
- [ ] Tests: argv per prompt mode, resume, bypass on/off, Codex bypass, Claude ignores bypass, after-start paste scheduling (fake timers), PATH in env, EDISABLED/EBADTOOL/not-installed allowed, router-apply generic, dashboard-runs any id.
### Task 5: Client data layer
**Files:** `client/src/lib/api.ts` (`aiTools.list/refresh/update`, `AiTool` type, `RunStartArgs.bypass`), `client/src/lib/types.ts` (`LaneTool = string`, drop `LANE_TOOL_LABEL`), create `client/src/hooks/useAiTools.ts` (`useAiTools(): {tools, default, byId, label(id), reload, loading}` — module-level shared cache + subscribers, refetch on focus), replace all `LANE_TOOL_LABEL` uses and the Workspace per-tool binary probes.
### Task 6: Settings → AI Tools section
**Files:** Create `client/src/components/settings/AiToolsSettings.tsx`; modify `client/src/pages/Settings.tsx` (TOC + section), i18n `settings.json` en/vi; hide Claude/Codex tabs while disabled (settings hub nav).
- [ ] Test `client/src/components/settings/__tests__/AiToolsSettings.test.tsx`: rows, toggle calls `update(id,{enabled})`, set default, refresh, available rows show only link.
### Task 7: Add lane + router row + run form
**Files:** `AddLaneModal.tsx` (Default / Other… select, empty state link), `LaneCard.tsx` `LaneRouterRow` ("Native (<label>)", router options disabled for non-routerCapable), `Workspace.tsx`, `RunSetup.tsx` (catalog form: resume, red bypass checkbox; Codex bypass), `LaneConsolePane.tsx`, i18n.
- [ ] Tests updated/added for Default/Other, disabled router option, resume + bypass on the catalog form, Codex bypass.
### Task 8: Docs + full verification
- CLAUDE.md (tool rule, bypass rule, router label, marker), AGENTS.md, docs/LANES.md, docs/API.md, docs/CLI.md, server/README.md, `npm run openapi:yaml`.
- `npm run test:server`, `cd client && NODE_ENV=test npx vitest run` (review snapshot diffs), `npx tsc --noEmit`, `bash skills/file-headers/scripts/check-headers.sh`.
@@ -0,0 +1,93 @@
# AI Tools A2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Install and update AI coding CLIs from Settings → AI Tools, keep the catalog fresh from the newest release, and show real icons.
**Architecture:** `ai-tools.js` learns two catalog fields and a third catalog tier (remote). Four small server modules own one job each: `ai-tool-jobs.js` (the one-at-a-time install/update runner), `ai-tool-versions.js` (npm-managed check + version badge), `ai-tools-sync.js` (daily remote catalog), `ai-tool-icons.js` (favicon cache). `routes/ai-tools.js` exposes them; the client polls one job endpoint.
**Tech Stack:** Node/Express (`node:test`), React + Vite + vitest.
**Spec:** `docs/superpowers/specs/2026-09-28-ai-tools-a2-design.md`
## Global Constraints
- Commands come only from catalog files; `install`/`update`/`launcher` are refused in `DASHBOARD_AI_TOOLS_DIR` files; allowed in built-in and remote (release) files.
- `package`: `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`; script `url` https, last two host labels equal the `homepageUrl` host's.
- Every exec is an argv (`execFile`/`spawn`), never a shell string; stdin ignored; job timeout 10 min; log cap 256 KB; one job server-wide (`409 EBUSY`).
- npm is resolved on the login shell's own PATH first (`shellPath()`), job env `PATH = shellPath() + ":" + process.env.PATH`.
- npm Update only for npm-managed tools (binary realpath under `npm prefix -g`), else `ENOTNPM`.
- Remote catalog only when release tag > running `package.json` version; `AGENTHUB_AI_TOOLS_SYNC=0|false|off` disables it; stored in `<getDataDir()>/runtime/ai-tools-remote/`.
- Icons: no SVG; ≤ 64 KB; dotted hostname, no IP literal; headers `nosniff`, `Content-Security-Policy: sandbox`; stored in `<getDataDir()>/runtime/ai-tool-icons/`.
- All writes behind `sameOriginGuard`.
- Every new/edited source file starts with the header ending `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
- Server tests: `npm run test:server`; client: `cd client && NODE_ENV=test npx vitest run`. Never commit or push without the user's explicit ask.
---
### Task 1: Catalog fields, trust tiers, shell PATH, repo URL
**Files:** Modify `server/lib/ai-tools.js`, `server/lib/shell-path.js`, `server/lib/update-check.js`, eight files in `server/data/ai-tools/` (§ 1 table), `server/data/ai-tools/README.md`. Tests: `server/__tests__/ai-tools.test.js`, `shell-path.test.js`, `update-check.test.js`.
**Produces:**
- Tool gains `install: {kind:"npm",package}|{kind:"script",url}|null`, `update: {kind:"npm"}|{kind:"script"}|{kind:"self",args:string[]}|null`; `source` ∈ `builtin|remote|custom`.
- `__setCatalogDirs([builtin, remote, custom])` (a two-element array keeps meaning `[builtin, custom]` for the A1 tests).
- `view()` rows gain `install`, `update`; `view().catalog` from `ai-tools-sync.catalogMeta()` (Task 3; until then `{source:"builtin"}`).
- `shellPath(): string|null` in `shell-path.js` — the last PATH the shell printed.
- `repoUrlFor(root): Promise<string|null>` in `update-check.js`.
- [ ] Failing tests: bad package name / http url / off-host url / `update:npm` without npm install / `install` in custom dir → file skipped; `launcher` allowed in remote tier; precedence custom › remote › builtin; `shellPath()` null before hydration, set after; `repoUrlFor` for a plugin-cache root uses the marketplace manifest.
- [ ] Implement; add fields to codex, gemini, qwen-code, opencode, copilot, amp, cursor, claude.
- [ ] `npm run test:server` green.
### Task 2: Jobs and versions
**Files:** Create `server/lib/ai-tool-jobs.js`, `server/lib/ai-tool-versions.js`. Tests: `server/__tests__/ai-tool-jobs.test.js`, `ai-tool-versions.test.js`.
**Produces:**
- `ai-tool-versions.js`: `npmBin(): string|null` (which("npm") over `shellPath()` then PATH); `npmManaged(id): Promise<boolean>`; `versions({fresh}): Promise<{[id]:{npmManaged,installed,latest,updateAvailable}}>` (24 h cache); `invalidate(id)`; `__setExec(fn)` seam (`fn(file,args,opts) → Promise<{stdout}>`).
- `ai-tool-jobs.js`: `start(id, action)` → job (throws `ENOTOOL|ENOINSTALL|EINSTALLED|ENOUPDATE|ENOTINSTALLED|ENOTNPM|EBUSY|ENOTHING`); `startUpdateAll()`; `current(): Job|null`; `stepsFor(id, action): Promise<Step[]>` where `Step = {argv:string[]} | {script:url}`; `__setSpawn(fn)`, `__setFetch(fn)` seams.
- Job end: hydrate, `aiTools.refresh()`, install ok ⇒ `setEnabled(id,true)`, `versions.invalidate(id)`.
- [ ] Failing tests: argv for npm install/update is `[npm,"install","-g","pkg@latest"]` with npm from the shell PATH ahead of PATH; self is `[detectedPath,...args]`; script downloads (≤1 MB, https final URL, same two-label host) and runs `["bash", tmp]` then deletes it; `EBUSY` while running; log capped at 256 KB keeping the tail; timeout ⇒ failed; EACCES ⇒ hint line; ok install ⇒ enabled; update-all steps = npm-managed with update + self updaters, `ENOTHING` when none; npm-managed by realpath under `npm prefix -g`; version parse from noisy `--version`; compare via `update-check.compareVersions`; offline ⇒ no badge.
- [ ] Implement.
- [ ] `npm run test:server` green.
### Task 3: Remote catalog sync
**Files:** Create `server/lib/ai-tools-sync.js`; modify `server/index.js` (`startBackgroundServices`: first sync 60 s after start, then every 24 h, `unref`). Test: `server/__tests__/ai-tools-sync.test.js`.
**Produces:** `syncOnce({root, fetch?}): Promise<{ok, reason?, tag?}>`; `remoteDir(): string`; `catalogMeta(): {source, tag, fetchedAt}`; `activeRemoteDir(): string|null` (null when disabled, absent, or its tag is not newer than the running version) — used by `ai-tools.load()`.
- [ ] Failing tests (fake fetch): release not newer ⇒ nothing written; newer ⇒ files validated and written atomically with `meta.json`; an invalid file is dropped, not fatal; a failing download keeps the previous set; > 200 entries / > 64 KB file refused; `AGENTHUB_AI_TOOLS_SYNC=0` ⇒ `{ok:false, reason:"disabled"}`; `activeRemoteDir()` null after the running version catches up.
- [ ] Implement (contents URL = `releaseApiUrl(repo)` with `/releases/latest` replaced by `/contents/server/data/ai-tools?ref=<tag>`; each entry's `download_url`).
- [ ] `npm run test:server` green.
### Task 4: Icons
**Files:** Create `server/lib/ai-tool-icons.js`. Test: `server/__tests__/ai-tool-icons.test.js`.
**Produces:** `iconFor(id, {fetch?}): Promise<{type, body}|null>` (cache hit, 24 h miss cache); `iconDomain(tool): string|null`.
- [ ] Failing tests: svg refused; > 64 KB refused; IP-literal / port / dotless domain ⇒ null; redirect off-domain refused; cache hit makes no second fetch; miss cached.
- [ ] Implement.
### Task 5: Routes
**Files:** Modify `server/routes/ai-tools.js`, `server/openapi-extra/ai-tools.js`, regenerate `server/openapi.yaml` (`npm run openapi` if present, else the existing generator script). Test: `server/__tests__/ai-tools-route.test.js`.
- [ ] Failing tests: `POST /:id/install` 202 + job, 400 `ENOINSTALL`, 409 `EBUSY`, cross-origin 403; `POST /:id/update` 409 `ENOTNPM`; `POST /update-all` 400 `ENOTHING`; `GET /job` null then job; `GET /versions` shape; `GET /:id/icon` headers and 404.
- [ ] Implement (error code → status map in one helper).
### Task 6: Client
**Files:** Modify `client/src/lib/api.ts`, `client/src/hooks/useAiTools.ts`, `client/src/components/settings/AiToolsSettings.tsx`, `client/src/i18n/locales/{en,vi}/settings.json` (wherever `aiTools.*` keys live). Test: `client/src/components/settings/__tests__/AiToolsSettings.test.tsx` (create or extend).
- [ ] Failing tests: Install opens a confirm dialog with `npm install -g <pkg>@latest`; confirming POSTs and renders the polled log; polling stops at `ok`; badge `v<latest> available`; `Installed outside npm` when not npm-managed; **Update tools** disabled with nothing to do; `<img>` falls back to the letter on error.
- [ ] Implement; review any screen snapshot diff before `-u`.
- [ ] `cd client && NODE_ENV=test npx vitest run` green; `npx tsc --noEmit` clean.
### Task 7: Docs + browser check
- [ ] `CLAUDE.md` marker, `AGENTS.md`, `docs/API.md`, `server/README.md`, `server/data/ai-tools/README.md`, env var list (`AGENTHUB_AI_TOOLS_SYNC`); `bash skills/file-headers/scripts/check-headers.sh` exits 0.
- [ ] Browser check on an isolated server (`DASHBOARD_DATA_DIR`, `TMUX_TMPDIR` temp, port 4850): icons render, Install dialog text, badge. No real install is run without the user's go.
@@ -0,0 +1,268 @@
# AI Tools — part A1: catalog, detection, enable/default, lanes for any tool
Issue: #40. Date: 2026-09-28. Status: design agreed in chat, awaiting spec review.
Part of a four-part split. **A1** (this spec) makes AgentHub know about many AI
coding CLIs and run a lane on any of them. **A2** adds Install / Update, remote
catalog sync and favicons (decisions recorded at the end, own spec later).
**B** moves per-tool settings into each tool's Settings tab. **C** brings Codex
to parity (token usage + pricing, hooks). **D** does the same for Antigravity
after a spike.
References studied: Orca (`stablyai/orca`, MIT) and 9Router (npm `9router`
0.5.91, MIT). Catalog data (commands, flags, homepages) is taken from Orca's
`agent-catalog.tsx`, `tui-agent-config.ts` and `tui-agent-permissions.ts`,
with attribution in `server/data/ai-tools/README.md`.
## Goals
- Settings → Dashboard gets an **AI Tools** section: **Installed (N detected)**
with **Refresh**, and **Available to install**. Each row: icon (letter avatar
in A1, favicon in A2), label, command, **Enabled / Disabled**,
**Set default / ✓ Default**, homepage link ↗.
- A newly installed CLI shows up without editing any config: the catalog is
broad (~40 tools, Oh My Pi included), detection re-reads the user's login-shell
PATH, and (A2) the catalog updates itself.
- A lane can be created for any installed, enabled tool; it runs the tool's CLI
in the lane's tmux pane.
- A disabled tool is hidden from Add lane and its Settings tab, and the server
refuses a NEW lane for it. Existing lanes keep working.
## Non-goals (A1)
Installing or updating a tool, remote catalog sync, favicons (A2). Per-tool
settings tabs, pricing and hooks for non-Claude tools (B, C, D). Per-tool
command/args/env overrides in the UI (Orca has them; rejected — they let a
browser choose an executable for the server to run; a custom JSON file in
`DASHBOARD_AI_TOOLS_DIR` covers the need). Heuristic discovery of CLIs that no
catalog lists.
## 1. Catalog
One JSON file per tool in `server/data/ai-tools/<id>.json`, plus any
`*.json` in `DASHBOARD_AI_TOOLS_DIR` (same override rule as
`DASHBOARD_PIPELINES_DIR`: a custom file with a built-in id replaces it).
```json
{
"id": "antigravity",
"label": "Antigravity",
"detect": ["agy"],
"homepageUrl": "https://antigravity.google/docs/cli/overview",
"faviconDomain": "antigravity.google",
"prompt": { "mode": "flag", "flag": "-i" },
"resume": ["--continue"],
"bypass": ["--dangerously-skip-permissions"]
}
```
- `id` — `^[a-z0-9][a-z0-9-]{0,39}$`. It is what `lanes.tool` stores.
- `detect` — bare command names, no `/`. The first one found on PATH is also
the command the lane runs (Orca's `detectCmd` + aliases).
- `requires` (optional) — other commands that must also be on PATH.
- `args` (optional) — fixed args right after the command, before anything
else (e.g. Hermes needs `["chat"]` before its `--query` prompt flag).
- `prompt` — how the first prompt reaches the tool:
- `{ "mode": "argv", "separator": "--" }` — trailing positional, optional
option terminator before it;
- `{ "mode": "flag", "flag": "-i" }` — `[flag, prompt]`;
- `{ "mode": "after-start", "delayMs": 3000 }` — start the bare CLI, then
paste the prompt into the pane after `delayMs` (`ponytail:` fixed delay;
add readiness detection like Orca's if a tool loses prompts).
- `resume` (optional) — args that continue the last session. Absent: no Resume
button.
- `bypass` / `bypassEnv` (optional) — args / env that turn off the tool's
permission prompts. Absent: no bypass switch.
- `launcher` (built-ins only) — `"claude"` or `"codex"`: the lane keeps using
the existing hand-written argv builders (model, effort, permission mode,
sandbox, approval, AI Router). A custom file may not set it.
- `faviconDomain`, `install` — used by A2; ignored in A1.
**Where the data comes from.** Orca ships and exercises this data for ~40
tools, so it is the source: `detectCmd`+aliases → `detect`, `requires` from
`detectRequiredCommands`, prompt modes mapped as `argv` → `argv` (+ its
`argvPromptSeparator`), `flag-prompt` → flag `--prompt`,
`flag-prompt-interactive` → flag `--prompt-interactive`, `flag-interactive` →
flag `-i`, `stdin-after-start` → `after-start`, `hermes-query` → `args
["chat"]` + flag `--query`; `YOLO_TUI_AGENT_ARGS` split into argv arrays →
`bypass`, `YOLO_TUI_AGENT_ENV` → `bypassEnv`. `resume` is filled only where
the tool's `--help` or official docs confirm a continue-last flag; anything
unconfirmed is **omitted**, not guessed. Tools whose launch is not "their own
command" (Orca's `claude-agent-teams`, which launches through Orca itself) are
left out.
**Validation** (`server/lib/ai-tools.js`): a file that fails any rule above is
skipped with one log line; it never throws at startup. Duplicate ids: custom
beats built-in, else first file wins. Antigravity moves from hard-coded
`pty-run.js` code to its catalog file.
## 2. Detection
- `detectTools()` resolves each tool's `detect` names by walking the PATH
directories with `fs` and checking for an executable file — no process per
tool (Windows is out of scope; AgentHub runs on Linux/macOS/WSL). Cached
until the next refresh.
- **PATH hydration.** The server's PATH is whatever the systemd unit /
LaunchAgent captured at install time, so a CLI installed later into a new
directory is invisible. At startup and on Refresh, run the user's `$SHELL`
(fallback `/bin/bash`) with `-ilc` and a command that prints `$PATH` between
two delimiters, with a 5 s timeout; the text between the delimiters is merged
into `process.env.PATH` (new segments appended). Interactive (`-i`) because
nvm and most installers add to PATH in `.bashrc`, which `-lc` skips; the
delimiters discard whatever the rc files print. stdin is `ignore`, so an rc
file that waits for input or auto-attaches tmux gets EOF instead of hanging
until the timeout. Failure keeps the existing PATH. It runs after the server
is listening, never before. Off with `AGENTHUB_SHELL_PATH=0` for users whose
rc files must not run from a service. Taken from Orca's
`hydrate-shell-path.ts`.
- Result per tool: `installed`, `path` (resolved binary), `command` (the name
that matched).
- **The pane gets the same PATH.** A lane pane's environment comes from the
tmux server, not from AgentHub's process, and an npm-installed CLI is a
`#!/usr/bin/env node` script that dies without `node` on PATH. So every spawn
passes the hydrated `PATH` through `tmux new-session -e` / `respawn-pane -e`
(the channel `AGENTHUB_LANE_ID` already uses), and a catalog tool is started
by its resolved absolute `path`.
## 3. Enable / default
Stored in `<getDataDir()>/ai-tools.json`:
`{ "disabled": ["grok"], "default": "claude" }`.
- Tools are enabled unless listed in `disabled` — a freshly detected or
installed tool is on.
- `default`: null or an id. Effective default = `default` if it is installed
and enabled, else `claude` if installed and enabled, else the first installed
enabled tool in catalog order, else null.
- Only ids present in the catalog are accepted. Disabling the default clears it.
## 4. API
All mutating routes sit behind the existing `sameOriginGuard`.
- `GET /api/ai-tools` →
`{ tools: [{ id, label, command, homepageUrl, installed, path, enabled,
source: "builtin"|"custom", launcher, prompt: {mode}, resume: bool,
bypass: bool, routerCapable: bool }], default: id|null }`.
`routerCapable` is true for `claude` and `codex` only. Arg arrays are not
returned.
- `POST /api/ai-tools/refresh` → hydrate PATH, re-detect, same body as GET.
- `PUT /api/ai-tools/:id` with `{ enabled?: boolean, default?: true }` →
same body as GET. Unknown id 404 `ENOTOOL`; making a not-installed or
disabled tool the default 400 `EBADDEFAULT`.
- `GET /api/run/binary?tool=<id>` keeps its shape (`{found, path}`) but
resolves through the catalog; an unknown id stays 400 `EBADTOOL`.
## 5. Lanes on any tool
- `lanes.tool` accepts any catalog id. `VALID_TOOLS` (in `lanes.js`,
`pty-run.js`, `routes/lanes.js`) is replaced by `aiTools.has(id)`. No schema
change: `lanes.tool` and `dashboard_runs.tool` are unconstrained TEXT, and
`idx_lanes_cwd_tool` still allows one lane per (cwd, tool).
- Creating a lane (`POST /api/lanes`, `/ensure`, `/worktree`) for a disabled
tool → 400 `EDISABLED`; unknown →
400 `EBADTOOL`. A tool that is not installed may still get a lane (it can be
installed afterwards); Start then fails with the existing missing-binary
banner.
- `agenthub lanes add` has no `--tool` flag today; it gains `--tool <id>`
(default `claude`), and the help strings saying `--tool claude|codex` become
`--tool <id>`.
- **Spawn** (`pty-run.js`): `launcher: "claude"|"codex"` → the existing
builders. Any other tool → `[path, ...args, ...resume?, ...bypass?,
...prompt]` per the prompt mode; `after-start` sets a named tmux buffer with
`set-buffer -b <name> -- <prompt>` and pastes it with `paste-buffer -p -d -b
<name>` + Enter after `delayMs` (argv only — the tmux wrapper has no stdin),
skipped if the pane is gone. `POST /api/run` and `POST /api/lanes/:id/start`
both forward `bypass`.
Args come only from the catalog; the prompt is always ONE argv element or a
paste buffer, never part of a shell string.
- **Bypass switch.** The run request carries `bypass: true`; the server
appends the catalog's `bypass` args / `bypassEnv`. Codex gains it
(`--dangerously-bypass-approvals-and-sandbox`); Claude does not (its
`bypassPermissions` permission mode already exists). Default off, never
remembered between runs. This replaces CLAUDE.md's "bypass flags are never
emitted" rule with "only when the user ticks the switch for that run".
- A lane whose tool left the catalog: label falls back to the id, Start →
400 `ENOTOOL`, the lane is not touched otherwise.
- Non-Claude tools: no pipeline, no agents install (as codex/antigravity
today); running/idle comes from the exited-pane heal sweep.
- Places that list tools by name today and must follow the catalog instead:
`dashboard-runs.js` (stores any id, not codex/antigravity-or-claude),
`router-apply.js` (the Antigravity special case becomes "a tool that is not
`routerCapable` accepts only `native`, else `EUNSUPPORTED`"), `routes/run.js`
(binary probe). Filters that mean exactly one tool stay (`codex-ingest.js`,
`cc-reload.js`, hook ingestion pinned to `claude`).
## 6. UI
- `client/src/hooks/useAiTools.ts` — fetches `/api/ai-tools`, refetches on
window focus and after a mutation. Replaces `LANE_TOOL_LABEL` and the
Workspace's per-tool binary probes; `LaneTool` becomes `string`.
- `client/src/components/settings/AiToolsSettings.tsx` — the AI Tools section
of Settings → Dashboard (Settings.tsx is ~1,900 lines already), with its own
table-of-contents entry. Rows as in Goals; A1 shows a letter avatar and no
Install button. An "Available to install" row has only the homepage link —
no Enabled/Default controls for a tool that is not there.
- **Add lane — Which CLI:** two buttons, **Default (<label>)** and
**Other… ▾** (a dropdown of the remaining installed, enabled tools). With no
installed, enabled tool the modal says so and links to Settings → AI Tools.
- **Model / AI Router row:** shown for every tool. The native option reads
**Native (<tool label>)** (the built-in router profile's label becomes plain
"Native" in `server/lib/ai-routers.js`); the AI Router option is disabled with
a tooltip for a tool that is not `routerCapable`.
- **Run form, catalog tools:** cwd, prompt ("Ask <label> anything…"), **Resume
last session** when `resume`, and a red **Bypass permissions** checkbox with
a warning when `bypass`. Claude and Codex keep their forms; Codex gains the
checkbox.
- **Settings tabs:** the Claude Code / Codex tabs are hidden while that tool is
disabled.
- i18n: every new string in `en` and `vi`.
## 7. Testing
Server (`npm run test:server`):
- catalog validation: bad id, `/` in a command, non-array args, `launcher` in
a custom file, duplicate ids, custom override;
- detection over a temp PATH of fake executables (and a non-executable file),
and a stubbed shell for PATH hydration (delimiters, noisy rc output, timeout,
spawn error, `AGENTHUB_SHELL_PATH=0`);
- the pane env carries the hydrated PATH; a catalog tool starts by absolute
path; `args` precede the prompt;
- enable/default rules and `EBADDEFAULT`/`ENOTOOL`;
- lane creation: `EDISABLED`, `EBADTOOL`, not-installed allowed;
- argv for each prompt mode, resume, bypass on/off, Codex bypass, Claude
ignoring `bypass`, `after-start` paste scheduling;
- `/api/run/binary` via the catalog.
Client (`npm run test:client`): AI Tools section (rows, toggle, set default,
refresh), Add lane Default/Other, disabled router option, catalog run form
(resume, bypass warning), hidden tabs; snapshot baselines reviewed, not blindly
updated.
## 8. Docs
CLAUDE.md (the lane-tool rule, the bypass rule, the router native label, the
marker), AGENTS.md, docs/LANES.md, docs/API.md, docs/CLI.md (`lanes add
--tool`), server/README.md, the OpenAPI source for the three new routes in
`server/openapi-extra/` plus a regenerated `openapi.yaml`, and the
`update-project-docs` pass.
## A2 decisions (own spec later)
- **Install:** a button per "Available to install" row; one install at a time,
live log under the row, re-detect and auto-enable when done. Commands come
only from catalog files shipped in a release (never from a request, never
from `DASHBOARD_AI_TOOLS_DIR`), as argv arrays. Vendor `curl … | bash`
scripts are allowed: the URL is fixed in the catalog, must be `https` on the
same registrable domain as the tool's homepage, is shown in a confirmation
dialog, is downloaded by the server to a temp file and run as `bash <file>`.
- **Update:** per-row **Update** and **Update all**. npm-distributed tools
compare `npm view <pkg> version` with `<cmd> --version` (5 s timeout,
parallel, cached a day) and show a "new version" badge; script/self-updating
tools (Antigravity, Claude) only offer a re-run of their updater.
- **Remote catalog:** fetched daily from the newest AgentHub release on the
forge (not `main`), validated like the local catalog, cached in the runtime
dir, off with `AGENTHUB_AI_TOOLS_SYNC=0`.
- **Favicons:** fetched by the server once per `faviconDomain`, cached in the
runtime dir, served from `/api/ai-tools/:id/icon`; the browser never calls a
third-party service; letter avatar on failure.
@@ -0,0 +1,213 @@
# AI Tools — part A2: install, update, remote catalog, favicons
Issue: #40. Date: 2026-09-28. Status: design agreed in chat, self-reviewed,
awaiting spec review. Builds on A1
(`2026-09-28-ai-tools-a1-design.md`, whose § "A2 decisions" this refines).
## Goals
- An **Install** button on "Available to install" rows for the tools whose
install command is known; a confirm dialog shows exactly what will run; a
live log appears under the row; the tool is re-detected (and enabled) when
the job ends.
- **Update** on installed rows, a "new version" badge for npm-distributed
tools, and **Update tools** in the section header (named so, not "Update
all", because Settings already has an **Update all** for plugins).
- The catalog refreshes itself daily from the newest AgentHub release, so a
new tool or a fixed flag reaches an install without a plugin update.
- Real icons, fetched by the server, never by the browser.
## Non-goals
- Install data for every tool. A2 ships it for a verified few (§ 1); the
remote catalog can add more later.
- `sudo`, Homebrew, `uv`, `pipx`. A system-wide Node's `EACCES` is reported,
not worked around.
- Uninstall. A job surviving a server restart (it is a child of the server
and dies with it; the log is lost, the tool is re-detected on next start).
## 1. Catalog fields
Two optional fields in a catalog file:
- `install`: `{ "kind": "npm", "package": "<name>" }` or
`{ "kind": "script", "url": "https://…" }`.
- `package` matches `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`.
- `url` is `https:` and its host shares its last two labels with the host of
the file's `homepageUrl`. (`ponytail:` two-label compare, not a public
suffix list — wrong for `co.uk`-style hosts, none in the catalog; swap in a
PSL if one arrives.) Against a malicious remote catalog this is only a
sanity check — that file controls `homepageUrl` too; the trust is the
forge's (§ 3).
- `update`: `{ "kind": "npm" }` (needs `install.kind === "npm"`, reuses its
package), `{ "kind": "script" }` (re-runs `install.url`), or
`{ "kind": "self", "args": ["update"] }` (the tool's own updater, run as
`[<detected path>, ...args]`).
A file with a bad `install`/`update` is rejected whole, like any A1 rule. A
file from `DASHBOARD_AI_TOOLS_DIR` may not carry either field (nor `launcher`,
as in A1) — that directory is writable by anyone who can drop a file, and an
Install click would run what it names.
First batch (npm owners checked with `npm view <pkg> maintainers` on
2026-09-28; each package's `bin` matches the catalog's `detect`):
| id | install | update |
|---|---|---|
| `codex` | npm `@openai/codex` | npm |
| `gemini` | npm `@google/gemini-cli` | npm |
| `qwen-code` | npm `@qwen-code/qwen-code` | npm |
| `opencode` | npm `opencode-ai` | npm |
| `copilot` | npm `@github/copilot` | npm |
| `amp` | npm `@sourcegraph/amp` | npm |
| `cursor` | script `https://cursor.com/install` | script |
| `claude` | — (always present: it runs AgentHub) | self `["update"]` |
Antigravity gets neither until its updater is known.
## 2. Jobs — `server/lib/ai-tool-jobs.js`
- ONE job at a time, server-wide. A second request answers `409 EBUSY`.
- A job is `{ id, tool, action: "install"|"update", status:
"running"|"ok"|"failed", startedAt, endedAt, exitCode, log }`, kept in
memory; `log` is stdout+stderr, capped at 256 KB (the head is dropped).
After it ends it stays readable until the next job starts.
- The argv comes only from the catalog entry:
- npm install/update: `[<npm>, "install", "-g", "<package>@latest"]`, where
`<npm>` is resolved on the **login shell's own PATH, in the shell's
order** — not on `process.env.PATH`, where `hydrateShellPath` APPENDS the
shell's directories after the unit's, so the pinned nvm Node's npm would
still win, and `-g` there lands where the user's PATH never looks (the
tool would "install" and stay undetected). `shell-path.js` therefore keeps
the last shell PATH it read (`shellPath()`, `null` when hydration is off or
failed ⇒ fall back to `process.env.PATH`), and a job's env PATH is
`shellPath()` followed by `process.env.PATH`, so npm's own
`#!/usr/bin/env node` picks the matching Node.
- script: the server downloads `url` (https, 15 s timeout, ≤ 1 MB, the final
URL after redirects must still be https on the same two-label host) to a
0600 temp file and runs `["bash", <file>]`; the file is deleted after.
- self: `[<detected path>, ...args]`.
- `execFile`/`spawn` with an argv, never a shell; stdin is `ignore`; env is the
server's env with the hydrated `PATH`; each command runs in its own process
group (`detached`), and a 10-minute timeout sends the whole group SIGTERM,
then SIGKILL after 5 s (`failed`, `ETIMEDOUT` in the log). A step ends on
`close`, or 500 ms after `exit` — a daemon an installer left behind can hold
the pipes forever, and `close` alone would keep the job (and `EBUSY`) alive.
- On an `EACCES` in an npm job's output, the log gets one hint line: Node is
installed system-wide, use a user-level Node (nvm/fnm) or install by hand.
- When a job ends: `shell-path` hydrate + `aiTools.refresh()`; a successful
install also sets the tool `enabled: true`; the version cache is dropped
(§ 3) — and only THEN does the status leave `running`, because the client
refetches once, on the first poll that sees it finished. A failure in that
bookkeeping is appended to the log, never thrown.
## 3. Versions and the remote catalog
**npm-managed.** An installed tool counts as npm-managed only when the
realpath of its detected binary lies under `<npm prefix -g>` (read once per
versions pass with the § 2 npm). A tool installed another way — this
machine's `codex` is Codex's standalone build under
`~/.codex/packages/standalone/` — gets NO npm Update and no badge: an npm
update would install a second copy the PATH order shadows, and the badge
would never clear. Its row says `Installed outside npm` instead.
**Versions** (`update.kind === "npm"`, npm-managed tools only): installed =
first `\d+\.\d+\.\d+` in `<path> --version`; latest = `npm view <pkg>
version`. All tools in parallel, 5 s timeout each, cached in memory for 24 h,
computed lazily on the first `GET /api/ai-tools/versions` and on Refresh.
`updateAvailable` = latest > installed by the same compare `update-check.js`
uses. Offline or unparsable ⇒ no badge, no error.
**Remote catalog** (`server/lib/ai-tools-sync.js`):
- Daily (and once 60 s after start), off with `AGENTHUB_AI_TOOLS_SYNC=0`.
- Resolves the repo URL exactly as `update-check.js` does — marketplace
manifest for a plugin-cache install, the canonical git remote for a
checkout — through a new export `repoUrlFor(root)` there (built from its
existing `detectPluginInstall`/`pluginMarketplaceUrl`/`pickCanonicalRemote`),
and reads the newest release through `fetchReleaseFromUrl`.
- Only when that release is NEWER than the running build's `package.json`
version — a dev checkout ahead of the newest release must never be rolled
back to older data.
- Lists `server/data/ai-tools/` at the release tag through the forge contents
API (`/api/v1/repos/<o>/<r>/contents/server/data/ai-tools?ref=<tag>`, the
Gitea/Forgejo shape `releaseApiUrl` already builds), downloads each `*.json`
(≤ 64 KB each, ≤ 200 files), validates each with A1's `validate` at
**release** trust (may carry `launcher`, `install`, `update`), and writes the
set atomically to `<runtime dir>/ai-tools-remote/` with a `meta.json`
(`tag`, `fetchedAt`). A failed sync leaves the previous set untouched.
- Precedence per id: `DASHBOARD_AI_TOOLS_DIR` › remote › built-in. A remote set
whose `tag` is no longer newer than the running build (the user upgraded) is
ignored.
- `GET /api/ai-tools` gains `catalog: { source: "builtin"|"remote", tag,
fetchedAt }`.
## 4. Icons — `GET /api/ai-tools/:id/icon`
- Domain = the file's `faviconDomain`, else the `homepageUrl` host. It must
be a dotted hostname (`^[a-z0-9.-]+\.[a-z]{2,}$`, no IP literal, no port).
- The server fetches `https://<domain>/favicon.ico` (10 s, ≤ 256 KB — multi-resolution .ico files run past 100 KB — final URL
https on the same site — last two host labels, so `github.com` →
`github.githubassets.com` is refused but `www.` redirects pass), accepts only PNG, ICO, JPEG, GIF, WebP recognised by their magic bytes
(the Content-Type header is ignored: sites omit or mislabel it) —
**never SVG**, which would run script on the dashboard's origin if the URL
were opened directly — and caches it in `<runtime dir>/ai-tool-icons/`. A
failure is cached for 24 h as a miss.
- Served with the cached type, `X-Content-Type-Options: nosniff`,
`Content-Security-Policy: sandbox`, `Cache-Control: max-age=86400`; a miss is
`204` (a 404 would log a console error per letter avatar on every visit)
and the client keeps its letter avatar; an unknown id is `404`.
## 5. API
All writes behind `sameOriginGuard`; nothing in a body names a command.
| Route | Does |
|---|---|
| `POST /api/ai-tools/:id/install` | starts the install job → `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL` (no `install`), `409 EINSTALLED`, `409 EBUSY` |
| `POST /api/ai-tools/:id/update` | starts the update job; `400 ENOUPDATE`, `409 ENOTINSTALLED`, `409 ENOTNPM` (npm update on a tool installed outside npm), `409 EBUSY` |
| `POST /api/ai-tools/update-all` | one job that updates, in catalog order, every npm-managed tool with `updateAvailable` plus every installed tool with a `self` updater; its log concatenates each step's; `400 ENOTHING` when the list is empty |
| `GET /api/ai-tools/job` | the current or last job, or `null` |
| `GET /api/ai-tools/versions` | `{ [id]: { npmManaged, installed, latest, updateAvailable } }` for tools with `update.kind === "npm"` |
| `GET /api/ai-tools/:id/icon` | § 4 |
`GET /api/ai-tools` rows gain `install` (`{kind, package?|url?}` or null) and
`update` (`{kind}`, plus `args` for `self`, or null) so the dialog can show the exact command.
## 6. UI (`AiToolsSettings.tsx`, `useAiTools.ts`)
- Avatar: `<img src="/api/ai-tools/:id/icon">`, letter avatar on `onError`.
- Available row with `install`: **Install** → confirm dialog showing
`npm install -g <package>@latest` or `Download and run <url> with bash`,
plus the homepage link. On confirm: POST, then poll `GET /job` every 1 s
while `running`; the log renders under that row in a `<pre>` (monospace,
max-height, auto-scroll); on end the list refetches.
- Installed row with `update`: **Update** (same dialog and log) — for an npm
update only when `npmManaged`, else the `Installed outside npm` note — and a
badge `v<latest> available` when `updateAvailable`.
- Header: **Update tools** beside **Refresh**, enabled when a badge or a
`self` updater exists; its dialog lists what will run.
- Every job button is disabled while a job runs; a page opened mid-job picks
it up from `GET /job`.
## 7. Testing
Server (`npm run test:server`): field validation (bad package names, a script
off the homepage's host, `install` in `DASHBOARD_AI_TOOLS_DIR` rejected,
`update: npm` without npm install); jobs with an injected exec (argv exactly as
§ 2, npm resolved from the shell's own PATH ahead of the unit's, one-at-a-time `EBUSY`, log cap,
timeout, enable-after-install, EACCES hint); script download guards (size,
https, redirect host); version parse/compare, the 24 h cache, npm-managed by realpath under the global prefix; sync
(newer-only, precedence, atomic replace, a failed sync keeps the old set,
release-trust `launcher`); icon guards (type allowlist without SVG, size,
IP-literal domain refused, headers). Client (`npm run test:client`): Install
confirm shows the exact command, the log polls and stops, badge and **Update
tools**, avatar fallback.
## 8. Docs
`CLAUDE.md` marker, `AGENTS.md`, `docs/API.md` (six routes), `server/README.md`,
`server/data/ai-tools/README.md` (the two fields, the trust rules),
`server/openapi-extra/` + regenerated `openapi.yaml`, the env var
`AGENTHUB_AI_TOOLS_SYNC` wherever env vars are listed, and the
`update-project-docs` pass.
@@ -0,0 +1,202 @@
# Antigravity parity, part D: sessions, tokens and hooks
Issue: #40 part D. Date: 2026-09-29. Status: self-reviewed (the user asked for
autonomous work with a self-review per part). Mirrors parts C1 and C2
(`2026-09-29-codex-usage-design.md`, `2026-09-29-codex-hooks-design.md`).
## Spike: what agy offers
Verified on agy 1.2.13 in an isolated `HOME`. agy keeps its login in the OS
keyring, so that run used the user's own account.
### Tokens
Not in the transcript, but in agy's own SQLite file per conversation:
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`.
- `gen_metadata` has one protobuf row per model call. Field 1 is a message
whose field 19 is the model name (`gemini-3.8-flash`) and whose field 4 is
`ModelUsageStats`.
- The field numbers were read from the proto descriptor embedded in the
binary: 2 input, 3 output, 4 cache write, 5 cache read, 9 thinking.
- Input does not include cache reads. Output includes thinking (171 = 73 + 98).
- `trajectory_meta.cascade_id` is the conversation id.
- `trajectory_metadata_blob` holds the workspace as a `file://` URI.
- agy 1.0.0 wrote a `.pb` file instead, and Google's service refuses 1.0.0.
### Hooks
Documented at antigravity.google/docs/hooks.
- `~/.gemini/config/hooks.json` maps hook **names** to their events:
`PreToolUse`/`PostToolUse` (`{matcher, hooks}` groups), and `PreInvocation`,
`PostInvocation`, `Stop` (flat handler lists). A handler is
`{type: "command", command, timeout (s)}`.
- agy asks for no trust.
- The payload carries `conversationId`, `workspacePaths`, `transcriptPath`
(`brain/<id>/.system_generated/logs/transcript_full.jsonl`) and `modelName`.
`PostToolUse` adds `toolCall {name, args}`, `stepIdx` and `error`. `Stop` adds
`terminationReason` and `fullyIdle`.
- The hook runs with its cwd in `~/.gemini/config`, and `AGENTHUB_LANE_ID`
does not reach it.
### Traps found in the spike
- **A `PreToolUse` hook that answers `{}` DENIES the tool** ("tool call denied
by pre-tool hook"). There is no neutral answer:
- `allow` would override the user's permission rules;
- `ask` would force a prompt.
- **No permission event, and no Stop when a run ends on an API error** or on a
permission that headless mode refused.
- `invocationNum` restarts at 0 for every run, including `--continue`.
- **Running `agy` self-updates `~/.local/bin/agy`**, even with another `HOME`.
The spike took the user's install from 1.0.0 to 1.2.13; the old binary is kept
as `agy.<n>.old`.
## Design
1. **Sessions and tokens** (`server/lib/antigravity-usage.js`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS`, default 15 s).
- Each conversation file is opened read-only when its mtime (or its
non-empty `-wal`'s) changes; the mtime lives in `antigravity_conversations`.
- It becomes a `tool = 'antigravity'` session: its name and start come from
the transcript, its cwd from the blob, its model from the last generation.
- Tokens are written whole, per model.
- The status reflects the session's last activity: the newest of its file
(a non-empty `-wal` included), its transcript and its last hook event. It
is quiet after ten minutes when it has any hook event — mid-turn a long
tool call writes nothing, and after a `Stop` agy may be waiting for the
user, with no exit hook to say otherwise — and after two minutes when it
has none.
- When a session settles to `completed`, its agents complete too, its
waiting flags are cleared, and its antigravity lane is set `idle`, but only
if the hooks left it `running` and it has no `run_id`. A `Stop` that marked
it `error` keeps that `error`.
- A file that is not agy's (`SQLITE_NOTADB`, `SQLITE_CORRUPT`, `no such
table`) is recorded with `session_id` `''` and retried only once it
changes or at the next server start; any other failure is retried on the
next pass.
- Only the CLI's directory is read.
- Gemini families are added to `pricing-sync.js`, taken from Vertex AI's or
the Gemini API's LiteLLM entries.
2. **Hooks** (`server/lib/antigravity-hooks.js`, Settings → Antigravity →
**Antigravity hooks**, `GET/POST/DELETE /api/settings/antigravity-hooks`,
`sameOriginGuard` on the writes).
- The named hook `agenthub` has `PreInvocation`, `PostToolUse` and `Stop`,
each `timeout: 5`. It never has `PreToolUse`.
- Every other named hook is kept, and a non-object file is refused
(`EBADJSON`).
- The command is the stable path `~/.gemini/config/agenthub-hook.sh`. The
script runs `scripts/hook-handler.js <Event> antigravity`, prints `{}`, and
is refreshed at every start when it is ours.
3. **Route** (`antigravityHookInput` in `server/routes/hooks.js`).
- A hook is accepted only with a UUID `conversationId` and a
`transcriptPath` under the CLI's directory. That keeps out the
Antigravity IDE, which loads the same global file.
- `session_id` is the `conversationId` and `cwd` is `workspacePaths[0]`.
- A `PreInvocation` becomes `UserPromptSubmit` when `invocationNum` is 0,
when the session has no event yet, when its last event is a `Stop`, when
the session is not `active`, or when its last event is more than two
minutes old; any other is dropped. `PostToolUse` carries
`tool_name`/`tool_input` and `step`. `Stop` stays `Stop` — as
`stop_reason: "error"` when it carries a non-empty `error` or a
`terminationReason` matching `/error|fail/i`, and dropped while
`fullyIdle` is false. Every other event is dropped.
4. **Settings → Antigravity** (`AntigravityConfig.tsx`) holds Model Pricing
(`vendor="google"`) and the hooks card. There is no sub-tab bar, because agy
has no config surface AgentHub edits.
## Limits (documented, not fixable from here)
- There is no "needs your permission" state for an Antigravity lane: agy has no
hook for it.
- A lane that a dashboard pane owns (`run_id`) keeps its run-owned status, as
it does for Codex.
- The Antigravity IDE's sessions are not read.
- Sub-agents: none were seen in a CLI run, so nothing folds them.
- The formats are undocumented: a future agy may move them. A generation that
no longer decodes is skipped, not guessed.
## Tests
- **Server:**
- usage: the protobuf reader; the session and per-model tokens; no re-read;
quiet → completed; the stuck lane set idle, while a paned one is kept;
deleted stays deleted; another tool's row untouched; a hook-made cwd kept;
a non-SQLite file skipped;
- hooks: install with three events and no PreToolUse; the forwarder prints
`{}`; other named hooks kept; remove; EBADJSON; a hook switched off is
reported; the refresh is foreign-safe;
- route: a turn on the antigravity lane only; later PreInvocations dropped;
tool calls in the timeline; IDE and bad-id hooks ignored; the settings
routes, including the cross-origin refusal;
- handler: the `antigravity` marker, and an unknown marker dropped;
- pricing: Gemini from Vertex AI, and another vendor refused.
- **Client:** the hooks card (install, a hook switched off, Refresh, Remove, a
refusal).
- **End to end:** an isolated server on :4851 with `AGENTHUB_GEMINI_DIR` set.
The hook was installed through the API, then a real `agy -p` read a file.
Seen:
- the lane went `running`;
- the run ended with no Stop (a shell command refused in headless mode), and
the poll set the lane back to `idle`;
- the session had its name, cwd, model and tokens, and `view_file` in its
timeline;
- Settings → Antigravity showed the card as installed, with its three events.
## Found in the code review, fixed
- **Our own read-only open looked like activity.** On a file agy closed
cleanly, better-sqlite3's read-only open leaves an empty `-wal`, whose fresh
mtime flipped the session back to `active` with "Last active" set to the read
time. An empty `-wal` no longer counts.
- **The poll undid the hooks' `running` within 15 s.** Activity is now the
newest of the file, the transcript and the session's last hook event. The
quiet window is ten minutes, not two, when that event is not a `Stop`,
because a long tool call writes nothing until it ends.
- **A failed run showed as "ready for input".** A `Stop` carrying an `error`,
or a `terminationReason` naming one, now arrives as `stop_reason: "error"`,
and the poll keeps that `error`.
- **Stops and turns.**
- A `Stop` with `fullyIdle: false` is dropped, since background work is
still running.
- The first `PreInvocation` after a `Stop` is a new prompt, whatever its
`invocationNum`.
- **A script that is not ours** at the forwarder's path is never overwritten
or removed (`EFOREIGN`, 400), in `codex-hooks.js` too.
- **A rewritten `hooks.json` keeps its mode**, in `codex-hooks.js` too.
- **Session id and symlinks.**
- The session id is the file name's UUID; `cascade_id` is no longer read.
- A symlinked `~/.gemini` is matched under both paths (`agyCliDirs`).
- **Settling and bad files.**
- A file that is not agy's is recorded, so it is not re-read and warned
about every 15 s.
- Settling a session to `completed` also completes its agents and clears
their waiting flags.
- **Docs.** `.env.example` now lists `DASHBOARD_ANTIGRAVITY_POLL_MS` and
`AGENTHUB_GEMINI_DIR`.
## Found in the whole-change review, fixed
- **An open agy looked finished after a Stop.** The ten-minute window applied
only while the last event was not a `Stop`, so a session waiting at the
prompt settled to `completed` two minutes after each turn. agy fires no exit
hook, so an open agy and a closed one look the same: any session with hook
events now gets ten minutes, a hookless one two.
- **A turn after a missed Stop was lost.** A `PreInvocation` is now also a
prompt when the session has no event yet, is not `active` (a run that ended
with no Stop, a session the poll settled), or its last event is over two
minutes old — not only on `invocationNum: 0` or after a `Stop`.
- **Error Stops.** Only a non-empty `error` counts, and a `terminationReason`
counts when it matches `/error|fail/i`.
- **A bad file was never retried, a busy one was given up on.** A file that is
not agy's is recorded with `session_id` `''`, and those rows are deleted at
every server start (`startAntigravityUsagePoll`), so a newer AgentHub tries
it again; any other read failure (a busy file, our own error) is no longer
recorded, so the next pass retries it.
- **Settling left the session waiting.** It now clears the session's own
waiting flag as well as its agents'.
- **A forwarder path that is not a file of ours** (other content, an unreadable
file, a directory — `scriptIsOurs`) is never overwritten (`EFOREIGN`) or
removed, in `codex-hooks.js` too.
- **A symlinked `hooks.json`** (a dotfiles repo's) left with no hook is emptied
through the link instead of being unlinked, in both installers.

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