Compare commits

...

31 Commits

Author SHA1 Message Date
nntrivi2001 fb3796f064 chore(release): bump version to 1.6.3 2026-09-15 14:55:23 +07:00
nntrivi2001 c3f8dec104 refactor(lanes): drop the tick box and radio dots from the Add-lane modal
Both controls already render as cards that light up when selected — the same
selected/unselected treatment LaneStripCard uses — so the tick box and the two
radio dots were a second indicator of a state the card was already showing.

Neither input is removed, only hidden (`sr-only`): they are still what a
keyboard toggles and what a screen reader announces, so the cards take a
`focus-within` ring in exchange for the focus ring that went with them.

The router radios needed one thing the checkbox did not. "AI Router" is
disabled until the profile list lands, and the greyed-out dot was the only
thing showing that; hidden, it would have read as a card you can click that
does nothing — the dead control the existing comment warns about. So disabled
is now its own branch: muted text, reduced opacity, no hover, not-allowed
cursor.

Layout follows: no flex row with a gap when there is one child left, and the
two equal-width router cards centre their label rather than leaving it hard
against an empty left edge.

Visual only. No behaviour, API or schema change.
2026-09-15 14:53:00 +07:00
nntrivi2001 6e685ce1c3 chore(release): bump version to 1.6.2 2026-09-15 11:01:07 +07:00
nntrivi2001 29a5da3e4e fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`claude plugin update` unpacks the new version into a new cache directory and
says "Restart to apply changes". A Claude Code process that was already running
does not: it keeps loading the OLD directory, hooks and all.

Every session such a process opened then ran the OLD plugin-bootstrap, which saw
a state.json naming a different root, concluded the server had to be replaced,
repointed agenthub.service at itself and restarted the dashboard onto the
version the user had just upgraded away from. The dashboard reported the old
version and offered the update again — once per session, forever, while
installed_plugins.json and the cache both said the new version was installed.
A /agenthub-update from that session "succeeded" and changed nothing.

- `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING
  cache directory is the recorded install, the bootstrap logs one line and
  returns "superseded" — no deps, no unit, no server. `--force` does not
  override it: the self-update chain forces from the NEW directory, which is
  never superseded, so the only --force refused is a deliberate downgrade.
  It cannot hand over either — the process it would replace is the one loading
  it — so the log line says to restart Claude Code.
- Matched by sibling directory, not by the <plugin>@<marketplace> key: correct
  without parsing a cache path or trusting a name. A phantom entry (installPath
  that no longer exists) is ignored rather than wedging the bootstrap shut.

Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy
of the repo — node_modules and all — per release; twelve of them measured 1.7 GB
on one machine. It runs after writeState, so a bad prune cannot cost the record
of a good bootstrap, and it protects four independent readings of "in use":
this build, every installPath Claude Code records, the directory
agenthub.service names (`unitRoot`, foreign unit included), and any directory
still named in the user's settings.json hooks — that last one is the un-restarted
process above, seen from the other side. Plus the 2 newest survivors as a
rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
2026-09-15 10:30:00 +07:00
nntrivi2001 713e9fe58b chore(release): bump version to 1.6.1 2026-09-14 16:33:46 +07:00
nntrivi2001 13bc7154d6 fix(updates): keep the update modal's footer buttons on one line
The footer wrapped every label mid-word ("Check / now", "Copy / command")
because nothing stopped the text breaking and the modal was too narrow to
hold four buttons.

Three changes, smallest first:

  - `whitespace-nowrap` on the footer container. `white-space` inherits, so
    one class keeps every label on its own line without touching each button.
  - `flex-wrap` alongside it, so a translation that still outgrows the modal
    breaks the ROW rather than the words.
  - `max-w-lg` -> `max-w-2xl` on the dialog.

Those alone were not enough in Vietnamese: the button's restarting label is
a full sentence ("Đã build xong — đang khởi động lại bảng điều khiển...",
52 chars), which put the row over the width on its own. That sentence is
already shown in the status banner directly above the footer, so the button
now uses a new short `restartingShort` key and the banner keeps the full
one unchanged.
2026-09-14 16:32:15 +07:00
nntrivi2001 e895c0e8eb chore(release): bump version to 1.6.0 2026-09-14 16:07:05 +07:00
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00
nntrivi2001 fc7b58f15b chore(release): bump version to 1.5.10 2026-09-14 13:10:21 +07:00
nntrivi2001 c4025cd2f7 style(lanes): render the create-readme option as a selectable card
Matches LaneStripCard's selected/unselected treatment and moves the hint
inside the card. Drops the "codex has no slash commands" clause from the
hint — the option is already hidden for a codex lane.
2026-09-14 13:10:16 +07:00
nntrivi2001 ee1e07bb5e fix(update-check): resolve the release URL for a relative plugin source
This project's marketplace entry is `"source": "./"` — the plugin IS the
marketplace repo — but pluginMarketplaceUrl only accepted a URL string or
`{url}`, so it returned null and every plugin-cache install answered
`no_remote`: no install of this plugin could ever see a published release.

A relative source now falls back to the manifest's own `repository`. An
`owner/repo` shorthand still returns null — it names a different repo, so
borrowing the marketplace's URL would be a confidently wrong answer.
2026-09-14 13:10:16 +07:00
nntrivi2001 91af3dca86 chore(release): bump version to 1.5.9 2026-09-14 11:22:11 +07:00
nntrivi2001 baa355bee4 feat(lanes): offer a create/update readme run when adding a lane
An optional checkbox in the Add-lane modal starts the new lane's terminal
run right after creation with `/sg-all:sg-md-writing` as its first message,
so the lane opens already writing or refreshing its CLAUDE.md/AGENTS.md set.
Claude lanes only — codex has no slash commands.
2026-09-14 11:20:37 +07:00
nntrivi2001 b7e81af0b6 chore(release): bump version to 1.5.8 2026-09-11 16:48:00 +07:00
nntrivi2001 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
2026-09-11 16:45:47 +07:00
nntrivi2001 49a041b1dc chore(release): bump version to 1.5.7 2026-09-11 16:32:50 +07:00
nntrivi2001 de8d1079e0 fix(bootstrap): never stop the dashboard from inside its own systemd cgroup
A plugin self-update took the dashboard down and left it down. The chain
ran correctly up to the point where the bootstrap SIGTERMs the server it is
replacing — and that server was systemd-managed, with the bootstrap running
as its descendant, i.e. inside its cgroup. Two failures fired at once:

  * the server exits 0, and `Restart=on-failure` reads a clean exit as
    "meant to stop", so systemd did not bring it back;
  * `KillMode=control-group` reaped the bootstrap itself, mid-run, before it
    could repoint the unit at the new plugin root or write state.json.

Evidence from the incident: bootstrap.log ends at "stopped the server
started from the previous plugin version" with no systemd line after it,
.bootstrap.lock survived with a dead pid, state.json still named the
previous version, and the unit's ExecStart still pointed at the old cache
dir. The same truncation is visible on three earlier updates — those only
recovered because a later SessionStart bootstrapped again.

systemd-service.js now splits install into writeUserService (repoints the
unit, starts nothing) and restartUserService (--no-block). The bootstrap
skips the manual stop whenever a unit of ours owns the server, and defers
the restart past writeState and releaseLock, so being killed by its own
request costs nothing. The unit is Restart=always: a graceful SIGTERM from
anything must no longer read as "leave it stopped".

installUserService keeps its old semantics for `agenthub service install`,
which runs from a user's shell and is not in the cgroup.
2026-09-11 16:30:10 +07:00
nntrivi2001 0335e0e6bc chore(release): bump version to 1.5.6 2026-09-11 15:59:33 +07:00
nntrivi2001 18614f378d fix(updates): give a plugin-cache install a real message, not null
`agenthub update-check` prints `message` as its headline, so every
plugin-cache install saw the literal "null" where the verdict belongs — and
that is the one install kind with no commit count to fall back on, so the
line carried nothing at all.

The plugin branch of getUpdatesStatus compares versions exactly like the
checkout branch does; it now builds the same four sentences from what it
already computed (newer release / newest / no local version to compare /
release lookup failed).

The nogit regression test asserted `message === null` as a proxy for "did
not fall through to the not-a-git-clone payload" — it now asserts that on
the message's content instead, which is what it always meant.
2026-09-11 15:54:03 +07:00
nntrivi2001 63ed90ba05 docs(plugins): stop promising an MCP server the plugin no longer ships
c7dbaf2 dropped it. The doctor row list also claimed an 'MCP build
freshness' check that plugin-doctor.js does not emit.
2026-09-11 15:39:24 +07:00
nntrivi2001 3ed88062d6 chore(release): bump version to 1.5.5 2026-09-11 15:35:35 +07:00
nntrivi2001 27313d4413 fix(run): refuse a run with ETMUXMISSING when the host has no tmux
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.

The guard goes in spawnRun, which both spawn paths (POST /api/run and
POST /api/lanes/:id/start) already route through, so one check covers
them; the message carries the install command. Both routes map the code to
503: the request was fine, the dependency is not installed. The client
already renders `error.message` from the body verbatim, so no UI change.

Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
2026-09-11 15:32:24 +07:00
nntrivi2001 d4b949a6f4 docs(deployment): list git and tmux beside node in the prerequisites
The only prerequisites block that still said node+npm alone.
2026-09-11 15:22:11 +07:00
nntrivi2001 8930865376 test(cli): cover the doctor's tmux line
The row was added without a check here, so a regression that dropped it
would only surface when someone's Start Run failed silently again.
2026-09-11 15:20:30 +07:00
nntrivi2001 29858ddccb docs: document the system prerequisites, and report tmux in both doctors
The READMEs never said tmux was needed, so a fresh install only found out
when Start Run failed with nothing explaining why. Both doctors were silent
about it too.

- README/README.vi: a prerequisites table (node, npm, git, tmux, claude,
  codex, gh, systemd) and per-OS tmux install + verify steps; the feature
  list is regrouped into observe/drive/configure and now covers analytics,
  alerts, import, remote sources, the config explorer and Update all.
- INSTALL: Step 0 for the system packages, plus a troubleshooting entry
  separating "tmux missing" from "the server has a different PATH".
- SETUP/docs/README: the same troubleshooting entry and a pointer to Step 0.
- plugin-doctor: a tmux row, WARN and never FAIL - the run console and
  `lanes shell` break without it, nothing else does. Reuses tmux.js's own
  isTmuxAvailable().
- agenthub doctor: asks GET /api/run/tmux instead, because the PATH that
  decides whether a run starts is the server's, not the caller's shell's.
2026-09-11 10:44:10 +07:00
nntrivi2001 7000a9c5ce chore(release): bump version to 1.5.4 2026-09-10 23:21:45 +07:00
nntrivi2001 99e718ad91 fix(plugin): build the UI with devDependencies so a self-update ships its own bundle
After the 1.5.2 -> 1.5.3 self-update the server ran 1.5.3 while the dashboard
still served the 1.5.2 UI. The client build had failed, and `runtime/client-dist`
still held the previous version's bundle, so nothing looked broken — the only
signal was the Settings page's "UI build v1.5.2 (rebuild client to match)".

Cause: the whole client build toolchain (vite, typescript,
@vitejs/plugin-react, tailwindcss, @types/*) is a devDependency, and npm reads
NODE_ENV=production as --omit=dev (`NODE_ENV=production npm config get omit` ->
`dev`). The dashboard server runs with NODE_ENV=production, and every bootstrap
descended from it inherits that: the plugin-update chain, and any SessionStart
fired by a `claude` running in a tmux pane the server started. So `npm ci`
dropped vite, `tsc -b` died on missing type declarations, and buildClient
returned "failed" with the stale bundle left in place.

`--include=dev` on both install paths — a CLI flag outranks the env, which is
the point. Verified by running buildClient with NODE_ENV=production against the
installed plugin root: previously "failed", now "built", and the served bundle
carries 1.5.3.

Also: plugin-update-helper writes its verdict BEFORE the bootstrap handover as
well as after. That step retires the process tree the helper hangs off, and the
observed run reached the new server with no verdict file at all — the dashboard
reported `last_plugin_update: null` for an update that had actually succeeded.
2026-09-10 23:17:33 +07:00
nntrivi2001 67c948bbe2 chore(release): bump version to 1.5.3 2026-09-10 17:09:07 +07:00
nntrivi2001 8b8582f231 fix(runs): persist tmux-backed runs so a resumed session shows its own info
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by
design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever
persisted — the newest row in a live database dated from before pty-run.js
replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview,
started_at and session_id off that row and the handle itself is recomputed
from tmux, so the runs modal rendered a live row with no cwd, no model, no
prompt and a start date of Jan 1 1970. The same missing row is why a resumed
session kept its separate `external` badge: the client dedupes an external
session against the session ids carried by run rows, and no run row ever
carried one.

- pty-run.js records `mode: "interactive"` — every run here is one pane.
- dashboard-runs.js still never fails a live run on a db error, but warns
  instead of swallowing silently; that silence hid this for six weeks.
- RunHistory renders `startedAt: 0` as "-" rather than dating a row with no
  known start time to the epoch.

Also closes the other half of the same lie: adopting a pane that is already
running an agent now throws ERUNLIVE (409 from both the lane start action and
POST /api/run) when the request carried a prompt or a resumeSessionId. That
argv cannot be typed over a live agent, so answering 200 reported a resume
that never spawned. A start asking for nothing but a pane still adopts.
2026-09-10 17:07:23 +07:00
nntrivi2001 b868a2fa85 chore(release): bump version to 1.5.2 2026-09-10 16:47:16 +07:00
nntrivi2001 30bc6debde fix(updates): check releases for a plugin install with no .git
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.

The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".

UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
2026-09-10 16:46:06 +07:00
65 changed files with 5191 additions and 183 deletions
+9 -6
View File
@@ -354,7 +354,7 @@ graph TD
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. |
@@ -1965,12 +1965,12 @@ These behaviors are reinforced by explicit `Cache-Control` headers from the prod
The Update Notifier tells the user when a newer **release** of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click.
The version signal is the forge's own release list, read over REST at `/api/v1/repos/<owner>/<repo>/releases/latest` with the owner and repo derived from the `origin` remote URL (no hardcoded host, no credentials for a public repo). The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look".
The version signal is the forge's own release list, read over REST at `/api/v1/repos/<owner>/<repo>/releases/latest` (no hardcoded host, no credentials for a public repo). The owner and repo come from one of **two** sources, chosen by install kind: a checkout derives them from its `origin` remote; a plugin-cache install derives them from the marketplace manifest on disk (`pluginMarketplaceUrl`), because it has no remote to read — and often no `.git` at all, since a marketplace entry with `"source": "url"` (this project's own) makes Claude Code unpack an archive rather than clone. The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look".
Two states the release model makes visible that a commit count cannot:
- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date".
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used.
### Module Layout
@@ -2057,7 +2057,7 @@ sequenceDiagram
| Component | Responsibility |
| --- | --- |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from the remote (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
@@ -2126,7 +2126,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Condition | Returned payload | User-visible effect |
| --- | --- | --- |
| Not a git clone | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
| Not a git clone (and not a plugin cache) | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
| Plugin cache with no `.git` (archive-sourced marketplace entry) | `{git_repo:false, install_kind:"plugin", release_check:"ok", latest_release:"vX.Y.Z", update_available:<version compare>}` | Checked normally: the repo URL comes from the marketplace manifest and `package.json` is the only local signal. The `notGit` note is suppressed and **Update now** is offered. |
| No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. |
| `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"}` | Sidebar button goes amber; modal stays suppressed until a successful check. |
| Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."}` | Modal suppressed. |
@@ -2137,8 +2138,10 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed"}` | No chain is spawned. Message points at `/agenthub-update` to force a refresh. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
+5 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -36,6 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/<name>.config.toml` — the layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
@@ -43,6 +44,9 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## Non-negotiable engineering rules
- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port.
- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps.
- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the 2 newest survivors as a rollback cushion, and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here.
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
- Preserve existing behavior unless explicitly asked to change it.
- Prefer minimal, reversible diffs.
+74 -1
View File
@@ -30,12 +30,64 @@ dashboard, run it from source, or stay on Node 20.
| Node.js | 20+ (22+ recommended) | Required for server and client |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
| Git | Any | For cloning the repository |
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
---
## Step 0 — System packages
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
fail while everything else keeps working.
```bash
sudo apt install git tmux # Debian / Ubuntu
sudo dnf install git tmux # Fedora / RHEL
brew install git tmux # macOS
sudo pacman -S git tmux # Arch
```
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
dashboard uses:
```bash
curl -s localhost:4820/api/run/tmux # {"available":true}
```
**No configuration is needed.** AgentHub creates its own session per lane,
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
depends on (mouse mode is set on the session explicitly). You can attach to the
exact same pane from a normal terminal:
```bash
agenthub lanes shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane-
```
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
the browser terminal and your local terminal are two clients on one pane, and
restarting the dashboard never stops a run.
---
## Step 1 — Clone the repository
```bash
@@ -261,6 +313,27 @@ npm run setup
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
### "Start Run" fails, or the terminal never attaches
The run console is tmux-backed. Check, in this order:
```bash
tmux -V # not installed → see Step 0
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the server runs with
a different `PATH` — most often a systemd `--user` service, which does not read
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
rather than into a shell-managed prefix, then `agenthub service uninstall &&
agenthub service install`.
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
name, so a `claude` installed only through an interactive shell's profile is
invisible to a service-managed server. `{"found":false}` for the lane's tool is
that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
+78 -7
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Tiếng Việt: [README.vi.md](README.vi.md))*
## What it does
## Main features
**Observe**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
**Drive**
- **Lanes.** One lane per working directory, surviving session restarts. A lane
moves through pipeline stages and the dashboard shows where it is.
- **Stage detection.** The stage is inferred from the tool stream, so a session
@@ -26,15 +40,72 @@ Internal build — all rights reserved.
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
setting.
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
feature from intake to PR, declaring every stage to the dashboard as it goes.
## Requirements
**Configure**
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
are verified on — node 25 currently breaks 6 server tests through a
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` lanes |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change.
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
(sessions, analytics, lanes, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
### The superpowers plugin
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
+93 -7
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
## Làm được gì
## Chức năng chính
**Quan sát**
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
được: tool call, token usage, cost, cây subagent, transcript.
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
pricing sửa được; top tool, agent type và workflow pattern.
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
target bắn khi rule khớp.
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
(idempotent, không bao giờ đếm trùng token).
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
(`agenthub remote-sources`).
**Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
@@ -26,14 +40,86 @@ Internal build — all rights reserved.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
## Yêu cầu
**Cấu hình**
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
tiếng Anh, Việt, Trung và Hàn.
## Yêu cầu hệ thống
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
```bash
tmux -V # ví dụ: tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
### Plugin superpowers
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
thì skill mới resolve được (session sau tự nhận). Cài tay:
```bash
claude plugin install superpowers@claude-plugins-official
```
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
## Cài như Claude Code plugin
+20
View File
@@ -324,6 +324,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
+22 -2
View File
@@ -1195,7 +1195,10 @@ async function cmdUpdateCheck() {
// sees the same fresh result via the update_status websocket broadcast.
const s = await post("/api/updates/check");
heading("Dashboard updates", s.repo_root || baseUrl());
if (!s.git_repo) {
// A plugin-cache install legitimately has no clone (an archive-sourced
// marketplace entry unpacks one), yet it IS checkable and updatable via the
// release API — so only a genuine non-git checkout bails out here.
if (!s.git_repo && s.install_kind !== "plugin") {
console.log(`${c.yellow("○")} ${s.message}`);
return;
}
@@ -1276,6 +1279,23 @@ async function cmdDoctor() {
);
console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`);
// Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
const { available } = await get("/api/run/tmux");
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
}
try {
const { sources = [] } = await get("/api/remote-sources");
if (sources.length === 0) {
@@ -2992,7 +3012,7 @@ const COMMAND_GROUPS = [
[
"Administration",
[
["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
["doctor", "", "Connectivity, hooks, database, tmux, and remote sources diagnosis"],
["info", "", "Raw system info JSON"],
["export", "[file.json]", "Export all data as JSON"],
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
+12 -4
View File
@@ -306,7 +306,9 @@ export function UpdateNotifier() {
if (e.target === e.currentTarget) dismiss();
}}
>
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
@@ -372,7 +374,7 @@ export function UpdateNotifier() {
</div>
) : null}
{!status.git_repo ? (
{!status.git_repo && status.install_kind !== "plugin" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("notGit")}
</div>
@@ -432,7 +434,10 @@ export function UpdateNotifier() {
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
own single line; `flex-wrap` then breaks the ROW instead of the
words if a translation still outgrows the modal. */}
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
<button
type="button"
onClick={checkNow}
@@ -473,7 +478,10 @@ export function UpdateNotifier() {
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
{/* `restartingShort`, not `restarting`: the full sentence is
already in the banner above, and a whole sentence on a button
is what forced this footer onto two lines. */}
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
</button>
) : null}
</div>
@@ -114,6 +114,29 @@ describe("UpdateNotifier — Update now", () => {
expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument();
});
it("still offers the button when a plugin install has no git clone at all", async () => {
// The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so
// git_repo is legitimately false. Gating the UI on that reported "not a git
// clone; check manually" for an install the dashboard can check AND update.
statusMock.mockResolvedValue({
...BASE_STATUS,
git_repo: false,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.1",
release_check: "ok",
latest_release: "v1.6.0",
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
});
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
+193 -2
View File
@@ -14,6 +14,8 @@
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
* auto-setup calls against a cwd that must actually exist on disk first.
* An optional "Create/update readme file" checkbox starts the lane's terminal
* run right after creation with `/sg-all:sg-md-writing` as its first message.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,7 +26,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool } from "../../lib/types";
/** Polls the lane until the background `git worktree add` finishes (status
@@ -94,6 +96,11 @@ export function AddLaneModal({
const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [writeDocs, setWriteDocs] = useState(false);
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
@@ -104,6 +111,7 @@ export function AddLaneModal({
profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed";
mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null);
const reset = () => {
@@ -114,6 +122,9 @@ export function AddLaneModal({
setBranches(null);
setBase("");
setPipeline("default");
setWriteDocs(false);
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
setBranchesError(null);
setError(null);
@@ -138,6 +149,26 @@ export function AddLaneModal({
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
api.aiRouters
.list()
.then((r) => {
if (cancelled) return;
setRouters(r.items);
// A profile deleted in another tab while this modal sat open would
// otherwise stay selected in state while the select showed no matching
// option — and submit would send a name the server no longer knows.
setRouter((current) =>
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
);
})
.catch(() => {
// Quiet: with no list the radio simply offers Native, and the lane is
// still created — a router is an optional extra, never a precondition.
if (!cancelled) setRouters([]);
})
.finally(() => {
if (!cancelled) setRoutersLoaded(true);
});
return () => {
cancelled = true;
};
@@ -177,6 +208,18 @@ export function AddLaneModal({
return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]);
// The checkbox below runs the docs skill by opening the lane's own terminal
// run with the slash command as its first message — a skill only exists
// inside a CLI session, so there is nothing server-side to call instead.
// Claude-only: codex has no slash commands (same reason LaneConsolePane
// prefills prose for a codex lane).
const startDocsRun = (laneId: number, cwd: string) =>
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
// Native is the built-in profile, so this is one call either way — there is
// no "no router" branch to keep in step with the real one.
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
@@ -192,7 +235,29 @@ export function AddLaneModal({
pipeline,
tool,
});
// Report the lane the INSTANT it exists server-side. Everything after
// this is post-creation setup that can fail on its own, and letting one
// of those failures skip `onAdded` would leave a real lane invisible in
// the list until some unrelated refetch happened to pick it up.
// Worktree mode below already works this way.
onAdded(result.lane);
// Router first: a session that booted on the vendor's API would keep
// talking to it for its whole life.
const [routerOutcome, docsOutcome] = await Promise.allSettled([
applyRouter(result.lane.id),
writeDocs && tool === "claude"
? startDocsRun(result.lane.id, result.lane.cwd)
: Promise.resolve(null),
]);
const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected");
if (failed && failed.status === "rejected") {
// The lane exists and is already listed — name what did not finish
// instead of closing on a half-set-up lane.
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
setBusy(false);
return;
}
reset();
onClose();
} catch (err) {
@@ -226,10 +291,11 @@ export function AddLaneModal({
return;
}
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id),
api.lanes.agentsInstall(result.lane.id),
api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]);
setSetupResult({
profile:
@@ -240,14 +306,23 @@ export function AddLaneModal({
: "failed",
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
});
if (writeDocs && tool === "claude") {
await startDocsRun(result.lane.id, provisionedLane.cwd);
}
if (import.meta.env.DEV) {
console.info("[add-lane] auto-setup result:", {
profile:
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
// Never the profile itself: it would put an endpoint and a lane id in
// the browser console. (The key is never sent here at all.)
router: routerOutcome.status,
});
}
@@ -390,6 +465,117 @@ export function AddLaneModal({
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active = option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
{tool === "claude" && (
<label
// No tick box: the card itself lights up when selected, the same
// treatment LaneStripCard uses, which makes a second indicator of
// the same state redundant. The input stays and only goes visually
// hidden — it is still what a keyboard toggles and what a screen
// reader announces — so the card carries the focus ring for it.
className={`block cursor-pointer rounded-lg border p-3 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
writeDocs
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="checkbox"
// The wrapping label also holds the hint, so name the input
// itself — otherwise its accessible name is label + hint.
aria-label={t("addLaneWriteDocsLabel")}
checked={writeDocs}
onChange={(e) => setWriteDocs(e.target.checked)}
className="sr-only"
/>
<span
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
>
{t("addLaneWriteDocsLabel")}
</span>
<span className="mt-1 block text-[10px] text-fg-muted">
{t("addLaneWriteDocsHint")}
</span>
</label>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
@@ -445,6 +631,11 @@ export function AddLaneModal({
},
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
{
label: t("addLaneSetupRouter"),
ok: setupResult.router !== "failed",
skipped: setupResult.router === "skipped",
},
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
profileInit: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
run: { start: vi.fn() },
},
}));
@@ -131,8 +134,49 @@ beforeEach(() => {
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply)
.mockReset()
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [nativeRouter()] });
});
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
function nativeRouter() {
return {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
}
/** A saved router profile, for the tests that need the radio to have a target. */
function savedRouter(over: Record<string, unknown> = {}) {
return {
...nativeRouter(),
name: "9router",
label: "9Router",
builtin: false,
hasKey: true,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
...over,
};
}
describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
renderModal();
@@ -398,6 +442,173 @@ describe("AddLaneModal — folder browse", () => {
});
});
describe("AddLaneModal — create/update readme", () => {
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
vi.mocked(api.run.start)
.mockReset()
.mockResolvedValue({} as never);
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByLabelText("Create/update readme file"));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 9,
cwd: "/Users/tester/projects/repo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("is hidden for a codex lane, which has no slash commands", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Codex" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
describe("AddLaneModal — AI Router", () => {
it("applies native without asking, so a lane always has a declared endpoint", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
it("sends the picked profile once AI Router is chosen", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
it("keeps the radio on Native when no profile is saved, and says why", async () => {
// Choosing "AI Router" with nothing to point at would create a lane whose
// declared endpoint does not exist.
renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
it("creates the lane anyway when the profile list cannot be fetched", async () => {
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
});
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
it("reports the adopted lane even when applying the router fails", async () => {
// `ensure` already created it server-side; skipping onAdded would hide a
// real lane from the list until some unrelated refetch found it.
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
});
});
describe("AddLaneModal — a router profile that disappeared", () => {
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
// Deleted in another tab while this modal sat open: the select would show
// no matching option while state still held the dead name.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const { rerender } = renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
// Re-open with the profile gone from the server's answer.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
// Clicking it early resolved back to Native with no feedback, which reads
// as a dead control.
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
vi.mocked(api.aiRouters.list).mockReturnValue(
new Promise((resolve) => {
resolveList = resolve;
}) as never
);
renderModal();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
resolveList({ items: [nativeRouter(), savedRouter()] });
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
+11 -8
View File
@@ -521,14 +521,17 @@ function UnifiedRunRowView({
}) {
const { t } = useTranslation("run");
const startedDate = new Date(row.startedAt);
const startedLabel = isNaN(startedDate.getTime())
? "-"
: startedDate.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
const startedLabel =
!row.startedAt || isNaN(startedDate.getTime())
? "-"
: startedDate.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// Without mode distinction, offer resume for any finished run with a session.
// An external session is live but has no attachable tmux session, so Resume
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
@@ -242,6 +242,14 @@ describe("ActiveRunsSwitcher", () => {
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
});
it("shows a dash, not the epoch, for a row with no start time", () => {
// A run whose persisted row is missing arrives with startedAt 0. Rendering
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
renderModal([row("no-start", { startedAt: 0 })]);
expect(screen.getByText("-")).toBeTruthy();
expect(screen.queryByText(/Jan 1,/)).toBeNull();
});
it("fires attach with the run id of the row that was clicked", () => {
const { spies } = renderSwitcher();
openModal();
+11 -1
View File
@@ -40,6 +40,8 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
"addLaneWriteDocsLabel": "Create/update readme file",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
@@ -132,5 +134,13 @@
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD",
"title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Router profile",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneSetupRouter": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…"
}
+40 -1
View File
@@ -445,6 +445,45 @@
"label": "Tools",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex"
"codex": "Codex",
"aiRouter": "AI Router"
},
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
"colModel": "Model",
"colKey": "API key",
"keySet": "Saved",
"keyMissing": "None",
"fieldLabel": "Name",
"fieldLabelHint": "What you will see in the lane form. Free text.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
"opus": "Opus",
"sonnet": "Sonnet",
"haiku": "Haiku",
"smallFast": "Small / fast",
"subagent": "Subagent"
},
"save": "Save",
"cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"loading": "Loading profiles…",
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}"
}
}
+2 -1
View File
@@ -25,5 +25,6 @@
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover."
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…"
}
+11 -1
View File
@@ -40,6 +40,8 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
@@ -132,5 +134,13 @@
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Profile router",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneSetupRouter": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…"
}
+40 -1
View File
@@ -445,6 +445,45 @@
"label": "Công cụ",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex"
"codex": "Codex",
"aiRouter": "AI Router"
},
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
"colModel": "Model",
"colKey": "API key",
"keySet": "Đã lưu",
"keyMissing": "Chưa có",
"fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
"opus": "Opus",
"sonnet": "Sonnet",
"haiku": "Haiku",
"smallFast": "Nhỏ / nhanh",
"subagent": "Subagent"
},
"save": "Lưu",
"cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"loading": "Đang tải danh sách profile…",
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}"
}
}
+2 -1
View File
@@ -25,5 +25,6 @@
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục."
"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…"
}
+91
View File
@@ -1868,6 +1868,40 @@ export const api = {
}),
},
// ──────────────────────────────── AI Router API ─────────────────────────────
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
* lane can be pointed at instead of the vendor's own API. Managed in the
* **AI Router** tab of Settings; attached to a lane when the lane is created.
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
/** POST /api/ai-routers - save a new profile. */
create: (body: AiRouterInput) =>
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
* keeps the stored one, which is what the edit form always does: it cannot
* display a key, so it has none to send back.
*/
update: (name: string, body: Partial<AiRouterInput>) =>
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/**
* DELETE /api/ai-routers/:name - also resets every lane using it back to
* native, on disk as well as in the DB.
*/
remove: (name: string) =>
request<{ removed: string; lanes: AiRouterLaneSync }>(
`/ai-routers/${encodeURIComponent(name)}`,
{ method: "DELETE" }
),
},
// ────────────────────────────────── Run API ─────────────────────────────────
/** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */
@@ -2434,6 +2468,16 @@ export const api = {
method: "POST",
body: "{}",
}),
/**
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
* or back at `native`. Writes the lane's own config on disk; only the
* profile NAME comes from here.
*/
routerApply: (id: number, profile: string) =>
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
method: "POST",
body: JSON.stringify({ profile }),
}),
mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
method: "POST",
@@ -2609,6 +2653,53 @@ export interface CcProfile extends CcAccountIdentity {
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** The per-tier model names an AI Router profile carries. Each maps to one env
* var Claude Code reads; a router that names its models once fills them all
* from the profile's `model`. */
export interface AiRouterModels {
opus: string;
sonnet: string;
haiku: string;
smallFast: string;
subagent: string;
}
/** One saved AI Router profile, as the API returns it — **never** with a key. */
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** Empty string for `native`, an http(s) URL otherwise. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
hasKey: boolean;
model: string;
models: AiRouterModels;
savedAt?: string;
}
/** How an edit or a delete landed on the lanes already using that profile.
* Attaching copies a profile, so the store alone is not the whole truth: a
* lane listed in `failed` still carries the OLD key and endpoint. */
export interface AiRouterLaneSync {
updated: number;
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
failed: string[];
}
/** Body accepted when creating or updating a profile. */
export interface AiRouterInput {
label: string;
baseUrl: string;
/** Omit on update to keep the stored key. */
apiKey?: string;
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
}
/** One markdown-based config artifact (skill/agent/command/output-style),
* as summarized by the `ccConfig` list endpoints. The list endpoints return
* a lightweight summary (frontmatter + a preview) rather than full contents;
+393
View File
@@ -0,0 +1,393 @@
/**
* @file AiRouterConfig.tsx
* @description The **AI Router** tab of Settings — CRUD for the named router
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
* at instead of the vendor's own API.
*
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one
* copies it into that lane's own config, so editing or deleting a profile here
* does reach every lane already using it — the server re-applies it, or resets
* those lanes to native, and reports any lane it could not touch. That report
* is surfaced: a rotated key that silently missed two lanes would leave the
* user believing a revoked key is out of use when it is not.
*
* The built-in `native` row is rendered read-only: it is the vendor's own API,
* and it exists so that "go back to native" is the same operation as "use a
* router" rather than a special case.
*
* An API key is write-only here by design — the server never returns one, so
* the form shows whether a key is saved, never the key.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
apiKey: string;
models: Partial<AiRouterModels>;
};
const emptyForm = (): FormState => ({
label: "",
baseUrl: "",
apiKey: "",
model: "",
models: {},
});
export function AiRouterConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<AiRouter[]>([]);
const [error, setError] = useState<string | null>(null);
// Not an error — the profile itself saved. It is the lanes that did not all
// follow, which the user must be told about rather than left to assume.
const [laneWarning, setLaneWarning] = useState<string | null>(null);
const [editing, setEditing] = useState<AiRouter | null>(null);
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
// Distinct from "no profiles": an empty table rendered before the first fetch
// resolves tells the user their profiles are gone. CodexConfig draws the same
// distinction for the same reason.
const [loading, setLoading] = useState(true);
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
// stable `t`, but nothing in this component's contract guarantees that, and
// the moment it changes identity per render this callback does too — which
// re-fires the effect below, which calls load(), which renders again. That
// loop also clears `error` on every pass, so a failed save would flash and
// vanish. Keeping the message untranslated is the cheaper half of the trade.
const load = useCallback(async () => {
try {
setItems((await api.aiRouters.list()).items);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const startAdd = () => {
setEditing(null);
setForm(emptyForm());
setOpen(true);
};
const startEdit = (item: AiRouter) => {
setEditing(item);
// No apiKey: the server never sent one. Blank means "keep what is stored".
setForm({
label: item.label,
baseUrl: item.baseUrl,
apiKey: "",
model: item.model,
models: { ...item.models },
});
setOpen(true);
};
/** Report any lane the server could not re-point, or clear a stale report. */
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
setLaneWarning(
lanes && lanes.failed.length
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
: null
);
};
const submit = async () => {
setBusy(true);
setError(null);
setLaneWarning(null);
try {
// Send only the tiers that differ from the headline model, so the server
// keeps filling the rest from it — that is what makes one symbolic name
// enough for a router that wants the same value everywhere.
const models: Partial<AiRouterModels> = {};
for (const tier of TIERS) {
const value = (form.models[tier] || "").trim();
if (value && value !== form.model.trim()) models[tier] = value;
}
const body: AiRouterInput = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
models,
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes);
else await api.aiRouters.create(body);
setOpen(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const remove = async (item: AiRouter) => {
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
setError(null);
setLaneWarning(null);
try {
reportLanes((await api.aiRouters.remove(item.name)).lanes);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<RouterIcon className="h-4 w-4 text-fg-muted" />
{t("aiRouter.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
</div>
<button
type="button"
onClick={startAdd}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("aiRouter.add")}
</button>
</div>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
{laneWarning && (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
{laneWarning}
</p>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name} className="border-t border-border">
<td className="px-3 py-2">
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
{item.label}
</span>
{item.builtin && (
<span className="mt-0.5 block text-[10px] text-fg-muted">
{t("aiRouter.builtinHint")}
</span>
)}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
{item.baseUrl || "—"}
</td>
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
<td className="px-3 py-2 text-fg-secondary">
{item.builtin
? "—"
: item.hasKey
? t("aiRouter.keySet")
: t("aiRouter.keyMissing")}
</td>
<td className="px-3 py-2 text-right">
{!item.builtin && (
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`${t("aiRouter.edit")} ${item.label}`}
onClick={() => startEdit(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${t("aiRouter.remove")} ${item.label}`}
onClick={() => remove(item)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
)}
</td>
</tr>
))}
{(loading || items.length <= 1) && (
<tr className="border-t border-border">
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
</h3>
<div className="space-y-3">
<Field
id="router-label"
label={t("aiRouter.fieldLabel")}
hint={t("aiRouter.fieldLabelHint")}
value={form.label}
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
/>
<Field
id="router-base-url"
label={t("aiRouter.fieldBaseUrl")}
hint={t("aiRouter.fieldBaseUrlHint")}
value={form.baseUrl}
mono
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
/>
<Field
id="router-api-key"
label={t("aiRouter.fieldApiKey")}
hint={
editing && editing.hasKey
? t("aiRouter.fieldApiKeyKeep")
: t("aiRouter.fieldApiKeyHint")
}
value={form.apiKey}
type="password"
mono
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
/>
<Field
id="router-model"
label={t("aiRouter.fieldModel")}
hint={t("aiRouter.fieldModelHint")}
value={form.model}
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
{t("aiRouter.perTier")}
</summary>
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
<div className="mt-2 space-y-2">
{TIERS.map((tier) => (
<Field
key={tier}
id={`router-model-${tier}`}
label={t(`aiRouter.tier.${tier}`)}
value={form.models[tier] || ""}
mono
placeholder={form.model}
onChange={(v) =>
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
}
/>
))}
</div>
</details>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
>
{t("aiRouter.cancel")}
</button>
<button
type="button"
onClick={submit}
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{t("aiRouter.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function Field({
id,
label,
hint,
value,
onChange,
mono,
type = "text",
placeholder,
}: {
id: string;
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
mono?: boolean;
type?: string;
placeholder?: string;
}) {
return (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
{label}
</label>
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
mono ? "font-mono" : ""
}`}
/>
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
</div>
);
}
+4 -2
View File
@@ -1,16 +1,17 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiRouterConfig } from "./AiRouterConfig";
interface ToolTab {
id: string;
@@ -31,6 +32,7 @@ const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
];
/**
@@ -0,0 +1,178 @@
/**
* @file Tests for the AI Router settings tab — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
vi.mock("../../lib/api", () => ({
api: {
aiRouters: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn() },
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.update)
.mockReset()
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
vi.mocked(api.aiRouters.remove)
.mockReset()
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
});
describe("AiRouterConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiRouterConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
apiKey: "sk-typed",
})
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("warns when a rotated key did not reach every lane using the profile", async () => {
// The whole point of pushing an edit out to the lanes is that a revoked key
// stops being used. A lane that silently kept the old one must be named,
// or the user believes a rotation is complete when it is not.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.aiRouters.update).mockResolvedValue({
...nineRouter,
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiRouters.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.5.1 (rebuild client to match)
UI build v1.6.3 (rebuild client to match)
</p>
</div>
<div
+35 -1
View File
@@ -245,7 +245,9 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive lane action
@@ -1716,6 +1718,38 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
---
### AI Router profiles
```
GET /api/ai-routers Every saved profile, the built-in `native` one first
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile
```
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing; attaching happens once, when a lane is created.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
---
## WebSocket API
### Connection
+2 -2
View File
@@ -272,12 +272,12 @@ Because a lane's services are fully detached, restarting the dashboard (or `agen
| Command | Description |
| ------- | ----------- |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
| `agenthub reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (derived from the `origin` remote); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (a checkout derives the repo from its `origin` remote; a plugin-cache install, which has no clone and often no `.git` at all, derives it from the marketplace manifest on disk); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
| `agenthub clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
| `agenthub open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
| `agenthub version` | Print the agenthub version (also `--version` / `-v`) |
+9 -1
View File
@@ -139,6 +139,11 @@ graph TB
- Node.js >= 20.0.0
- npm >= 9.0.0
- git — every lane operation shells out to it
- tmux — only for the browser run console and `agenthub lanes shell`; both
execute the binary directly and have no fallback. Per-OS install commands and
the two-sided check (your PATH vs the server's) are in
[INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages).
### Setup
@@ -451,7 +456,10 @@ Environment=NODE_ENV=production
Environment=PORT=4820
Environment=DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
# `always`, not `on-failure`: a graceful SIGTERM exits 0, which `on-failure`
# reads as "meant to stop" — one stray kill and the dashboard stays down.
# `systemctl stop` still stops it. Same reasoning as the plugin's own unit.
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
+4
View File
@@ -43,6 +43,10 @@ The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. The UI offers the choice only at creation, and with no profile saved the radio stays on Native and says so. Unlike the lane's tool, though, this one is not immutable: `POST /api/lanes/:id/router/apply` re-points an existing lane, rewriting its config and its `router_profile` column together. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
## Lane tool: Claude or Codex
+134 -12
View File
@@ -1,6 +1,6 @@
# AgentHub — Plugin Marketplace
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server, `agenthub` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
@@ -129,13 +129,14 @@ milliseconds — the real work happens in a detached worker, so a session never
waits on an install:
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
2. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
3. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Legacy hook cleanup** — see above.
6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
7. **UI build** — `client/` is copied into the runtime dir and built there (`npm install && npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (API and MCP still work, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed.
8. **Server** — on Linux with `systemd --user` available, install/refresh a
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — on Linux with `systemd --user` available, install/refresh a
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
of a bare spawn, so the server is supervised and comes back after a reboot
with no Claude Code session involved. Everywhere else (macOS, Windows, or
@@ -143,7 +144,11 @@ waits on an install:
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
`client-dist` — scoped to this session, same as before this fell back to
systemd.
9. **Record state** — `runtime/state.json`.
10. **Record state** — `runtime/state.json`.
11. **Prune retired cache copies** — delete the plugin-cache directories earlier updates left behind (see [Pruning the plugin cache](#pruning-the-plugin-cache)). After step 10, so a prune that goes wrong cannot also cost the record of a bootstrap that went right.
12. **Hand over to systemd** — only when a unit of ours already owns the server
(see [Who may stop the server](#who-may-stop-the-server)). Deliberately
after step 10.
The first run takes a few minutes — most of it is the UI build, which is what
makes client-only routes (`http://localhost:4820/run`, and every other page)
@@ -152,6 +157,66 @@ goes to `~/.claude/agenthub/runtime/bootstrap.log`; `npm run build`'s own
output goes to `client-build.log` next to it; the server's own output goes to
`server.log`.
### A retired build never acts
`claude plugin update` unpacks the new version into a **new** cache directory
and prints "Restart to apply changes". A Claude Code process that was already
running does not: it keeps loading the OLD directory — skills, commands and
hooks included — until it is restarted.
That used to be self-inflicting. Every session such a process opened ran the
OLD `plugin-bootstrap.js`, which saw a `state.json` naming a different root,
concluded the server had to be replaced, repointed `agenthub.service` at itself
and restarted the dashboard onto the version the user had just upgraded away
from. The dashboard then reported the old version and offered the update again
— once per session, forever, while `installed_plugins.json` and the cache both
said the new version was installed. A `/agenthub-update` from that session
"succeeded" and changed nothing.
So the bootstrap now asks first (`supersededBy`): if Claude Code's
`~/.claude/plugins/installed_plugins.json` records a **sibling** directory as
the installed build of this plugin, this build logs one line and returns
`superseded` without touching dependencies, the unit, or the server. `--force`
does not override it — the self-update chain runs `--force` from the NEW
directory, which is never superseded, so the only `--force` this can refuse is
one that would deliberately downgrade.
It cannot hand over to the new build either: the process it would have to
replace is the one loading it. Restarting Claude Code is the fix, and the log
line says so.
Matching is by sibling directory rather than by the `<plugin>@<marketplace>`
key, which holds without parsing a cache path or trusting a name. An entry
pointing at a directory that no longer exists is ignored — a phantom must never
wedge a bootstrap shut.
### Pruning the plugin cache
Claude Code unpacks every update beside the last one and does not reliably
remove the old. A machine that tracks releases closely accumulates a full copy
of this repo — `node_modules` and all — per release; a dozen of them is
gigabytes.
Step 11 of the bootstrap (`pruneRetiredPluginRoots`) deletes them. This is the
only place anything in the plugin touches the cache, and the rule it appears to
break ("never write into the plugin cache") is about the *runtime* living
outside it, not about keeping retired copies forever.
Four independent readings of "still in use" are protected, and nothing matching
any of them is ever deleted:
| Protected | Why |
|---|---|
| This build | It is running. |
| Every `installPath` in `installed_plugins.json`, at any scope | Claude Code says it is installed. |
| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. |
| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. |
Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback
cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error.
### Auto-start on boot
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
@@ -172,11 +237,68 @@ On macOS, Windows, or a Linux box without systemd, nothing changes: the
server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always.
### Who may stop the server
**When a `agenthub.service` unit of ours exists, nothing else may stop the
dashboard.** The bootstrap that performs a self-update is spawned by the
running server, so it lives inside that server's systemd cgroup. SIGTERM-ing
the server from there breaks two ways at once:
- the server exits **0**, and the unit's old `Restart=on-failure` read a clean
exit as "meant to stop" — so systemd did not bring it back;
- `KillMode=control-group` (systemd's default) reaps **every** process in the
cgroup when the main process exits, including the bootstrap itself — mid-run,
before it could repoint the unit at the new plugin root or write
`runtime/state.json`.
The result was a dashboard that went down on every self-update and stayed down
until some later Claude Code session happened to bootstrap again, leaving a
stale `.bootstrap.lock` and a `state.json` still naming the previous version.
So the order is fixed, and `scripts/systemd-service.js` splits its API to
enforce it:
| Situation | What the bootstrap does |
|---|---|
| A unit of ours owns the server (`unitIsOurs`) | Never stops it. `writeUserService` repoints the unit at the new plugin root **without starting anything**, the rest of the bootstrap runs, state is written, the lock is released — and only then `restartUserService` asks systemd to restart, with `--no-block` so being killed by its own request costs nothing. |
| No unit, or a foreign one | Unchanged: stop the old server, then `installUserService` (or a detached spawn where there is no systemd). Nothing here is inside a unit's cgroup, so the stop is safe. |
The unit is written with **`Restart=always`**, not `on-failure`: a graceful
SIGTERM from anything at all would otherwise leave the dashboard down for good.
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
the Settings panel do — still stops it.
`agenthub service install` (the checkout path) keeps using `installUserService`:
it runs from your own shell, outside the unit's cgroup, so it may block and
start in one step.
**A unit pointing at a retired plugin root repairs itself on the next session.**
This is the residue an interrupted update leaves behind, and it is invisible
while the dashboard is up: the server runs fine, every doctor row reads OK, and
then a reboot starts a cache directory Claude Code deleted during the update —
so the dashboard never comes back, for a reason nothing recorded. The fast path
therefore checks it (`unitIsStale`, pure fs — it may not spawn a process on
every `SessionStart`) and refuses to short-circuit, and the bootstrap rewrites
the unit.
Repairing the *unit* is not restarting the *server*: when the running server is
current and healthy, the corrected unit is simply left to take effect at the
next start (`pendingSystemdRestart = systemdOwned && needsServer`). Restarting
a working dashboard to fix a file that only matters at boot would be churn the
user feels for nothing. For the same reason the detached-spawn fallback is
guarded by `needsServer` — entering that branch for a stale unit alone would
start a second server onto a port the live one already holds.
`systemdOwned` requires the unit to be **active**, not merely present: an
installed-but-stopped unit owns nothing, and treating it as the owner would
skip the stop and then ask systemd to bind an occupied port — a crash loop,
courtesy of `Restart=always`.
### Commands
| Command | Does |
|---|---|
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **skill plugins** (see below), MCP build freshness, UI bundle |
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle |
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes |
| `/ship-full <requirement>` | Put the lane on the 16-node `ship-feature` template, then run `/agenthub:ship-feature-lane` **in this session**. The in-session twin of `agenthub ship-full` (which spawns its own `claude` from a terminal) |
@@ -268,8 +390,8 @@ not assumed. It does **not**:
For a clean machine, do all of this yourself:
```bash
# 1. Stop the server. On Linux, check for the systemd unit first — a bare
# `kill` just gets restarted by it (Restart=on-failure).
# 1. Stop the server. On Linux, disable the systemd unit FIRST — the unit is
# Restart=always, so a bare `kill` is always undone by systemd.
systemctl --user disable --now agenthub.service 2>/dev/null
rm -f ~/.config/systemd/user/agenthub.service && systemctl --user daemon-reload 2>/dev/null
# Fallback for a detached (non-systemd) server, or if the unit above was absent:
+6 -4
View File
@@ -70,10 +70,12 @@ graph LR
**Quick Start:**
1. Read [SETUP.md](../SETUP.md)
2. Run `npm run setup`
3. Run `npm run dev`
4. Open browser to `http://localhost:5173`
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
2. Read [SETUP.md](../SETUP.md)
3. Run `npm run setup`
4. Run `npm run dev`
5. Open browser to `http://localhost:5173`
---
@@ -0,0 +1,106 @@
# AI Router profiles — design
## Goal
Configure named AI Router endpoints (9Router, OpenRouter, …) once in Settings,
then pick one when creating a lane so that lane's Claude Code or Codex session
talks to the router instead of the vendor's own API — without hand-editing a
settings file per project, and without a second copy of the whole settings file
drifting out of sync with the first.
## What the reference setup showed
The user today keeps two whole files, `~/.claude/settings.json` (native) and
`~/.claude/settings2.json` (9Router), and swaps them. Diffing the two:
- They differ in exactly one place that matters — the `env` block, 8 keys:
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`,
`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
`ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,
`CLAUDE_CODE_SUBAGENT_MODEL`.
- Every other difference (`hooks`, `enabledPlugins`, `statusLine`,
`effortLevel`, …) is drift between two hand-maintained copies. Removing that
drift is half the point of this feature.
- "Native" is **not** the absence of the connection variables: the native file
sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` to empty strings and
carries real `claude-*` model names.
- The 9Router file sets all six model slots to one symbolic value (`Highest`),
which the router resolves. Model values are therefore free text, and "one
value for every tier" is the common case, not the exception.
## Decisions (from brainstorming)
1. **The Settings tab only stores profiles.** It does not attach anything.
Attaching happens when a lane is created.
2. **Native is a built-in profile, not a code branch.** Its `baseUrl`/`apiKey`
are empty and its six model slots hold the native `claude-*` names. It cannot
be edited or deleted. Switching native ↔ router is therefore one write path
with no special case.
3. **A profile carries six model slots plus an "apply to all" convenience
field**, mirroring exactly the env keys Claude Code reads.
4. **The API key never leaves the server.** Profiles are stored one JSON file
each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, file `0600`),
following `cc-profiles.js`. `GET` returns metadata plus `hasKey: true`,
never the key.
5. **A lane's router is fixed at creation**, like `lanes.tool`: a new nullable
`lanes.router_profile` column, deliberately absent from `PATCHABLE`.
6. **AgentHub never reads `~/.9router/db.json`.** That is another application's
private store; parsing it would couple this dashboard to a format its owner
is free to change. The user pastes a base URL and a key, as they do today.
## Attaching, per tool
The two tools differ because the tools themselves differ, not by choice.
**Claude lane** — write the profile's eight env keys into
`<lane.cwd>/.claude/settings.local.json`, merging into any `env` already there
and leaving every other key in the file untouched. `settings.local.json` rather
than `settings.json` because the value written includes an API key and that file
is the one Claude Code's own convention keeps out of git.
**Codex lane** — Codex has no project-level config: `codex doctor` reports one
loaded file, `~/.codex/config.toml`. It does have
`-p, --profile <name>`, documented as *"Layer `$CODEX_HOME/<name>.config.toml`
on top of the base user config"*. So each router profile is written once to
`~/.codex/<name>.config.toml` (`model_provider` plus a `[model_providers.<id>]`
table with `base_url`, `env_key`, `wire_api`), and the lane's spawn adds
`-p <name>`. The base `~/.codex/config.toml` is never modified. Codex reads the
key from the environment variable named by `env_key`, so the spawn also injects
that variable into the tmux pane — `pty-run.js` already passes an `env` map to
`tmux new-session -e`.
## Surface
- `server/lib/ai-routers.js` — store: list/get/create/update/remove, name
validation, `0700`/`0600`, key never returned by the list shape.
- `server/lib/router-apply.js` — the two writers above, taking a lane row and a
profile. Target directories come from the lane row in the DB, never from a
request body — the same narrowing every other lane write path uses.
- `server/routes/ai-routers.js` → `/api/ai-routers`: `GET /`, `POST /`,
`PATCH /:name`, `DELETE /:name`, reusing `cc-profiles.js`'s error-code→status
table.
- `POST /api/lanes/:id/router/apply` — a fourth member of the auto-setup set the
Add-lane modal already fires (`profileInit`, `agentsInstall`, `mcpSync`).
- `client/src/pages/AiRouterConfig.tsx` + one line in `SettingsHub.tsx`'s
`TOOL_TABS`, placed after Codex.
- `AddLaneModal`: a Native / AI Router radio, and a profile select shown only
when AI Router is chosen.
- DB: `ALTER TABLE lanes ADD COLUMN router_profile TEXT`, nullable.
## Out of scope
Changing an existing lane's router (create-time only, exactly like `tool`),
attaching at user/global scope, editing Codex's base `config.toml`, and reading
any router vendor's own configuration store.
## Verification
Server tests against a temporary `CLAUDE_HOME`/`CODEX_HOME`: store CRUD and its
permission bits, the key never appearing in a list response, the
`settings.local.json` merge preserving unrelated keys, and the generated
`<name>.config.toml`. Client tests: the tab renders and creates a profile, and
the Add-lane radio sends the chosen profile only when AI Router is selected.
The load-bearing assumption — a project-level `settings.local.json` `env` block
overrides the user-level one per key — is confirmed by the user from their own
earlier testing.
+2 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.5.1
version: 1.6.3
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -6807,7 +6807,7 @@ paths:
schema:
type: object
additionalProperties: true
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.'
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing to pull: either release_check is "ok" and the published release is already installed, or the release check did not succeed (the message says which — an unknown is never reported as "nothing newer"). When a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.'
'500':
description: Update apply failed
content:
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.5.1",
"version": "1.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.5.1",
"version": "1.6.3",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
@@ -1943,7 +1943,7 @@
"license": "MIT"
},
"node_modules/path-expression-matcher": {
"version": "1.6.1",
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
"funding": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.5.1",
"version": "1.6.3",
"bin": {
"agenthub": "bin/agenthub.js"
},
+11 -1
View File
@@ -1,5 +1,5 @@
---
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build
---
Run the diagnostic and report what it found. Do not fix anything unless the user
@@ -31,6 +31,16 @@ Print the output verbatim, then add at most three lines of guidance based on the
means that attempt has not run or did not succeed — say `/agenthub-update` retries
it, and print the `claude plugin install …` line from the output as the manual
route.
- `WARN systemd` → when it says the unit names a **previous plugin version**,
the dashboard is running now but will NOT come back after a reboot. Starting
a new Claude Code session repairs it; print the `--force` command from the
output as the immediate fix. "installed but not running" just needs
`systemctl --user start agenthub`.
- `WARN tmux` → only the run console, `agenthub lanes shell` and the
session-reload keystrokes are affected; monitoring, lanes and the CLI are not.
Print the install command from the output. If the row says `ok` but Start Run
still fails, the server has a different PATH than this check — ask it directly
with `curl -s localhost:4820/api/run/tmux`.
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
quotes a build error, report that error — it is the actual problem.
+282 -31
View File
@@ -14,8 +14,14 @@
* scoped to this session, same as before.
*
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
* on every plugin update.
* the plugin cache, which Claude Code replaces wholesale on every plugin
* update. The one thing this file does reach into the cache for is deleting
* the copies those updates leave behind (`pruneRetiredPluginRoots`).
*
* A bootstrap that is no longer the installed build does nothing at all
* (`supersededBy`): an old cache directory, still loaded by a Claude Code
* process that has not been restarted, would otherwise repoint systemd at
* itself and downgrade the dashboard once per session, forever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -40,14 +46,65 @@ function runtimeDir() {
return path.join(getDataDir(), "runtime");
}
function pluginVersion() {
function pluginVersion(root = PLUGIN_ROOT) {
try {
return require(path.join(PLUGIN_ROOT, "package.json")).version;
return require(path.join(root, "package.json")).version;
} catch {
return "unknown";
}
}
/* -------------------------------------------------- superseded plugin build */
/** Claude Code's own record of which build of each plugin is installed. */
function installedPluginsPath() {
return path.join(getClaudeHome(), "plugins", "installed_plugins.json");
}
/**
* The build Claude Code has installed IN PLACE OF this one, or null when this
* build is the installed one (or when there is no such record to read).
*
* `claude plugin update` unpacks the new version into a new cache directory and
* says "Restart to apply changes" — but a Claude Code process that was already
* running keeps loading the OLD directory, hooks and all. Every session it
* opens from then on runs the OLD bootstrap, which happily repoints the systemd
* unit at itself and restarts the dashboard onto the version the user just
* upgraded away from. The dashboard then reports the old version and offers the
* update again — forever, once per session, until Claude Code is restarted.
*
* So a bootstrap that is no longer the installed build does NOTHING. It cannot
* hand over either: the process it would have to replace is the one loading it.
*
* Matched by SIBLING directory rather than by `<plugin>@<marketplace>` key: the
* builds of one plugin are exactly the directories beside this one, and that
* holds without parsing a cache path or trusting a name. An entry naming a
* directory that no longer exists is ignored — a phantom must never wedge a
* bootstrap shut.
*/
function supersededBy(root = PLUGIN_ROOT) {
let data;
try {
data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
} catch {
return null; // no record, unreadable, or not a plugin install at all
}
const siblings = path.dirname(root);
const found = [];
for (const entries of Object.values((data && data.plugins) || {})) {
for (const entry of entries || []) {
const installed =
entry && typeof entry.installPath === "string" ? path.resolve(entry.installPath) : null;
if (!installed) continue;
// This build IS installed (possibly at one scope among several) — the
// whole question is settled, whatever any other entry says.
if (installed === root) return null;
if (path.dirname(installed) === siblings && fs.existsSync(installed)) found.push(installed);
}
}
return found[0] || null;
}
/* ------------------------------------------------------------------ state */
function statePath(rt = runtimeDir()) {
@@ -107,6 +164,12 @@ function fastPathOk(rt = runtimeDir()) {
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
// actual check (manifest + every user SKILL.md) runs once, in the worker.
if (!state.skillPluginsSettled) return false;
// A live server says nothing about whether it will come back. When our unit
// still names a previous plugin cache directory, everything looks healthy
// until the next reboot — and then the dashboard is simply gone, from a path
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
// path; `unitIsStale` deliberately spawns nothing.
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
return serverIsLive();
}
@@ -441,16 +504,133 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
}
}
/* ------------------------------------------------------ plugin-cache pruning */
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
const KEEP_RETIRED_ROOTS = 2;
/**
* Delete the cache directories earlier updates left behind.
*
* Claude Code unpacks every plugin update into a NEW directory beside the old
* one and does not reliably remove the old. A machine that tracks releases
* closely ends up with a dozen full copies of this repo — node_modules and all
* — which is what this clears.
*
* This is the one place anything here touches the plugin cache, and the file
* header's "never write into the cache" rule is about the RUNTIME living
* outside it, not about leaving retired copies forever. Everything still in use
* is protected, by four independent readings of "in use":
*
* - this build;
* - every build Claude Code records as installed, at any scope;
* - the directory our systemd unit runs from, foreign unit included;
* - any directory still named in the user's `settings.json` hooks — a Claude
* Code process that has not been restarted is still executing from there,
* and deleting it would break that session's hooks mid-flight. (This is
* exactly the directory `supersededBy` describes, seen from the other side.)
*
* …plus the newest `KEEP_RETIRED_ROOTS` of whatever is left, so a rollback has
* something to roll back to. Best effort throughout: a directory that will not
* delete is left alone, never an error.
*
* @returns {number} how many directories were removed
*/
function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
// Only inside Claude Code's plugin cache. For a git checkout the parent
// directory holds the user's own work and is never ours to delete from.
const cacheMarker = `${path.sep}plugins${path.sep}cache${path.sep}`;
if (!root.includes(cacheMarker)) return 0;
const cacheDir = path.dirname(root);
const keep = new Set([root]);
try {
const data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
for (const entries of Object.values((data && data.plugins) || {})) {
for (const entry of entries || []) {
if (entry && typeof entry.installPath === "string")
keep.add(path.resolve(entry.installPath));
}
}
} catch {
/* no record to read — the other three protections still apply */
}
try {
const unitRoot = require("./systemd-service").unitRoot("agenthub");
if (unitRoot) keep.add(path.resolve(unitRoot));
} catch {
/* best effort */
}
let settings = "";
try {
settings = fs.readFileSync(getSettingsPath(), "utf8");
} catch {
/* no settings file — nothing pinned by a hook */
}
let dirs;
try {
dirs = fs.readdirSync(cacheDir, { withFileTypes: true });
} catch {
return 0;
}
const retired = dirs
.filter((d) => d.isDirectory())
.map((d) => path.join(cacheDir, d.name))
.filter((dir) => !keep.has(dir) && !settings.includes(dir))
.map((dir) => {
let at = 0;
try {
at = fs.statSync(dir).mtimeMs;
} catch {
/* unreadable sorts oldest, so it goes first */
}
return { dir, at };
})
.sort((a, b) => b.at - a.at)
.slice(KEEP_RETIRED_ROOTS);
let removed = 0;
for (const { dir } of retired) {
try {
fs.rmSync(dir, { recursive: true, force: true });
removed += 1;
} catch {
/* in use, read-only, whatever — leaving it is always safe */
}
}
if (removed) {
log(rt, `removed ${removed} retired plugin cache ${removed === 1 ? "copy" : "copies"}`);
}
return removed;
}
/**
* The real work. Runs detached from the SessionStart hook, so its only channel
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
* reinstalls dependencies and restarts the server unconditionally.
*
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
*/
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
fs.mkdirSync(rt, { recursive: true });
// First, and ahead of the lock: a retired build has nothing to do and must
// not take the lock to discover it. `force` does NOT override this — the
// plugin-update chain runs `--force` from the NEW cache directory, which is
// never superseded, so the only `--force` this can refuse is one that would
// downgrade the user on purpose.
const newer = supersededBy();
if (newer) {
log(
rt,
`this build (${pluginVersion()}) has been replaced by ${pluginVersion(newer)} — doing nothing. ` +
`Restart Claude Code so it loads the new one; until then this session keeps running the old plugin.`
);
return "superseded";
}
if (!nodeVersionOk()) {
log(
rt,
@@ -465,6 +645,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
return "locked";
}
// Declared out here because the restart it guards has to happen in the
// `finally`, after the lock is released and the state is on disk: it is the
// step that may cost this process its own life.
let pendingSystemdRestart = false;
try {
const wantDeps = depsHash();
const state = readState(rt) || {};
@@ -504,44 +689,85 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
log(rt, "built the dashboard UI bundle");
}
const svc = require("./systemd-service");
// Who owns the running server decides how it may be retired. A unit of
// ours means systemd owns it — and this process is a descendant of that
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
// there does two fatal things at once: the clean exit(0) tells systemd
// "meant to stop", and `KillMode=control-group` reaps this bootstrap
// before it can write state or point the unit at the new cache dir. That
// is the bug that left a dashboard dead after every self-update until the
// next Claude Code session happened to bootstrap again.
// ACTIVE matters, not merely present: a unit of ours that is installed but
// stopped owns nothing right now. If a detached server is holding the port
// in that state, treating systemd as the owner would skip the stop and then
// ask systemd to start a second server onto a bound port — a crash loop,
// courtesy of Restart=always.
const systemdOwned =
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
// After a plugin update the running server executes code from a cache
// directory Claude Code has already discarded — retire it first.
// directory Claude Code has already discarded — retire it first. Only
// when nothing else owns its lifecycle; otherwise systemd retires it, in
// one restart job, once everything below is durable.
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
if (movedRoot && stopDashboard()) {
if (movedRoot && !systemdOwned && stopDashboard()) {
log(rt, "stopped the server started from the previous plugin version");
waitForPortsFree();
}
if (movedRoot || !serverIsLive()) {
const svc = require("./systemd-service");
const result = svc.systemctlAvailable()
? svc.installUserService({
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: PLUGIN_ROOT,
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
env: {
NODE_ENV: "production",
NODE_PATH: path.join(rt, "node_modules"),
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
// systemd --user units get systemd's own minimal PATH, not the
// shell's — forward it so the server can find `claude`/`codex`.
PATH: process.env.PATH || "",
},
})
: "unavailable";
// Two different needs, deliberately not collapsed. `needsServer` is "the
// running process must be replaced"; `unitStale` is "the unit names a cache
// directory that is no longer this one", which is invisible until a reboot
// and must be repaired even while a perfectly healthy server is up.
const needsServer = movedRoot || !serverIsLive();
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
if (needsServer || unitStale) {
const unit = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: PLUGIN_ROOT,
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
env: {
NODE_ENV: "production",
NODE_PATH: path.join(rt, "node_modules"),
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
// systemd --user units get systemd's own minimal PATH, not the
// shell's — forward it so the server can find `claude`/`codex`.
PATH: process.env.PATH || "",
},
};
// Two shapes of the same step. When systemd already owns the server the
// unit is only REWRITTEN here — pointing at the new plugin root — and
// the restart is deferred to `pendingSystemdRestart` below, because
// starting it now would kill this process mid-bootstrap. Otherwise
// there is no cgroup to be reaped by, and install-and-start is fine.
const result = systemdOwned
? svc.writeUserService(unit)
: svc.systemctlAvailable()
? svc.installUserService(unit)
: "unavailable";
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
// Only when the RUNNING process has to change. A stale unit beside a
// healthy server is now corrected on disk and left to take effect at
// the next start — restarting a working dashboard to fix a file that
// only matters at boot would be churn the user feels for no reason.
pendingSystemdRestart = systemdOwned && needsServer;
log(
rt,
result === "written"
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
: "systemd --user service already up to date"
result === "unchanged"
? "systemd --user service already up to date"
: unitStale && !needsServer
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
);
} else {
} else if (needsServer) {
// No systemd (macOS/Windows/WSL without it), or the unit exists but
// wasn't ours to touch — same detached-spawn fallback as before,
// scoped to this session only.
// scoped to this session only. Guarded by `needsServer`: entering this
// block for a stale unit alone must never spawn a second server onto
// the port the live one already holds.
const pid = startDashboard(rt);
log(
rt,
@@ -569,9 +795,22 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
},
rt
);
// After the state is durable, so a prune that goes wrong cannot also cost
// us the record of a bootstrap that went right.
pruneRetiredPluginRoots(rt);
return "ok";
} finally {
releaseLock(rt);
// LAST, and after the lock: systemd stops the old main process to start
// the new one, and this process shares its cgroup, so it may not outlive
// this call. Everything that had to be durable already is — the state
// file is written and the lock is gone, so even being killed here leaves
// no debris and no half-recorded bootstrap.
if (pendingSystemdRestart) {
log(rt, "handing the server over to systemd (restarting the unit)");
require("./systemd-service").restartUserService("agenthub");
}
}
}
@@ -596,7 +835,14 @@ if (require.main === module) {
// npm output and the result.
const result = bootstrap(runtimeDir(), { force });
if (force) {
console.log(result === "ok" ? "AgentHub runtime refreshed." : `bootstrap: ${result}`);
console.log(
result === "ok"
? "AgentHub runtime refreshed."
: result === "superseded"
? "A newer AgentHub plugin build is already installed — this one did nothing on purpose.\n" +
"Restart Claude Code so it loads the new build, then try again."
: `bootstrap: ${result}`
);
if (result !== "ok") process.exitCode = 1;
}
} else {
@@ -611,6 +857,11 @@ if (require.main === module) {
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
runtimeDir,
statePath,
readState,
+74 -1
View File
@@ -5,7 +5,8 @@
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
* declared package, not just that node_modules exists), server liveness,
* duplicate hook entries (the one failure that silently doubles every token and
* cost figure), the `agenthub` CLI launcher and its PATH, whether the slash
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
* every browser-started run depends on, whether the slash
* commands the pipeline templates name as node drivers are actually resolvable
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
* build error when it is missing.
@@ -82,6 +83,8 @@ function diagnose() {
);
add(...cliStatus());
add(...systemdStatus());
add(...tmuxStatus());
add(...skillPluginStatus());
add(...dashboardUiStatus(rt));
@@ -218,6 +221,76 @@ function skillPluginStatus() {
];
}
/**
* The failure this row exists for is silent by construction: a unit still
* naming a retired plugin cache directory keeps a running dashboard looking
* perfectly healthy, and only bites at the next reboot — when the server does
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
* in this report would show it.
*
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
* Windows or a Linux box without systemd there is no unit to have an opinion
* about.
*/
function systemdStatus() {
const svc = require("./systemd-service");
const unit = svc.unitPath("agenthub");
if (!svc.unitIsOurs("agenthub")) {
if (fs.existsSync(unit)) {
return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`];
}
return [
"ok",
"systemd",
svc.systemctlAvailable()
? "no unit — the server is started per session (install one with `agenthub service install`)"
: "not available on this platform — the server is started per session",
];
}
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
return [
"warn",
"systemd",
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
];
}
if (!svc.unitIsActive("agenthub")) {
return [
"warn",
"systemd",
"unit is installed and current but not running — start it with `systemctl --user start agenthub`",
];
}
return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"];
}
/**
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
* skill-plugins row: a partial feature loss must not fail a doctor run.
*
* This checks the doctor's own PATH. A server started as a systemd `--user`
* service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that.
*/
function tmuxStatus() {
if (require("../server/lib/tmux").isTmuxAvailable()) {
return ["ok", "tmux", "on PATH — the run console can start sessions"];
}
return [
"warn",
"tmux",
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
function countLegacyHookEntries() {
try {
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
+11 -1
View File
@@ -73,7 +73,17 @@ function buildClient({
// tarball against the lockfile's integrity hash, so a bad cache entry is
// refetched instead of silently reused. It needs a lockfile that matches
// package.json — fall back to `install` when the copy has none or has drifted.
const npmFlags = ["--no-audit", "--no-fund"];
//
// `--include=dev` is not optional here: the whole build toolchain (vite,
// typescript, @vitejs/plugin-react, tailwindcss, @types/*) lives in the
// client's devDependencies, and npm reads `NODE_ENV=production` as
// `--omit=dev`. Every bootstrap descended from the running server inherits
// that NODE_ENV — the self-update chain and any SessionStart fired from a
// tmux pane the server started — so the install silently dropped `vite`,
// `npm run build` failed, and the PREVIOUS bundle stayed in place: the
// dashboard kept serving the old version's UI while the API was already the
// new one. An explicit CLI flag outranks the env, which is the point.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
+12
View File
@@ -157,6 +157,18 @@ function main() {
}
log(`new plugin root: ${newRoot}`);
// Write the verdict BEFORE the handover, not only after it. The step below
// retires the process tree this helper hangs off, and an observed run reached
// the new server with no verdict file at all — so the dashboard reported
// `last_plugin_update: null` for an update that had in fact succeeded. A
// pre-written record is overwritten by the real outcome when this survives,
// and is the outcome when it does not.
record({
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
});
try {
// Foreground and blocking: this stops the old server (us), waits for the
// ports, then starts the new one. It must finish before we record success.
+136 -13
View File
@@ -26,9 +26,89 @@ function unitPath(name) {
return path.join(unitDir(), `${name}.service`);
}
// Test seam: swap the systemctl implementation so unit tests never talk to a
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
function __setExecImpl(fn) {
execImpl = fn;
}
function __reset() {
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
}
function systemctlAvailable() {
if (process.platform !== "linux") return false;
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0;
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
}
/**
* Is there a unit by this name, and did WE write it? The distinction decides
* whether the dashboard may be stopped by hand: a unit of ours means systemd
* owns the process, and SIGTERM-ing it out of band is the one thing that must
* never happen (see `restartUserService`).
*/
function unitIsOurs(name) {
try {
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
} catch {
return false;
}
}
/**
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
* stop during a plugin self-update — then left the dashboard down until some
* later Claude Code session happened to run the bootstrap. An explicit
* `systemctl stop` (what uninstall does) still stops it for good.
*/
/**
* Does our unit still point at `root`? A plugin update moves the install to a
* new cache directory, and a unit left naming the old one is a failure that
* shows NOTHING until the next reboot — the dashboard simply does not come
* back, from a path Claude Code garbage-collected weeks earlier.
*
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
* runs on every single SessionStart and may not spawn a process.
*
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
*/
function unitIsStale(name, root) {
let body;
try {
body = fs.readFileSync(unitPath(name), "utf8");
} catch {
return false; // no unit at all: nothing to be stale
}
if (!body.includes(MARKER)) return false;
return !body.includes(`\nWorkingDirectory=${root}\n`);
}
/**
* The directory our unit actually runs from, or null when there is no unit.
*
* Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is
* not ours to rewrite, but whatever directory it runs from is still a directory
* nothing here may delete. The one caller is the plugin-cache pruner.
*/
function unitRoot(name) {
let body;
try {
body = fs.readFileSync(unitPath(name), "utf8");
} catch {
return null;
}
const match = body.match(/^WorkingDirectory=(.*)$/m);
return match ? match[1].trim() : null;
}
/** Is the unit currently running? Costs a `systemctl` call — never call it
* from the fast path. */
function unitIsActive(name) {
if (!systemctlAvailable()) return false;
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "active";
}
function unitContent({ description, workingDir, execStart, env }) {
@@ -44,7 +124,7 @@ After=network.target
Type=simple
WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=on-failure
Restart=always
RestartSec=3
${envLines}
@@ -54,17 +134,18 @@ WantedBy=default.target
}
function systemctl(args) {
return spawnSync("systemctl", ["--user", ...args], { stdio: "inherit" });
return execImpl(args, { stdio: "inherit" });
}
/**
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
* cache path after an update) is reloaded and restarted.
* Writes (or refreshes) the unit file and enables it for boot — but starts
* nothing. Split out of `installUserService` for the plugin self-update, which
* must be able to point the unit at the new cache directory *before* anything
* stops the running server, and defer the restart to its very last act.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function installUserService({ name, description, workingDir, execStart, env = {} }) {
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ description, workingDir, execStart, env });
const target = unitPath(name);
@@ -72,22 +153,56 @@ function installUserService({ name, description, workingDir, execStart, env = {}
if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8");
if (!current.includes(MARKER)) return "foreign";
if (current === body) {
systemctl(["enable", "--now", `${name}.service`]);
return "unchanged";
}
if (current === body) return "unchanged";
}
fs.mkdirSync(unitDir(), { recursive: true });
fs.writeFileSync(target, body, "utf8");
systemctl(["daemon-reload"]);
systemctl(["enable", "--now", `${name}.service`]);
systemctl(["restart", `${name}.service`]);
systemctl(["enable", `${name}.service`]);
return "written";
} catch {
return "failed";
}
}
/**
* Ask systemd to restart the unit and do NOT wait for it.
*
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
* spawned by the running server, so it lives in that server's cgroup; the
* moment systemd stops the old main process, `KillMode=control-group` reaps
* the caller too. Blocking here means being killed mid-call, which is why the
* restart must be the last thing the bootstrap does — by then there is nothing
* left to lose.
*/
function restartUserService(name) {
if (!systemctlAvailable()) return "unavailable";
const res = systemctl(["restart", "--no-block", `${name}.service`]);
return res.status === 0 ? "restarted" : "failed";
}
/**
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
* cache path after an update) is reloaded and restarted.
*
* Safe for `agenthub service install`, which is invoked from a user's own
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
* is, and uses `writeUserService` + `restartUserService` instead.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function installUserService(opts) {
const result = writeUserService(opts);
if (result === "unchanged") {
systemctl(["enable", "--now", `${opts.name}.service`]);
} else if (result === "written") {
systemctl(["enable", "--now", `${opts.name}.service`]);
systemctl(["restart", `${opts.name}.service`]);
}
return result;
}
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
function uninstallUserService(name) {
if (!systemctlAvailable()) return "unavailable";
@@ -110,8 +225,16 @@ function enableLinger() {
module.exports = {
systemctlAvailable,
unitIsOurs,
unitIsStale,
unitRoot,
unitIsActive,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
unitPath,
__setExecImpl,
__reset,
};
+6 -1
View File
@@ -555,7 +555,12 @@ failures return `500` with `error.stderr`.
`start` returns `409 ERUNLIVE` when the lane's recorded run is still `spawning`
or `running`: overwriting `run_id` would orphan that child, and a later `reset`
would then `git clean -fd` a directory the orphan is still writing into.
would then `git clean -fd` a directory the orphan is still writing into. It
returns the same code when the lane's tmux pane is running an agent and the
request carried a `prompt` or `resumeSessionId` — `pty-run.js` will not type
over a live agent, and adopting the pane silently would answer 200 for a resume
that never spawned. `POST /api/run` maps that code to `409` too; a start with
neither field still adopts the pane.
Besides the stage a skill declares explicitly, every lane also carries a
server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
+5 -1
View File
@@ -369,7 +369,7 @@ describe("agenthub CLI — import & administration", () => {
assert.match(err, /Usage: agenthub import/);
});
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
it("doctor reports API, hooks, database, tmux, remotes, and uptime lines", async () => {
const { code, out } = await agenthub("doctor");
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
@@ -377,6 +377,10 @@ describe("agenthub CLI — import & administration", () => {
assert.match(out, /Claude Code hooks/);
assert.match(out, /Database/);
assert.match(out, /Server uptime/);
// Asked of the server (GET /api/run/tmux), not of this shell: a missing
// tmux is why Start Run fails, and the server's PATH is the one that
// decides. Reported either way, and never fatal.
assert.match(out, /tmux/);
assert.match(out, /Remote sources/);
});
+190
View File
@@ -0,0 +1,190 @@
/**
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
* native profile, and the guarantee that a stored API key never appears in a
* response the browser can read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
/** The smallest profile the API accepts. */
function nineRouter(over = {}) {
return {
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
...over,
};
}
describe("ai-routers — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = routers.listRouters();
assert.equal(items.length, 1);
assert.equal(items[0].name, "native");
assert.equal(items[0].builtin, true);
});
it("leaves the two connection variables empty rather than absent", () => {
// The reference native settings.json sets both to "" — an absent key would
// let a user-level router base URL leak into a lane meant to run native.
const native = routers.getRouter("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("refuses to be edited or removed", () => {
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
});
});
describe("ai-routers — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = routers.createRouter(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = routers.createRouter(nineRouter());
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
const dir = fs.statSync(ROUTERS_DIR);
assert.equal(file.mode & 0o777, 0o600);
assert.equal(dir.mode & 0o777, 0o700);
});
it("fills every model slot from `model` when no per-tier value is given", () => {
// The 9Router reference sets all six slots to one symbolic value; typing it
// once must be enough.
routers.createRouter(nineRouter());
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "Highest");
assert.equal(saved.models.sonnet, "Highest");
assert.equal(saved.models.haiku, "Highest");
assert.equal(saved.models.smallFast, "Highest");
assert.equal(saved.models.subagent, "Highest");
});
it("keeps a per-tier value that was given explicitly", () => {
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "PremiumCombo");
assert.equal(saved.models.sonnet, "Highest");
});
it("gives a second profile with the same label a free name", () => {
const a = routers.createRouter(nineRouter());
const b = routers.createRouter(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a router; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-routers — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
routers.createRouter(nineRouter());
const { items } = routers.listRouters();
const saved = items.find((i) => i.name === "9router");
assert.equal(saved.hasKey, true);
assert.equal(saved.apiKey, undefined);
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
});
it("is still absent when the profile has no key at all", () => {
routers.createRouter(nineRouter({ apiKey: "" }));
const { items } = routers.listRouters();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-routers — update", () => {
it("changes the label without changing the name a lane already references", () => {
// A lane stores the name. Renaming the label must not orphan that lane.
const created = routers.createRouter(nineRouter());
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
assert.equal(updated.name, created.name);
assert.equal(updated.label, "9Router (nhà)");
});
it("keeps the stored key when the update omits it", () => {
// The edit form cannot show the key, so it cannot send it back either.
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { label: "renamed" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
});
describe("ai-routers — remove", () => {
it("deletes the file", () => {
const created = routers.createRouter(nineRouter());
routers.removeRouter(created.name);
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
assert.equal(routers.listRouters().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
routers.createRouter(nineRouter());
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
const { items } = routers.listRouters();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
+204
View File
@@ -129,6 +129,71 @@ describe("fast path", () => {
);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false when the systemd unit still names a retired plugin root", () => {
// The one failure a live server hides completely: everything is healthy
// until the next reboot, and then the dashboard never comes back because
// its unit points into a cache directory Claude Code has deleted. The fast
// path has to reject it, or no SessionStart ever repairs it.
//
// Everything else here is deliberately HEALTHY — including a live server,
// without which `fastPathOk` is false anyway and this test would pass
// against the unfixed code.
const ROOT = path.resolve(__dirname, "..", "..");
const pkg = require("../../package.json");
const infoPath = path.join(TMP_HOME, ".agenthub.json");
const prevHome = process.env.HOME;
// HOME first: `unitPath()` resolves against it, and computing the path
// before this line points every write in this test at the REAL
// ~/.config/systemd/user/agenthub.service — which the cleanup below then
// deletes. (Asked how I know.)
process.env.HOME = TMP_HOME;
const unit = require("../../scripts/systemd-service").unitPath("agenthub");
assert.ok(
unit.startsWith(TMP_HOME + path.sep),
`refusing to touch a unit outside the temp home: ${unit}`
);
const writeUnit = (workingDir) => {
fs.mkdirSync(path.dirname(unit), { recursive: true });
fs.writeFileSync(
unit,
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
`[Service]\nWorkingDirectory=${workingDir}\n`,
"utf8"
);
};
try {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
);
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: ROOT,
skillPluginsSettled: "nothing-missing",
},
RT
);
// Control: a unit naming THIS root is the steady state — fast path taken.
writeUnit(ROOT);
assert.equal(boot.fastPathOk(RT), true, "a current unit must not block the fast path");
// The regression: same healthy install, unit left on a retired root.
writeUnit("/plugins/cache/agenthub/deadbeef");
assert.equal(boot.fastPathOk(RT), false);
} finally {
fs.rmSync(infoPath, { force: true });
fs.rmSync(unit, { force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
});
});
describe("skill-plugin auto-install gate", () => {
@@ -391,6 +456,21 @@ describe("buildClient dependency install", () => {
);
});
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
// The whole client build toolchain (vite, typescript, @types/*) is a
// devDependency, and npm turns NODE_ENV=production into --omit=dev. Every
// bootstrap descended from the running server inherits that env, so the
// install dropped vite, `npm run build` failed, and the dashboard kept
// serving the PREVIOUS version's bundle after a self-update.
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
for (const call of calls.filter((c) => c.startsWith("ci") || c.startsWith("install"))) {
assert.match(call, /--include=dev/, `dependency install must include dev deps: ${call}`);
}
});
it("fails without building when neither install path works", () => {
resetRuntime();
const calls = [];
@@ -408,3 +488,127 @@ describe("buildClient dependency install", () => {
);
});
});
describe("supersededBy — an old cache directory must not act", () => {
const pluginsDir = path.join(TMP_HOME, "plugins");
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
const oldRoot = path.join(cacheDir, "aaaaaaaaaaaa");
const newRoot = path.join(cacheDir, "bbbbbbbbbbbb");
function writeInstalled(installPath) {
fs.mkdirSync(pluginsDir, { recursive: true });
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: { "agenthub@sg": [{ scope: "user", installPath }] },
})
);
}
beforeEach(() => {
fs.rmSync(pluginsDir, { recursive: true, force: true });
fs.mkdirSync(oldRoot, { recursive: true });
fs.mkdirSync(newRoot, { recursive: true });
});
it("names the installed build when this one is not it", () => {
writeInstalled(newRoot);
assert.equal(boot.supersededBy(oldRoot), newRoot);
});
it("clears the build that IS installed", () => {
writeInstalled(newRoot);
assert.equal(boot.supersededBy(newRoot), null);
});
it("ignores an installed path that no longer exists", () => {
// A phantom entry must never wedge a bootstrap shut.
writeInstalled(path.join(cacheDir, "cccccccccccc"));
assert.equal(boot.supersededBy(oldRoot), null);
});
it("ignores a plugin that is not a sibling of this one", () => {
fs.mkdirSync(pluginsDir, { recursive: true });
const other = path.join(pluginsDir, "cache", "sg", "sg-all", "0.1.0");
fs.mkdirSync(other, { recursive: true });
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({ plugins: { "sg-all@sg": [{ installPath: other }] } })
);
assert.equal(boot.supersededBy(oldRoot), null);
});
it("says nothing when there is no record to read (a git checkout)", () => {
assert.equal(boot.supersededBy(oldRoot), null);
});
});
describe("pruneRetiredPluginRoots", () => {
const pluginsDir = path.join(TMP_HOME, "plugins");
const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub");
const settingsPath = path.join(TMP_HOME, "settings.json");
/** Six builds, oldest first, so mtime order is deterministic. */
function seed(names) {
fs.rmSync(pluginsDir, { recursive: true, force: true });
let t = Date.now() - names.length * 60_000;
for (const n of names) {
const dir = path.join(cacheDir, n);
fs.mkdirSync(dir, { recursive: true });
fs.utimesSync(dir, new Date(t), new Date(t));
t += 60_000;
}
}
const NAMES = ["v1", "v2", "v3", "v4", "v5", "current"];
const dir = (n) => path.join(cacheDir, n);
beforeEach(() => {
resetRuntime();
seed(NAMES);
fs.rmSync(settingsPath, { force: true });
fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}");
});
it("keeps the current build and the two newest retired ones", () => {
const removed = boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.equal(removed, 3); // v1 v2 v3 go; v4 v5 are the cushion
assert.ok(fs.existsSync(dir("current")));
assert.ok(fs.existsSync(dir("v5")));
assert.ok(fs.existsSync(dir("v4")));
assert.ok(!fs.existsSync(dir("v1")));
});
it("never deletes a build Claude Code records as installed", () => {
fs.writeFileSync(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({ plugins: { "agenthub@sg": [{ installPath: dir("v1") }] } })
);
boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.ok(fs.existsSync(dir("v1")));
});
it("never deletes a build the user's hooks still point at", () => {
// A Claude Code process that has not been restarted is still executing
// from there — deleting it breaks that session's hooks mid-flight.
fs.writeFileSync(
settingsPath,
JSON.stringify({
hooks: { SessionStart: [{ hooks: [{ command: `node "${dir("v2")}/scripts/x.js"` }] }] },
})
);
boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.ok(fs.existsSync(dir("v2")));
assert.ok(!fs.existsSync(dir("v1")));
});
it("refuses to touch anything outside the plugin cache", () => {
const checkout = path.join(TMP_HOME, "checkout", "agenthub");
const sibling = path.join(TMP_HOME, "checkout", "something-precious");
fs.mkdirSync(checkout, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
assert.equal(boot.pruneRetiredPluginRoots(RT, checkout), 0);
assert.ok(fs.existsSync(sibling));
});
});
@@ -0,0 +1,121 @@
/**
* @file Tests the `systemd` row of scripts/plugin-doctor.js.
*
* The row exists for a failure nothing else in the report can see: a unit still
* naming a retired plugin cache directory leaves a running, healthy-looking
* dashboard that simply does not come back after the next reboot — it starts a
* path Claude Code deleted during the update. Every other row would read OK.
*
* WARN and never FAIL: the dashboard in front of the user works, and plenty of
* machines legitimately have no unit at all.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-systemd-"));
const prevHome = process.env.HOME;
// Before anything resolves a unit path: `unitPath()` reads HOME, and a path
// computed against the real one makes these tests rewrite the developer's own
// ~/.config/systemd/user/agenthub.service.
process.env.HOME = TMP_HOME;
process.env.CLAUDE_HOME = path.join(TMP_HOME, ".claude");
const svc = require("../../scripts/systemd-service");
const { diagnose } = require("../../scripts/plugin-doctor");
const UNIT = svc.unitPath("agenthub");
const PLUGIN_ROOT = path.resolve(__dirname, "..", "..");
assert.ok(UNIT.startsWith(TMP_HOME + path.sep), `refusing to touch ${UNIT}`);
beforeEach(() => {
fs.rmSync(UNIT, { force: true });
// Claim systemd exists and the unit is running, so each test varies exactly
// one thing: the unit file on disk.
svc.__setExecImpl((args) => ({
status: 0,
stdout: args[0] === "is-active" ? "active\n" : "",
}));
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
delete process.env.CLAUDE_HOME;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
function row() {
const found = diagnose().find((r) => r.label === "systemd");
assert.ok(found, "the doctor must always emit a systemd row");
return found;
}
function writeUnit(workingDir) {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(
UNIT,
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
`[Service]\nWorkingDirectory=${workingDir}\n`,
"utf8"
);
}
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
describe("plugin-doctor: systemd row", linuxOnly, () => {
it("warns, never fails, when the unit names a retired plugin root", () => {
writeUnit("/plugins/cache/sg/agenthub/deadbeef");
const r = row();
assert.equal(r.level, "warn", "a working dashboard must not fail the doctor run");
assert.match(r.detail, /previous plugin version/i);
// The consequence is the whole point — a reader who does not know it will
// ignore the row.
assert.match(r.detail, /reboot/i);
assert.match(r.detail, /plugin-bootstrap\.js --force|new Claude Code session/);
});
it("passes when the unit is current and running", () => {
writeUnit(PLUGIN_ROOT);
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /current and running/i);
});
it("warns when the unit is current but stopped", () => {
writeUnit(PLUGIN_ROOT);
svc.__setExecImpl((args) => ({
status: 3,
stdout: args[0] === "is-active" ? "inactive\n" : "",
}));
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not running/i);
});
it("reports no unit as fine, not as a problem", () => {
// A plugin install without `agenthub service install` is a normal setup:
// the server is started per session.
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /no unit/i);
});
it("warns about a unit it did not write, and never claims it is stale", () => {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(UNIT, "[Service]\nExecStart=/opt/someone-elses-agenthub\n", "utf8");
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not written by AgentHub|left untouched/i);
});
});
@@ -0,0 +1,54 @@
/**
* @file Tests the `tmux` row of scripts/plugin-doctor.js — the check that tells
* an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
* binary, so failing a doctor run over it would cry wolf. tmux is faked through
* the module's own exec seam, so the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-tmux-"));
process.env.CLAUDE_HOME = TMP_HOME;
const tmux = require("../lib/tmux");
const { diagnose } = require("../../scripts/plugin-doctor");
after(() => {
tmux.__reset();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
delete process.env.CLAUDE_HOME;
});
/** Fake the tmux binary as present or absent, then read back the row. */
function rowWithTmux(present) {
tmux.__setExecImpl(() => {
if (!present) throw new Error("spawnSync tmux ENOENT");
return "tmux 3.4\n";
});
const row = diagnose().find((r) => r.label === "tmux");
assert.ok(row, "the doctor must always emit a tmux row");
return row;
}
describe("plugin-doctor: tmux row", () => {
it("warns, never fails, when tmux is not on PATH", () => {
const row = rowWithTmux(false);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it");
});
it("passes when tmux is available", () => {
const row = rowWithTmux(true);
assert.equal(row.level, "ok", row.detail);
});
});
+133 -18
View File
@@ -58,6 +58,29 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
// for a missing host package, with no hint of what to install.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }),
(err) => err.code === "ETMUXMISSING" && /install/i.test(err.message),
"a missing tmux must be reported as such, with the install command"
);
assert.ok(
!calls.some((c) => c[0] === "new-session"),
"must not attempt a session it already knows cannot be created"
);
});
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = [];
tmux.__setExecImpl((args) => {
@@ -71,7 +94,7 @@ describe("pty-run", () => {
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
});
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
it("spawnRun runs the argv in an existing session idling at a shell prompt", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
@@ -80,24 +103,24 @@ describe("pty-run", () => {
});
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
assert.ok(literal, "expected a literal send-keys with the command line");
assert.match(literal[4], /^AGENTHUB_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
assert.ok(
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
"expected the command to be submitted"
);
const respawn = calls.find((c) => c[0] === "respawn-pane");
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
const argv = respawn.slice(respawn.indexOf("--") + 1);
assert.equal(argv[0], "claude");
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
});
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
let literal = null;
it("spawnRun passes an initial prompt as one argv element, never a shell line", () => {
// No shell is involved any more, so a prompt full of shell metacharacters
// is just a string — but it must still arrive whole and unmangled.
let respawn = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "zsh\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
if (args[0] === "respawn-pane") respawn = args;
return "";
});
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
assert.equal(respawn.at(-1), "don't; rm -rf /");
});
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
@@ -119,17 +142,22 @@ describe("pty-run", () => {
assert.ok(at < newSessionArgv.indexOf("--"));
});
it("spawnRun prefixes AGENTHUB_LANE_ID when typing into an existing shell pane", () => {
let literal = null;
it("spawnRun passes AGENTHUB_LANE_ID through tmux -e when reusing a shell pane", () => {
let respawn = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "bash\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
if (args[0] === "respawn-pane") respawn = args;
return "";
});
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// An assignment prefix on the same line: tmux's -e only applies at session
// creation, and a live shell takes its env from the command line.
assert.match(literal, /^AGENTHUB_LANE_ID='12' 'codex'/);
// Through argv, never as a shell assignment typed into the pane: this is
// also the path an AI Router's API key takes, and a typed line would be
// rendered to every attached browser and land in the shell history.
const at = respawn.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(respawn[at + 1], "AGENTHUB_LANE_ID=12");
assert.ok(at < respawn.indexOf("--"));
assert.equal(respawn.at(-1), "codex");
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
@@ -335,4 +363,91 @@ describe("pty-run", () => {
);
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
});
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
// null there persisted nothing at all while every call still looked fine: the
// runs modal rendered a row with no cwd, model, prompt or start time, and a
// resumed session kept its separate `external` row because no run row ever
// carried its session id. These assert the row actually lands.
it("refuses to swallow a resume or a prompt when the pane already runs an agent", () => {
// Adopting silently here answered 200 while spawning no `--resume` at all:
// the dashboard reported a resumed session that never happened.
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → pane is busy with a live agent
});
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", resumeSessionId: "abc12345" }),
(err) => err.code === "ERUNLIVE"
);
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", initialPrompt: "do the thing" }),
(err) => err.code === "ERUNLIVE"
);
});
it("still adopts a busy pane when the request asked for nothing but a pane", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "claude\n";
return "";
});
const handle = pty.spawnRun({ laneId: 52, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-52");
assert.ok(!calls.some((c) => c[0] === "new-session"));
assert.ok(!calls.some((c) => c[0] === "send-keys"));
});
describe("run persistence", () => {
const dashboardRuns = require("../lib/dashboard-runs");
it("spawnRun persists the run so the runs list has cwd, model and prompt", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({
laneId: 71,
cwd: "/tmp/persisted",
model: "claude-opus-5",
initialPrompt: "ship it",
});
const row = dashboardRuns.getRun("agenthub-lane-71");
assert.ok(row, "expected a dashboard_runs row for the spawned run");
assert.equal(row.cwd, "/tmp/persisted");
assert.equal(row.model, "claude-opus-5");
assert.equal(row.prompt_preview, "ship it");
assert.equal(row.lane_id, 71);
assert.ok(row.started_at, "started_at must be set, or the UI dates the row to the epoch");
assert.ok(row.mode, "mode is NOT NULL — a null here silently drops the whole insert");
});
it("a resumed run carries the session id, which is what dedupes the external row", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
const sessionId = "c2055094-09cf-4735-9712-09e5cdf7023f";
const handle = pty.spawnRun({
laneId: 72,
cwd: "/tmp/resumed",
resumeSessionId: sessionId,
});
// The UI reads sessionId off the run list; an external session whose id is
// already on a run row is not repeated as its own `external` row.
assert.equal(handle.sessionId, sessionId);
assert.equal(dashboardRuns.getRun("agenthub-lane-72").resume_session_id, sessionId);
});
});
});
+389
View File
@@ -0,0 +1,389 @@
/**
* @file Tests for attaching an AI Router profile to a lane — the Claude Code
* `settings.local.json` env merge and the Codex `<name>.config.toml` layer,
* including the guarantees that neither write destroys anything already there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const lanesLib = require("../lib/lanes");
const {
applyRouterToLane,
reapplyToLanes,
detachFromLanes,
routerEnvForSpawn,
routerArgvForSpawn,
} = require("../lib/router-apply");
const LANE_CWD = path.join(SUITE_ROOT, "project");
const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => {
fs.rmSync(path.join(process.env.CLAUDE_HOME, "agenthub-routers"), {
recursive: true,
force: true,
});
fs.rmSync(LANE_CWD, { recursive: true, force: true });
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
fs.mkdirSync(LANE_CWD, { recursive: true });
// The DB outlives each test; a profile name is reused, so lanes left behind
// would be picked up by the next test's `reapplyToLanes` and inflate its count.
for (const lane of lanesLib.listLanes()) lanesLib.deleteLane(lane.id);
});
function saveNineRouter() {
return routers.createRouter({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
}
const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" });
const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" });
describe("router-apply — Claude lane", () => {
it("writes the eight env keys the reference settings file swaps", () => {
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
assert.equal(env.ANTHROPIC_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Highest");
assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Highest");
assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, "Highest");
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, "Highest");
});
it("keeps every unrelated key in the file it merges into", () => {
// A lane's settings.local.json is the user's file; this feature owns eight
// keys in it and nothing else.
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
fs.writeFileSync(
SETTINGS,
JSON.stringify({
permissions: { defaultMode: "bypassPermissions" },
env: { MY_OWN_VAR: "keep me", ANTHROPIC_MODEL: "to be replaced" },
})
);
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const saved = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.equal(saved.permissions.defaultMode, "bypassPermissions");
assert.equal(saved.env.MY_OWN_VAR, "keep me");
assert.equal(saved.env.ANTHROPIC_MODEL, "Highest");
});
it("writes the file 0600, because it now holds an API key", () => {
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
assert.equal(fs.statSync(SETTINGS).mode & 0o777, 0o600);
});
it("blanks the connection variables for the native profile instead of dropping them", () => {
// Absent keys would let a user-level router base URL apply to a lane that
// explicitly asked for native.
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_BASE_URL, "");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "");
});
it("removes the model names for the native profile rather than pinning them", () => {
// The opposite of the connection pair above, and for the opposite reason:
// a project-scope model name overrides the user's own global choice, so a
// lane on native must leave that choice alone instead of writing whatever
// `claude-*` names this codebase happened to ship with.
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
fs.writeFileSync(SETTINGS, JSON.stringify({ env: { MY_OWN_VAR: "keep me" } }));
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
assert.equal(env.ANTHROPIC_MODEL, undefined);
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, undefined);
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, undefined);
assert.equal(env.MY_OWN_VAR, "keep me"); // clearing ours touches nothing else
});
it("refuses a profile that does not exist rather than writing a broken file", () => {
assert.throws(() => applyRouterToLane(claudeLane(), "ghost"), { code: "ENOTFOUND" });
assert.equal(fs.existsSync(SETTINGS), false);
});
it("leaves a settings file that is not valid JSON alone", () => {
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
fs.writeFileSync(SETTINGS, "{not json");
const { name } = saveNineRouter();
assert.throws(() => applyRouterToLane(claudeLane(), name), { code: "EBADJSON" });
assert.equal(fs.readFileSync(SETTINGS, "utf8"), "{not json");
});
});
describe("router-apply — the written file must survive a lane reset", () => {
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
// Without -x that spares IGNORED files only — an untracked settings file
// would be deleted, silently detaching the lane from its router. The same
// entry also stops an API key from being committable.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
});
it("excludes it for a native lane too, not only a lane on a router", () => {
// The blanked connection pair is load-bearing in its own right: losing it
// to `git clean -fd` lets a USER-level router take over a lane that
// explicitly asked for native.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
applyRouterToLane(claudeLane(), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
});
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
// would be a path that cannot exist.
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
});
it("adds the entry once, however many times a router is applied", () => {
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
});
it("still writes the settings file when the lane is not a git repo at all", () => {
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
assert.ok(fs.existsSync(SETTINGS));
});
});
describe("router-apply — Codex lane", () => {
it("writes a profile layer file, never the base config", () => {
const base = path.join(process.env.CODEX_HOME, "config.toml");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(base, 'approval_policy = "never"\n');
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.match(layer, /model_provider = "9router"/);
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
});
it("keeps the key out of the config file, naming an env var instead", () => {
// Codex reads the key from the environment; writing it into config.toml
// would put a secret in a file with no reason to hold one.
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.ok(!layer.includes("sk-secret-value"));
});
it("writes no layer file for the native profile", () => {
// Native means "the base config as it already is" — a layer that overrode
// nothing would still shadow a model the user set in the base.
applyRouterToLane(codexLane(), "native");
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "native.config.toml")), false);
});
it("does not touch the lane's .claude directory", () => {
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
assert.equal(fs.existsSync(SETTINGS), false);
});
it('never writes wire_api = "chat", which codex refuses to load at all', () => {
// Verified against the installed codex-cli 0.149.0: `wire_api = "chat" is
// no longer supported` is a hard config error, so a layer carrying it makes
// the lane fail to start rather than fall back to anything.
const { name } = saveNineRouter();
applyRouterToLane(codexLane(), name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.match(layer, /wire_api = "responses"/);
assert.ok(!layer.includes('wire_api = "chat"'));
});
it("names no env_key when the profile has no key to put in it", () => {
// A local router needs no key — the reference 9Router provider has none.
// Naming a variable that is never set makes codex refuse to start.
const { name } = routers.createRouter({
label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
applyRouterToLane(codexLane(), name);
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
assert.ok(!layer.includes("env_key"), layer);
});
});
describe("router-apply — an edit or a delete reaches the lanes already using it", () => {
// Attaching COPIES the profile into the lane's own config, so a store-only
// write is inert exactly where it matters: the key a lane authenticates with.
/** A real lane row in a directory of its own, already attached to `profile`. */
function laneOn(profile, dirName) {
const cwd = path.join(SUITE_ROOT, dirName);
fs.mkdirSync(cwd, { recursive: true });
const lane = lanesLib.createLane({ title: dirName, cwd, router_profile: profile });
applyRouterToLane(lane, profile);
return { lane, settings: path.join(cwd, ".claude", "settings.local.json") };
}
const envOf = (file) => JSON.parse(fs.readFileSync(file, "utf8")).env;
it("pushes a rotated key out to every claude lane on that profile", () => {
const { name } = saveNineRouter();
const a = laneOn(name, "rotate-a");
const b = laneOn(name, "rotate-b");
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
routers.updateRouter(name, { apiKey: "sk-rotated" });
const result = reapplyToLanes(name);
assert.equal(result.updated, 2);
assert.deepEqual(result.failed, []);
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(envOf(b.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
});
it("leaves lanes on a different profile alone", () => {
const { name } = saveNineRouter();
const other = routers.createRouter({
label: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "sk-other",
model: "auto",
});
const mine = laneOn(name, "mixed-mine");
const theirs = laneOn(other.name, "mixed-theirs");
routers.updateRouter(name, { apiKey: "sk-rotated" });
reapplyToLanes(name);
assert.equal(envOf(mine.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(envOf(theirs.settings).ANTHROPIC_AUTH_TOKEN, "sk-other");
});
it("clears the deleted router out of the lane's settings and its DB row", () => {
// Without this the lane keeps routing to an endpoint the user believes they
// removed, with a key they believe they revoked.
const { name } = saveNineRouter();
const { lane, settings } = laneOn(name, "deleted-router");
routers.removeRouter(name);
const result = detachFromLanes(name);
assert.equal(result.updated, 1);
assert.equal(envOf(settings).ANTHROPIC_BASE_URL, "");
assert.equal(envOf(settings).ANTHROPIC_AUTH_TOKEN, "");
assert.equal(envOf(settings).ANTHROPIC_MODEL, undefined);
assert.equal(lanesLib.getLane(lane.id).router_profile, null);
});
it("deletes the codex layer file, so a later same-named profile cannot inherit it", () => {
const { name } = saveNineRouter();
const layer = path.join(process.env.CODEX_HOME, `${name}.config.toml`);
applyRouterToLane(codexLane(), name);
assert.ok(fs.existsSync(layer));
routers.removeRouter(name);
detachFromLanes(name);
assert.equal(fs.existsSync(layer), false);
});
it("reports a lane it could not update instead of failing the whole edit", () => {
const { name } = saveNineRouter();
const ok = laneOn(name, "partial-ok");
const broken = laneOn(name, "partial-broken");
fs.writeFileSync(broken.settings, "{ not json");
const result = reapplyToLanes(name);
assert.equal(result.updated, 1);
assert.equal(result.failed.length, 1);
assert.match(result.failed[0], /partial-broken/);
assert.equal(envOf(ok.settings).ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
});
it("has nothing to do for native, which is stored as no router at all", () => {
laneOn("native", "native-lane");
assert.deepEqual(reapplyToLanes("native"), { updated: 0, failed: [] });
});
});
describe("router-apply — what a spawn adds", () => {
it("gives a codex lane the profile flag and the key in its environment", () => {
const { name } = saveNineRouter();
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), ["-p", name]);
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {
AGENTHUB_ROUTER_KEY: "sk-secret-value",
});
});
it("adds nothing for a claude lane, whose settings file already carries it", () => {
const { name } = saveNineRouter();
assert.deepEqual(routerArgvForSpawn({ ...claudeLane(), router_profile: name }), []);
assert.deepEqual(routerEnvForSpawn({ ...claudeLane(), router_profile: name }), {});
});
it("adds nothing for a lane with no router, or one set to native", () => {
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: null }), []);
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: "native" }), []);
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: "native" }), {});
});
it("adds nothing when the profile a lane names has since been deleted", () => {
// Deleting a profile must degrade the lane to its base config, never crash
// the spawn that was about to start.
const { name } = saveNineRouter();
routers.removeRouter(name);
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []);
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {});
});
});
+17
View File
@@ -118,6 +118,23 @@ describe("/api/run", () => {
assert.equal(body.error.code, "EBADCWD");
});
it("POST / answers 503, not 400, when the host has no tmux", async () => {
// The request is fine; the dependency is absent. 400 would tell the user to
// fix their input, and the body must carry the install line the UI shows.
tmux.__setExecImpl(() => {
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { laneId: 9, cwd: TMP },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ETMUXMISSING");
assert.match(body.error.message, /install/i);
});
it("POST / spawns a tmux session and GET /:id finds it", async () => {
let hasSessionCalls = 0;
tmux.__setExecImpl((args) => {
+213
View File
@@ -0,0 +1,213 @@
/**
* @file Tests scripts/systemd-service.js, and specifically the split that
* exists because of one incident: the plugin self-update killed the dashboard
* and left it dead.
*
* The bootstrap that performs an update is spawned by the running server, so
* it lives in that server's systemd cgroup. The old code SIGTERM-ed the server
* from inside that cgroup, which (a) exits 0, and `Restart=on-failure` reads a
* clean exit as "meant to stop", and (b) trips `KillMode=control-group`, so
* systemd reaped the bootstrap before it could write state or repoint the unit
* at the new plugin root. The dashboard then stayed down until some later
* Claude Code session happened to bootstrap again.
*
* Hence: writing the unit must not start anything, restarting must not block,
* and a clean exit must no longer be read as "leave it stopped".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-systemd-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const svc = require("../../scripts/systemd-service");
/** Every systemctl invocation this test saw, as flat argv strings. */
let calls = [];
beforeEach(() => {
calls = [];
// Each test starts from "no unit on disk" — otherwise a previous test's
// write turns the next one's `written` into `unchanged`.
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
// `--version` is the availability probe every entry point runs first, not
// an action — recording it would bury the calls these tests are about.
if (args[0] !== "--version") calls.push(args.join(" "));
return { status: 0 };
});
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const UNIT = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: "/plugin/root/new",
execStart: "/usr/bin/node /plugin/root/new/server/index.js",
env: { NODE_ENV: "production" },
};
function unitFile() {
return fs.readFileSync(svc.unitPath("agenthub"), "utf8");
}
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
describe("systemd-service — unit content", linuxOnly, () => {
it("restarts always, because a graceful SIGTERM exits 0", () => {
svc.writeUserService(UNIT);
const body = unitFile();
// `on-failure` is what let one stray kill (or an out-of-band stop during
// an update) leave the dashboard down indefinitely.
assert.match(body, /^Restart=always$/m);
assert.doesNotMatch(body, /^Restart=on-failure$/m);
});
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
svc.writeUserService(UNIT);
assert.match(unitFile(), /agenthub-managed-unit/);
});
});
describe("systemd-service — writeUserService", linuxOnly, () => {
it("writes and enables the unit but starts NOTHING", () => {
// The point of the split: the update must be able to repoint the unit at
// the new plugin root while the old server is still serving requests.
const result = svc.writeUserService(UNIT);
assert.equal(result, "written");
assert.ok(
calls.some((c) => c.startsWith("daemon-reload")),
"a rewritten unit must be reloaded"
);
assert.ok(
calls.some((c) => c === "enable agenthub.service"),
"it must still be enabled for boot"
);
assert.ok(
!calls.some((c) => c.includes("--now") || c.startsWith("restart") || c.startsWith("start")),
`nothing may be started here, got: ${JSON.stringify(calls)}`
);
});
it("reports an identical unit as unchanged and touches systemd not at all", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.writeUserService(UNIT), "unchanged");
assert.deepEqual(calls, [], "an unchanged unit needs no systemctl call");
});
it("refuses a unit it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
assert.equal(svc.writeUserService(UNIT), "foreign");
assert.equal(svc.unitIsOurs("agenthub"), false);
});
});
describe("systemd-service — restartUserService", linuxOnly, () => {
it("never blocks, because the caller is inside the cgroup being restarted", () => {
assert.equal(svc.restartUserService("agenthub"), "restarted");
assert.deepEqual(calls, ["restart --no-block agenthub.service"]);
});
it("reports failure rather than throwing when systemctl refuses", () => {
// Available, but the restart itself is rejected — distinct from "no
// systemd here", which must not be reported as a failure.
svc.__setExecImpl((args) => ({ status: args[0] === "--version" ? 0 : 1 }));
assert.equal(svc.restartUserService("agenthub"), "failed");
});
});
describe("systemd-service — unitIsStale", linuxOnly, () => {
it("is true when the unit names a plugin root that is no longer the current one", () => {
// Invisible while the server runs, fatal at the next reboot: the unit
// starts a path Claude Code garbage-collected after the update.
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true);
});
it("is false for a unit already naming the current root", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
});
it("is false when there is no unit, and for one we do not own", () => {
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
assert.equal(
svc.unitIsStale("agenthub", "/plugin/root/new"),
false,
"a foreign unit is not ours to call stale, or to rewrite"
);
});
it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => {
svc.writeUserService(UNIT);
calls = [];
svc.unitIsStale("agenthub", "/plugin/root/new");
assert.deepEqual(calls, []);
});
});
describe("systemd-service — unitRoot", linuxOnly, () => {
it("reports the directory the unit runs from", () => {
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
assert.equal(svc.unitRoot("agenthub"), "/plugin/root/old");
});
it("reports a FOREIGN unit's directory too", () => {
// Not ours to rewrite, but still a directory the cache pruner may not
// delete out from under a running service.
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
assert.equal(svc.unitRoot("agenthub"), "/opt/mine");
});
it("is null when there is no unit", () => {
assert.equal(svc.unitRoot("agenthub"), null);
});
});
describe("systemd-service — unitIsActive", linuxOnly, () => {
it("is true only for `active`", () => {
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
assert.equal(svc.unitIsActive("agenthub"), true);
svc.__setExecImpl((args) => ({
status: 3,
stdout: args[0] === "is-active" ? "inactive\n" : "",
}));
assert.equal(svc.unitIsActive("agenthub"), false);
});
});
describe("systemd-service — unitIsOurs", linuxOnly, () => {
it("is false when no unit exists at all", () => {
fs.rmSync(svc.unitPath("agenthub"), { force: true });
assert.equal(svc.unitIsOurs("agenthub"), false);
});
it("is true for a unit this code wrote", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitIsOurs("agenthub"), true);
});
});
@@ -0,0 +1,374 @@
/**
* @file Regression tests for update detection on a plugin-cache install that is
* NOT a git clone.
*
* This is the case 1.5.1 shipped broken. When a marketplace entry declares
* `"source": "url"` — this project's own does — Claude Code unpacks an archive,
* so the cache directory has no `.git`. `getUpdatesStatus` probed for `.git`
* first and returned "Install directory is not a git clone; check for updates
* manually", skipping the entire release layer: `update_available` was
* permanently false however many releases were published, `plugin_marketplace`
* and `plugin_name` were never set, so `POST /api/updates/apply`'s plugin branch
* could never fire either. The bug read as "up to date" and was therefore
* invisible for as long as the install happened to be current.
*
* These tests pin the git-free path: the repository URL comes from the
* marketplace manifest on disk, version comparison is the only signal, and a
* newer release must flip `update_available` to true.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
getUpdatesStatus,
pluginMarketplaceUrl,
fetchReleaseFromUrl,
detectPluginInstall,
} = require("../lib/update-check");
let tmpDir;
let prevHome;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "upd-nogit-"));
prevHome = process.env.CLAUDE_HOME;
});
after(() => {
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prevHome;
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
});
/**
* Build a fake Claude home containing a marketplace manifest and an
* archive-style plugin cache directory — no `.git` anywhere.
*
* @param {object} opts
* @param {any} [opts.source] the marketplace entry's `source` value
* @param {string|null} [opts.version] package.json version, or null to omit the file
* @param {boolean} [opts.knownMarketplaces] write known_marketplaces.json
* @returns {{root: string, home: string}} the plugin cache root
*/
function makePluginInstall(name, opts = {}) {
const {
source = { source: "url", url: "https://forge.example.com/Owner/Repo.git", ref: "main" },
version = "1.5.1",
knownMarketplaces = true,
repository,
} = opts;
const home = path.join(tmpDir, name, ".claude");
const mktDir = path.join(home, "plugins", "marketplaces", "sg");
const root = path.join(home, "plugins", "cache", "sg", "agenthub", "d4052ed23506");
fs.mkdirSync(path.join(mktDir, ".claude-plugin"), { recursive: true });
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(mktDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] })
);
if (knownMarketplaces) {
fs.writeFileSync(
path.join(home, "plugins", "known_marketplaces.json"),
JSON.stringify({ sg: { installLocation: mktDir } })
);
}
if (version !== null) {
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ version }));
}
process.env.CLAUDE_HOME = home;
return { root, home };
}
/** A resolved release, as fetchReleaseFromUrl would return it. */
function release(tag) {
return {
ok: true,
tag,
name: tag.replace(/^v/, ""),
url: `https://forge.example.com/Owner/Repo/releases/tag/${tag}`,
published_at: "2026-09-10T09:18:48Z",
notes: "notes",
};
}
describe("pluginMarketplaceUrl", () => {
it("reads the plugin's repository URL out of the marketplace manifest", () => {
makePluginInstall("ok");
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("falls back to the conventional marketplace location when the index is missing", () => {
// known_marketplaces.json is an index, not the authority — the manifest is.
makePluginInstall("noindex", { knownMarketplaces: false });
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("accepts a bare URL string as the source", () => {
makePluginInstall("strsrc", { source: "https://forge.example.com/Owner/Repo.git" });
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("falls back to the marketplace's repository for a relative source", () => {
// This project's own entry is `"source": "./"` — the plugin IS the
// marketplace repo. Without this the dashboard answered `no_remote` and
// could never see a release on any plugin-cache install.
makePluginInstall("relsrc", {
source: "./",
repository: "https://forge.example.com/Owner/Repo",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), "https://forge.example.com/Owner/Repo");
});
it("returns null for a relative source when the manifest declares no repository", () => {
makePluginInstall("relnorepo", { source: "./plugins/agenthub" });
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null for a github owner/repo shorthand rather than guessing a host", () => {
// Inventing github.com here would produce an API URL that 404s and a
// confidently wrong "up to date".
// Not even when the marketplace declares a repository: a shorthand source
// names a DIFFERENT repo, so borrowing the marketplace's is a wrong answer.
makePluginInstall("ghshort", {
source: { source: "github", repo: "Owner/Repo" },
repository: "https://forge.example.com/Owner/Marketplace",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null when the manifest has no entry for that plugin", () => {
const { home } = makePluginInstall("nomatch");
const manifest = path.join(
home,
"plugins",
"marketplaces",
"sg",
".claude-plugin",
"marketplace.json"
);
fs.writeFileSync(manifest, JSON.stringify({ name: "sg", plugins: [{ name: "other" }] }));
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null instead of throwing on a malformed manifest", () => {
const { home } = makePluginInstall("badjson");
const manifest = path.join(
home,
"plugins",
"marketplaces",
"sg",
".claude-plugin",
"marketplace.json"
);
fs.writeFileSync(manifest, "{not json");
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null when nothing is on disk at all", () => {
process.env.CLAUDE_HOME = path.join(tmpDir, "empty-home");
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
});
describe("getUpdatesStatus — plugin cache with no .git", () => {
it("checks the release API instead of reporting 'not a git clone'", async () => {
const { root } = makePluginInstall("regression");
assert.equal(fs.existsSync(path.join(root, ".git")), false, "fixture must have no .git");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
// The old behaviour: the not-a-git-clone message, everything else absent.
// Asserted on the message's CONTENT, not on it being null — a plugin
// install now carries its own sentence, and null was only ever a proxy for
// "did not fall through here".
assert.doesNotMatch(
s.message,
/not a git clone/i,
"must not fall through to the not-a-git-clone payload"
);
assert.equal(s.release_check, "ok", "the release layer must actually run");
assert.equal(s.install_kind, "plugin");
assert.equal(s.plugin_marketplace, "sg");
assert.equal(s.plugin_name, "agenthub");
assert.equal(s.local_version, "1.5.1");
assert.equal(s.latest_release, "v1.5.1");
assert.equal(s.situation, "plugin_managed");
});
it("reports up-to-date for the RIGHT reason when versions match", async () => {
const { root } = makePluginInstall("current");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
assert.equal(s.update_available, false);
// The distinction that matters: false because it looked and found nothing,
// not false because it never looked.
assert.equal(s.release_check, "ok");
assert.equal(s.latest_release, "v1.5.1");
});
it("flips update_available to true when a newer release is published", async () => {
// The whole point: before the fix this stayed false forever.
const { root } = makePluginInstall("newer");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.update_available, true);
assert.equal(s.latest_release, "v1.6.0");
// The fields POST /apply's plugin branch requires must all be present, or
// the batch can never be spawned.
assert.ok(s.plugin_marketplace && s.plugin_name && s.repo_root);
});
it("carries a human-readable message in every outcome, never a null", async () => {
// `agenthub update-check` prints `message` as its headline. Null here made
// it print the literal "null" on every plugin-cache install — the one kind
// that has no commit count to fall back on.
const cases = [
[makePluginInstall("msg-newer").root, { latestRelease: release("v1.6.0") }, /v1\.6\.0/],
[makePluginInstall("msg-current").root, { latestRelease: release("v1.5.0") }, /newest/i],
[
makePluginInstall("msg-failed").root,
{ latestRelease: { ok: false, reason: "unreachable" } },
/unreachable/,
],
[
makePluginInstall("msg-noversion", { version: null }).root,
{ latestRelease: release("v1.6.0") },
/no version|cannot be compared/i,
],
];
for (const [root, opts, expected] of cases) {
const s = await getUpdatesStatus(root, opts);
assert.equal(typeof s.message, "string", `message must be a string, got ${s.message}`);
assert.match(s.message, expected);
}
});
it("does not treat an older published release as an update", async () => {
const { root } = makePluginInstall("older");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") });
assert.equal(s.update_available, false);
// Not a dev_build either: a plugin install is whatever shipped, it cannot
// be "ahead" in the way a checkout can.
assert.equal(s.dev_build, false);
});
it("never claims to have checked when the release lookup failed", async () => {
const { root } = makePluginInstall("unreachable");
const s = await getUpdatesStatus(root, {
latestRelease: { ok: false, reason: "unreachable" },
});
assert.equal(s.release_check, "unreachable");
assert.equal(s.update_available, false);
assert.equal(s.latest_release, null);
});
it("reports no_local_version when package.json cannot be read", async () => {
// With nothing to compare against, "up to date" would be a guess.
const { root } = makePluginInstall("noversion", { version: null });
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.release_check, "no_local_version");
assert.equal(s.update_available, false);
});
it("reports no git-derived numbers rather than fabricating them", async () => {
const { root } = makePluginInstall("nonumbers");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.git_repo, false, "honest: there is no clone here");
assert.equal(s.shallow_clone, false);
assert.equal(s.commits_behind_release, null);
assert.equal(s.commits_ahead_of_release, null);
assert.equal(s.release_sha, null);
});
it("prints the claude plugin commands as the manual fallback", async () => {
const { root } = makePluginInstall("manual");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.match(s.manual_command, /claude plugin marketplace update sg/);
assert.match(s.manual_command, /claude plugin update agenthub@sg -y/);
// No git command may be suggested for a directory that is not a clone.
assert.doesNotMatch(s.manual_command, /git /);
});
it("takes the marketplace and plugin from the path, not from a caller", async () => {
const { root } = makePluginInstall("frompath");
assert.deepEqual(detectPluginInstall(root), { marketplace: "sg", plugin: "agenthub" });
});
});
describe("fetchReleaseFromUrl", () => {
let realFetch;
beforeEach(() => {
realFetch = global.fetch;
});
it("never touches the network for a URL that isn't owner/repo shaped", async () => {
global.fetch = () => {
throw new Error("must not fetch for an unusable URL");
};
try {
const res = await fetchReleaseFromUrl("/some/local/path");
assert.equal(res.ok, false);
assert.equal(res.reason, "remote_not_http");
} finally {
global.fetch = realFetch;
}
});
it("reports no_remote for a missing URL instead of throwing", async () => {
global.fetch = () => {
throw new Error("must not fetch without a URL");
};
try {
const res = await fetchReleaseFromUrl(null);
assert.equal(res.ok, false);
assert.equal(res.reason, "no_remote");
} finally {
global.fetch = realFetch;
}
});
it("resolves the tag from a Gitea-shaped releases payload", async () => {
global.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: "v9.9.9", name: "9.9.9", html_url: "u", body: "b" }),
});
try {
const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git");
assert.equal(res.ok, true);
assert.equal(res.tag, "v9.9.9");
} finally {
global.fetch = realFetch;
}
});
it("treats 404 as 'no releases yet', not a fault", async () => {
global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
try {
const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git");
assert.equal(res.ok, false);
assert.equal(res.reason, "no_releases");
} finally {
global.fetch = realFetch;
}
});
});
+10
View File
@@ -533,6 +533,16 @@ try {
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
}
// Which AI Router profile this lane runs against (see server/lib/ai-routers.js).
// Nullable, and null means the same thing as 'native': the vendor's own API.
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
// because the router is already written into the lane's config on disk and a
// PATCH would leave the row and that file disagreeing.
try {
db.prepare("SELECT router_profile FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN router_profile TEXT").run();
}
// Migrate: label a dashboard run with the lane it was started through, so the
// Workspace page can show one lane's run history. Additive and nullable —
+2
View File
@@ -73,6 +73,7 @@ const importRouter = require("./routes/import");
const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const aiRoutersRouter = require("./routes/ai-routers");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
@@ -115,6 +116,7 @@ function createApp() {
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/ai-routers", aiRoutersRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/locks", locksRouter);
+319
View File
@@ -0,0 +1,319 @@
/**
* @file ai-routers.js
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
* self-hosted gateway) plus the model names to use against it, saved once and
* attached to a lane when that lane is created.
*
* A profile is exactly what the two CLIs need to talk to a router and nothing
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
* the env keys Claude Code reads. Per-tier slots because a router may expose a
* different id per tier — and the `model` field fills every one of them,
* because the common case is a single symbolic name for everything.
*
* Profiles are stored one JSON file per profile under
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
* route ever returns key material** — `listRouters` reports `hasKey` and
* nothing else about it.
*
* `native` is a built-in profile rather than a branch in the caller: it carries
* empty connection fields and the vendor's own model names, so "switch back to
* native" is the same single write path as "switch to a router". It cannot be
* edited, removed, or shadowed by a saved profile.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
/**
* A profile's `name` is its filename, so the charset is deliberately narrow.
* What the user reads and edits is `label`, which accepts spaces and diacritics
* precisely because it never touches a path. Same split as cc-profiles.js.
*/
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
const LABEL_MIN = 3;
const LABEL_MAX = 60;
/** The per-tier model overrides Claude Code reads, in the order the UI shows them. */
const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
const BUILTIN_NAME = "native";
/**
* The vendor's own defaults, used when a lane is set back to native. Empty
* connection fields rather than absent ones: the native reference settings.json
* sets both to "", and an absent key would let a user-level router base URL
* leak into a lane that asked for native.
*/
const NATIVE = Object.freeze({
name: BUILTIN_NAME,
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
apiKey: "",
model: "claude-sonnet-5",
models: Object.freeze({
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
}),
});
function err(code, message) {
const e = new Error(message);
e.code = code;
return e;
}
function routersDir() {
return path.join(getClaudeHome(), "agenthub-routers");
}
function routerFile(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
}
return path.join(routersDir(), `${name}.json`);
}
function readJson(file) {
let raw;
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return null;
}
try {
return JSON.parse(raw);
} catch {
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
}
}
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
}
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
function slugify(text) {
const base = String(text || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
return NAME_RE.test(base) ? base : "router";
}
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
function freeName(base) {
let name = base;
for (
let n = 2;
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
) {
name = `${base}-${n}`;
n += 1;
}
return name;
}
function normalizeLabel(label) {
const clean = typeof label === "string" ? label.trim() : "";
if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
throw err(
"EBADLABEL",
`label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters`
);
}
return clean;
}
/**
* A router endpoint is an http(s) URL and nothing else. A `file:` or
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
* point a CLI somewhere it should not go.
*/
function normalizeBaseUrl(baseUrl) {
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
if (!raw) throw err("EBADURL", "baseUrl is required");
let parsed;
try {
parsed = new URL(raw);
} catch {
throw err("EBADURL", `not a valid URL: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw err("EBADURL", "baseUrl must be http:// or https://");
}
return raw.replace(/\/+$/, "");
}
/** A model name is free text — a router names its own models — but not blank. */
function normalizeModel(model) {
const clean = typeof model === "string" ? model.trim() : "";
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
throw err("EBADMODEL", "model must be 1-200 characters and no control characters");
}
return clean;
}
/**
* Fill every slot from `model`, keeping any the caller set explicitly. This
* is what makes "one symbolic name for every tier" — the 9Router case — a
* single field to type instead of six.
*/
function normalizeModels(model, models) {
const given = models && typeof models === "object" ? models : {};
const out = {};
for (const slot of MODEL_SLOTS) {
out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]);
}
return out;
}
function assertNotBuiltin(name) {
if (name === BUILTIN_NAME) {
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
}
}
/** Read one stored profile (key included), or null when it does not exist. */
function readRouter(name) {
const stored = readJson(routerFile(name));
return stored && typeof stored === "object" ? stored : null;
}
/** The browser-safe view: everything except the key, plus whether one is set. */
function publicView(profile) {
const { apiKey, ...rest } = profile;
return { ...rest, hasKey: Boolean(apiKey) };
}
/**
* Every saved profile, with the built-in native one first.
* @returns `{ items }` — key material is never included.
*/
function listRouters() {
let names = [];
try {
names = fs
.readdirSync(routersDir())
.filter((f) => f.endsWith(".json"))
.map((f) => f.slice(0, -5));
} catch {
/* nothing saved yet */
}
const items = [publicView(NATIVE)];
for (const name of names.sort()) {
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
let stored;
try {
stored = readRouter(name);
} catch {
continue; // a corrupt profile file is skipped, not fatal for the list
}
if (!stored) continue;
items.push(publicView({ ...stored, name, builtin: false }));
}
return { items };
}
/**
* One profile INCLUDING its key — for the server's own writers only. No route
* may return this shape; `listRouters` is what the browser sees.
*/
function getRouter(name) {
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
return { ...stored, name, builtin: false };
}
/**
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
* models?:Record<string,string>}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
const label = normalizeLabel(input.label);
const slug = slugify(label);
assertNotBuiltin(slug);
const model = normalizeModel(input.model);
const profile = {
label,
baseUrl: normalizeBaseUrl(input.baseUrl),
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
model,
models: normalizeModels(model, input.models),
savedAt: new Date().toISOString(),
};
const name = freeName(slug);
writeJson(routerFile(name), profile);
return publicView({ ...profile, name, builtin: false });
}
/**
* Merge changes into a saved profile. The `name` never changes — a lane stores
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
* one: the edit form cannot show a key, so it cannot send one back either.
*/
function updateRouter(name, changes = {}) {
assertNotBuiltin(name);
const stored = readRouter(name);
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
const next = {
...stored,
label: changes.label === undefined ? stored.label : normalizeLabel(changes.label),
baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl),
apiKey:
typeof changes.apiKey === "string" && changes.apiKey.trim()
? changes.apiKey.trim()
: stored.apiKey,
model,
models:
changes.model === undefined && changes.models === undefined
? stored.models
: normalizeModels(model, changes.models ?? stored.models),
savedAt: new Date().toISOString(),
};
writeJson(routerFile(name), next);
return publicView({ ...next, name, builtin: false });
}
function removeRouter(name) {
assertNotBuiltin(name);
const file = routerFile(name);
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
fs.rmSync(file);
return { removed: name };
}
module.exports = {
listRouters,
getRouter,
createRouter,
updateRouter,
removeRouter,
MODEL_SLOTS,
BUILTIN_NAME,
NATIVE,
};
+6 -2
View File
@@ -91,8 +91,12 @@ function recordRun(handle) {
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: handle.tool === "codex" ? "codex" : "claude",
});
} catch {
/* persistence is best-effort */
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
// but not silent: a swallowed insert leaves the runs modal showing rows with
// no cwd, model, prompt or start time, which reads as a UI bug rather than a
// persistence one. Say it once, here, where the cause is known.
console.warn(`[dashboard-runs] failed to record run ${handle?.id}: ${err.message}`);
}
}
+42 -1
View File
@@ -148,6 +148,7 @@ function createLane({
base_branch = null,
slug = null,
tool = "claude",
router_profile = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
@@ -157,7 +158,7 @@ function createLane({
validateTool(tool);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
title,
@@ -169,6 +170,7 @@ function createLane({
base_branch,
slug,
tool,
router_profile || null,
nowIso()
);
return getLane(info.lastInsertRowid);
@@ -205,6 +207,43 @@ function updateLane(id, patch = {}) {
return getLane(id);
}
/**
* Record which AI Router profile a lane runs against. Deliberately its own
* function rather than a `PATCHABLE` field: the router is also written into the
* lane's config on disk (`server/lib/router-apply.js`), so the column may only
* move together with that write — a bare PATCH would leave the row claiming a
* router the lane's settings file has never heard of.
*
* @param {number} id - The lane id.
* @param {string|null} profile - A profile name, or null/"native" for the
* vendor's own API.
*/
function setRouterProfile(id, profile) {
const value = !profile || profile === "native" ? null : String(profile);
db.prepare("UPDATE lanes SET router_profile = ?, updated_at = ? WHERE id = ?").run(
value,
nowIso(),
id
);
return getLane(id);
}
/**
* Every lane attached to one AI Router profile. Editing or deleting a profile
* has to reach the lanes that copied it (`server/lib/router-apply.js`), and this
* is how those lanes are found.
*
* @param {string} profile - A profile name. `native` matches nothing: it is
* stored as NULL, and a lane on the vendor's own API has nothing to update.
*/
function lanesByRouter(profile) {
if (!profile || profile === "native") return [];
return db
.prepare("SELECT * FROM lanes WHERE router_profile = ? ORDER BY id ASC")
.all(String(profile))
.map(hydrate);
}
/**
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
@@ -752,6 +791,8 @@ module.exports = {
updateLane,
deleteLane,
setStage,
setRouterProfile,
lanesByRouter,
recordDetection,
clearLane,
recoverInterruptedProvisioning,
+52 -3
View File
@@ -25,6 +25,7 @@
*/
const tmux = require("./tmux");
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
let dashboardRuns = null;
try {
@@ -98,8 +99,13 @@ function buildCodexArgv({
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
}) {
const argv = ["codex"];
// Before the subcommand and before the prompt: `-p` layers
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
// lands after the positional prompt is not an option any more.
argv.push(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model);
@@ -127,6 +133,8 @@ function buildArgv(args) {
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.resumeLast]
* @param {string} [args.initialPrompt]
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
* (`lanes.router_profile`), never a request field.
*/
function spawnRun(args) {
const {
@@ -141,6 +149,7 @@ function spawnRun(args) {
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
} = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
@@ -181,6 +190,21 @@ function spawnRun(args) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// A run IS a tmux session, so an absent binary is a precondition failure, not
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
// three directories down — a message that names no fix — and the route maps
// it to 400, blaming the request for the host's missing package. Checked here
// rather than in each route because both spawn paths (POST /api/run and
// POST /api/lanes/:id/start) come through this function.
if (!tmux.isTmuxAvailable()) {
throw makeErr(
"ETMUXMISSING",
"tmux is not installed, and every run the dashboard starts is a tmux session. " +
"Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " +
"then restart the dashboard. Everything else keeps working without it."
);
}
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({
@@ -193,6 +217,7 @@ function spawnRun(args) {
resumeSessionId,
resumeLast,
initialPrompt,
routerProfile,
});
const record = () => {
@@ -200,7 +225,13 @@ function spawnRun(args) {
dashboardRuns.recordRun({
id,
sessionId: resumeSessionId || null,
mode: null,
// `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw
// inside recordRun's best-effort catch, so no tmux-backed run was ever
// persisted: publicRun then read its cwd/model/prompt/startedAt off a row
// that did not exist (a blank row dated Jan 1 1970 in the runs modal), and
// session_id never landed either, so a resumed session stayed listed as a
// separate `external` row. Every run here is one interactive pane.
mode: "interactive",
cwd,
tool: tool === "codex" ? "codex" : "claude",
model: model || null,
@@ -220,7 +251,13 @@ function spawnRun(args) {
// from inside it then resolves the lane exactly, instead of matching on cwd —
// which is ambiguous now that one directory can back both a `claude` lane and
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
const env = { AGENTHUB_LANE_ID: String(laneId) };
// A codex lane on an AI Router also needs the key its layer's `env_key`
// names. A claude lane needs nothing here — its settings file already carries
// the router — and a lane on the vendor's own API needs nothing either.
const env = {
AGENTHUB_LANE_ID: String(laneId),
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
@@ -232,12 +269,24 @@ function spawnRun(args) {
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
tmux.sendCommand(id, argv, env);
tmux.sendCommand(id, argv, env, cwd);
record();
}
// Pane is running something (a live `claude`, an editor, a build): adopt
// silently, same convention as this repo's server port-adoption logic — no
// error, no duplicate session. Attaching shows the user what is running.
//
// Adopting is only honest when the request asked for nothing more than a
// pane. A Resume or an initial prompt cannot be delivered into a pane that is
// already running an agent (typing over it is what the shell-pane branch
// above exists to avoid), so answering 200 there reports work that never
// happened: the user sees "resumed" while no `--resume` was ever spawned.
else if (resumeSessionId || initialPrompt) {
throw makeErr(
"ERUNLIVE",
`lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
);
}
return getRun(id);
}
+320
View File
@@ -0,0 +1,320 @@
/**
* @file router-apply.js
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
*
* The two CLIs are configured in different places, and the difference is
* theirs, not a choice made here:
*
* - **Claude Code** reads an `env` block out of the settings layers, so a
* router is eight env keys merged into the lane's own
* `.claude/settings.local.json`. `settings.local.json` and not
* `settings.json` because the value written includes an API key, and the
* `.local` file is the one Claude Code's own convention keeps out of git.
*
* - **Codex** has no project-level config at all — `codex doctor` reports a
* single loaded file, `~/.codex/config.toml`. What it does have is
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
* base. So a profile becomes a layer file, the base is never touched, and
* the lane's spawn passes the flag. Codex takes the key from the environment
* variable named by `env_key`, so the key stays out of the file entirely and
* reaches the pane through tmux's own `-e`, never as typed text.
*
* Target directories come from the lane row, never from a request body — the
* same narrowing every other lane write path in this codebase uses.
*
* Attaching COPIES a profile, so the store is not the single source of truth
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit
* or a delete in Settings from being silently inert on the lanes using it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const routers = require("./ai-routers");
const { patchSettings } = require("./cc-mutate");
const { getCodexHome } = require("./codex-home");
const { lanesByRouter, setRouterProfile } = require("./lanes");
/**
* The env var Codex is told to read the router key from. One fixed name rather
* than one per profile: a Codex pane runs exactly one profile, so a second name
* would only create the chance of the wrong one being set.
*/
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
function envForProfile(profile) {
return {
ANTHROPIC_BASE_URL: profile.baseUrl,
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
ANTHROPIC_MODEL: profile.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
};
}
/**
* Read a profile, or resolve to null when it has since been deleted. Used on
* the spawn path, where a missing profile must degrade the lane to its base
* configuration rather than refuse to start it.
*/
function readOrNull(name) {
if (!name || name === routers.BUILTIN_NAME) return null;
try {
return routers.getRouter(name);
} catch {
return null;
}
}
/**
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
*
* @returns {string|null} absolute git dir, or null when this is not a repo
*/
function gitDirOf(laneDir) {
const dotGit = path.join(laneDir, ".git");
let stat;
try {
stat = fs.statSync(dotGit);
} catch {
return null; // not a git repo — a perfectly normal adopted directory
}
if (stat.isDirectory()) return dotGit;
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
return match ? path.resolve(laneDir, match[1].trim()) : null;
}
/**
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
* that both matter:
*
* - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`).
* Without `-x` that spares IGNORED files only, so an untracked settings file
* is deleted and the lane silently loses its router.
* - The file holds an API key. Untracked means `git add -A` can commit it.
*
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
* must not fail an otherwise-successful attach.
*/
function excludeSettingsFromGit(laneDir) {
const line = ".claude/settings.local.json";
try {
const gitDir = gitDirOf(laneDir);
if (!gitDir) return;
const excludePath = path.join(gitDir, "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (existing.split("\n").includes(line)) return;
fs.writeFileSync(
excludePath,
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n`
);
} catch {
/* best effort — see above */
}
}
/**
* Merge the eight keys into the lane's settings, leaving every other key —
* including every other env var — exactly as it was.
*
* `patchSettings` does this already and does it better than a local merge
* would: per-key deep merge, a size ceiling, `__proto__`-style segments
* refused, an atomic write, a timestamped backup, and a file that is not valid
* JSON reported (`EBADJSON`) instead of overwritten. It resolves
* `project-local` to exactly `<cwd>/.claude/settings.local.json`. The
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
* comes from the DB row, which is the shape that rule allows.
*
* What it does not do is set a mode, because nothing else it writes holds a
* secret. This one does, so the file and the backup it just made of the
* previous one are both tightened afterwards.
*/
function applyToClaudeLane(lane, profile) {
// Native is not simply "no keys". The two halves go opposite ways:
//
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
// scope means the user-level one applies, so dropping these would let a
// user-level router take over a lane that explicitly asked for native.
// - The MODEL names are REMOVED. Writing them would pin every lane — including
// one created by a user who never opened this tab — to whatever names this
// file shipped with, overriding the user-level choice that is the actual
// source of truth for native.
const env = envForProfile(profile);
const set = {};
let unset = [];
if (profile.builtin) {
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
for (const key of connection) set[`env.${key}`] = "";
unset = Object.keys(env)
.filter((key) => !connection.includes(key))
.map((key) => `env.${key}`);
} else {
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
}
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
// Both branches, not just the router one. A native lane's blanked connection
// pair is load-bearing too — `git clean -fd` deleting it would let a
// user-level router quietly take over a lane that asked for native.
excludeSettingsFromGit(lane.cwd);
for (const file of [result.path, result.backupPath]) {
if (!file) continue;
try {
fs.chmodSync(file, 0o600);
} catch {
// Best effort: a write that landed must not be reported as a failure
// because the mode could not be tightened afterwards.
}
}
return { tool: "claude", file: result.path, profile: profile.name };
}
/** TOML string literal — router names and URLs are validated, quotes are not free. */
function tomlString(value) {
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/**
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
* top of the base config. The base file is never read or written here.
*/
function applyToCodexLane(lane, profile) {
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
const body =
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
`model = ${tomlString(profile.model)}\n` +
`model_provider = ${tomlString(profile.name)}\n\n` +
`[model_providers.${profile.name}]\n` +
`name = ${tomlString(profile.label)}\n` +
`base_url = ${tomlString(profile.baseUrl)}\n` +
// `env_key` only when there is a key to name. A local router (the 9Router
// reference config is exactly this) needs none, and naming a variable that
// is never set makes Codex refuse to start.
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
// no longer supported`, a hard config error before the first request. The
// user's own working 9Router provider uses "responses" too.
`wire_api = "responses"\n`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body, { mode: 0o600 });
return { tool: "codex", file, profile: profile.name };
}
/**
* Attach `profileName` to `lane`. Called once, when the lane is created.
*
* @param {{id:number, cwd:string, tool:string}} lane the lane row
* @param {string} profileName a saved profile's name, or `"native"`
*/
function applyRouterToLane(lane, profileName) {
const profile = routers.getRouter(profileName); // throws ENOTFOUND
if (lane.tool === "codex") {
// Native is the base config as it already stands. A layer that overrode
// nothing would still shadow whatever model the user set in the base.
if (profile.builtin) return { tool: "codex", file: null, profile: profile.name };
return applyToCodexLane(lane, profile);
}
return applyToClaudeLane(lane, profile);
}
/**
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
* nothing, its settings file already carries the router.
* @returns {string[]}
*/
function routerArgvForSpawn(lane) {
if (!lane || lane.tool !== "codex") return [];
const profile = readOrNull(lane.router_profile);
return profile ? ["-p", profile.name] : [];
}
/**
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
* @returns {Record<string,string>}
*/
function routerEnvForSpawn(lane) {
if (!lane || lane.tool !== "codex") return {};
const profile = readOrNull(lane.router_profile);
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
}
/**
* Re-run the attach for every lane already on `profileName`.
*
* Attaching COPIES the profile — a claude lane's key and base URL live in its
* own settings file from then on. So an edit that stopped here would be inert
* where it matters most: rotating a leaked key in Settings would leave every
* claude lane authenticating with the revoked one, forever, with no in-product
* way to notice or fix it.
*
* Per-lane best effort: a lane whose directory is gone, or whose settings file
* a human has since broken, must not fail the edit for every other lane.
*
* @returns {{updated:number, failed:string[]}}
*/
function reapplyToLanes(profileName) {
const failed = [];
let updated = 0;
for (const lane of lanesByRouter(profileName)) {
try {
applyRouterToLane(lane, profileName);
updated += 1;
} catch (e) {
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
}
}
return { updated, failed };
}
/**
* Detach `profileName` from every lane using it, and delete the Codex layer
* file it wrote.
*
* Deleting a profile has to reach the lanes for the same reason an edit does,
* and more urgently: a claude lane keeps the deleted router's key and base URL
* in its settings file and would go on routing to a dead endpoint with a key
* the user believes they have removed. Each lane is reset to native — which now
* UNSETS those keys — and its `router_profile` cleared.
*
* The layer file matters too: left behind, a later profile that slugifies to
* the same name silently inherits this one's `base_url`.
*/
function detachFromLanes(profileName) {
const failed = [];
let updated = 0;
for (const lane of lanesByRouter(profileName)) {
try {
applyRouterToLane(lane, routers.BUILTIN_NAME);
setRouterProfile(lane.id, null);
updated += 1;
} catch (e) {
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
}
}
try {
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
} catch {
/* best effort — an orphan layer is a leak, not a failure to report */
}
return { updated, failed };
}
module.exports = {
applyRouterToLane,
reapplyToLanes,
detachFromLanes,
routerArgvForSpawn,
routerEnvForSpawn,
CODEX_KEY_ENV,
};
+26 -22
View File
@@ -6,10 +6,11 @@
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` with an
* explicit argument array (matches this repo's rule for git in worktree.js).
* The one place a command line is composed is `sendCommand`, which types into
* an existing pane's shell: there the shell IS the consumer, so every argument
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
* `sendText` is the same primitive for the opposite case — a pane running an
* No call composes a shell command line at all, `sendCommand` included: it
* takes over an idle pane with `respawn-pane -- <argv>` rather than typing a
* quoted line into that pane's shell, which keeps an API key passed in `env`
* out of the pane's rendered text, its scrollback and the user's shell history.
* `sendText` is the one primitive that does send keystrokes — a pane running an
* interactive chat program that reads stdin as a message, not a shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -88,27 +89,30 @@ function paneCommand(name) {
}
}
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
function shellQuote(arg) {
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
/**
* Type `argv` into an existing session's pane and press Enter. Only ever
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
* the string literally so no character is read as a tmux key name.
* Run `argv` in an existing session's pane. Only ever called when the pane sits
* at a shell prompt (see `paneCommand`), so what `-k` kills is an idle shell.
*
* `env` rides along as a shell assignment prefix on the SAME line — tmux's own
* `-e` applies only at session creation, and a shell that is already running
* takes its environment from the command line. The name is never quoted (bash
* only recognizes an assignment when the `=` is unquoted); the value is.
* `respawn-pane` and not `send-keys`, because `env` may carry a secret — an AI
* Router's API key, which Codex reads from the variable its `env_key` names.
* Typing it would put a `KEY='sk-live-…' codex …` line into the pane's rendered
* text, which the dashboard streams to every attached browser, into the tmux
* scrollback, and into the user's shell history. `-e` hands the value to the
* tmux server through argv instead, exactly as `newSession` already does, and
* the pane never renders it. Running argv directly also means no shell to quote
* for, so the whole class of quoting bugs goes with it.
*/
function sendCommand(name, argv, env) {
const prefix = Object.entries(env || {})
.map(([key, value]) => `${key}=${shellQuote(value)} `)
.join("");
execImpl(["send-keys", "-t", name, "-l", prefix + argv.map(shellQuote).join(" ")]);
execImpl(["send-keys", "-t", name, "Enter"]);
function sendCommand(name, argv, env, cwd) {
execImpl([
"respawn-pane",
"-k",
"-t",
name,
...(cwd ? ["-c", cwd] : []),
...envFlags(env),
"--",
...argv,
]);
}
/**
+200 -12
View File
@@ -2,21 +2,34 @@
* @file Detects whether this install is behind the newest published RELEASE of
* its own repository, and can fast-forward onto that release tag on request.
*
* The release is read straight from the forge's REST API, derived from the
* `origin` remote URL (`/api/v1/repos/<owner>/<repo>/releases/latest` — the
* Gitea/Forgejo shape this project is hosted on), so the check needs no
* hardcoded host and no credentials for a public repo. When no release can be
* read — a non-HTTP remote, an offline machine, a repo with no releases yet —
* detection degrades to the original commit-count comparison against the
* canonical default branch rather than reporting a false "up to date".
* The release is read straight from the forge's REST API
* (`/api/v1/repos/<owner>/<repo>/releases/latest` — the Gitea/Forgejo shape
* this project is hosted on), so the check needs no hardcoded host and no
* credentials for a public repo. When no release can be read — a non-HTTP
* remote, an offline machine, a repo with no releases yet — detection degrades
* to the original commit-count comparison against the canonical default branch
* rather than reporting a false "up to date".
*
* **Two independent paths, chosen by install kind.** A checkout derives the API
* URL from its `origin` remote. A plugin-cache install does NOT: Claude Code
* unpacks an archive, so the cache directory has no `.git` and there is no
* remote to read. That path is answered entirely without git — the repository
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`,
* which for this project's own relative `"source": "./"` entry resolves to the
* manifest's top-level `repository`) and
* the package.json version is the only comparison signal. Getting this wrong is
* what shipped in 1.5.1: the `.git` probe ran first, returned "not a git clone",
* and skipped the release layer, so `update_available` stayed false however many
* releases were published — a bug that reads as "up to date" and is therefore
* invisible while the install happens to be current. Keep the plugin branch
* ahead of the `.git` probe.
*
* Two states the release model makes visible that a commit count cannot:
* a checkout sitting AHEAD of the newest release tag is a `dev_build`, not an
* up-to-date install; and an install living under `~/.claude/plugins/cache/`
* is `plugin_managed`, where this process must NOT self-update — Claude Code
* only re-resolves `${CLAUDE_PLUGIN_ROOT}` on `/reload-plugins`, a REPL-only
* action no server can trigger, so a self-pull would leave a cache directory
* whose name (a commit sha) no longer matches its contents.
* is `plugin_managed`, which this process does not rewrite itself — Claude Code
* owns that directory, so `applyUpdate` refuses and `routes/updates.js` hands
* the job to the detached `claude plugin update` chain instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,6 +37,7 @@ const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const { readLastPluginUpdate } = require("./plugin-update");
const { getClaudeHome } = require("./claude-home");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
@@ -239,8 +253,21 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) {
} catch {
return { ok: false, reason: "no_remote" };
}
return fetchReleaseFromUrl(remoteUrl, timeoutMs);
}
const api = releaseApiUrl(remoteUrl);
/**
* The HTTP half of the release lookup, split out because a **plugin-cache
* install has no git remote to derive the URL from** — it is unpacked from an
* archive, so `pluginMarketplaceUrl()` supplies the repository URL instead and
* calls straight in here. Same contract as `fetchLatestRelease`: never throws.
*
* @param {string} repoUrl a repository URL (https, scp-style, or ssh://)
* @param {number} [timeoutMs]
*/
async function fetchReleaseFromUrl(repoUrl, timeoutMs = 10_000) {
if (!repoUrl) return { ok: false, reason: "no_remote" };
const api = releaseApiUrl(repoUrl);
if (!api) return { ok: false, reason: "remote_not_http" };
try {
@@ -279,6 +306,154 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) {
* a tag that is actually a plain ancestor. Callers must fall back to comparing
* version strings, the one signal a shallow clone still answers correctly.
*/
/**
* Repository URL a marketplace declares for one of its plugins, read off disk.
*
* This exists because a plugin-cache install is NOT necessarily a git clone.
* When a marketplace entry uses `"source": "url"` (this project's own does),
* Claude Code unpacks an archive and the cache directory has no `.git` at all —
* so there is no remote to derive the releases API from, and every git-based
* signal is unavailable. The marketplace manifest is the authority on where the
* plugin came from, and it is already on disk next to the cache.
*
* Path: `<claude home>/plugins/known_marketplaces.json` gives the marketplace's
* `installLocation`; that directory's `.claude-plugin/marketplace.json` lists
* the plugins, each with its own `source`.
*
* @param {string} marketplace marketplace name (from the cache path)
* @param {string} plugin plugin name (from the cache path)
* @returns {string|null} the URL, or null when it cannot be determined. Never throws.
*/
function pluginMarketplaceUrl(marketplace, plugin) {
try {
const pluginsDir = path.join(getClaudeHome(), "plugins");
let installLocation = path.join(pluginsDir, "marketplaces", marketplace);
try {
const known = JSON.parse(
fs.readFileSync(path.join(pluginsDir, "known_marketplaces.json"), "utf8")
);
const entry = known && known[marketplace];
if (entry && typeof entry.installLocation === "string" && entry.installLocation) {
installLocation = entry.installLocation;
}
} catch {
// Fall back to the conventional location — the manifest below is what
// actually matters, and it may well be readable without this index.
}
const manifest = JSON.parse(
fs.readFileSync(path.join(installLocation, ".claude-plugin", "marketplace.json"), "utf8")
);
const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin);
if (!entry) return null;
// `source` is either a string (a bare URL, a relative path, or owner/repo)
// or an object with its own `source` discriminator plus `url`/`repo`.
const src = entry.source;
if (typeof src === "string" && src.includes("://")) return src;
if (src && typeof src.url === "string" && src.url) return src.url;
// A RELATIVE source ("./", "./plugins/x") means the plugin ships inside the
// marketplace repository itself — this project's own entry is exactly that
// ("source": "./") — so the manifest's declared `repository` is where its
// releases are published. Without this fallback every plugin-cache install
// of this very plugin answered `no_remote` and could never see a release.
const relative = typeof src === "string" && /^\.{0,2}\//.test(src);
if (relative) {
const repo = manifest?.repository;
const url = typeof repo === "string" ? repo : repo?.url;
if (typeof url === "string" && url.includes("://")) return url;
}
// A `github`-style `repo: "owner/repo"` shorthand carries no host; this
// build is distributed from its own forge, so rather than guessing
// github.com the caller reports "remote_not_http" and prints the manual
// command instead of inventing an API URL that would 404.
return null;
} catch {
return null;
}
}
/**
* Full update status for an install running out of the plugin cache, computed
* WITHOUT git. Version comparison is the only signal available and the only one
* that means anything here: the cache directory name is a content hash, and
* even when a `.git` happens to exist it is a depth-1 clone whose commit
* distances are graft artefacts.
*
* Returns the same payload shape as the git path, with every git-derived field
* null, so one client renders both.
*/
async function pluginStatus(root, pluginInstall, options = {}) {
const localVersion = readLocalVersion(root);
const repoUrl = pluginMarketplaceUrl(pluginInstall.marketplace, pluginInstall.plugin);
const release =
options.latestRelease ??
(options.skipFetch ? { ok: false, reason: "skipped" } : await fetchReleaseFromUrl(repoUrl));
// No local version means nothing to compare against — report "could not
// look" rather than a false "up to date".
const comparable = release.ok && Boolean(localVersion);
const releaseIsNewer = comparable && compareVersions(release.tag, localVersion) > 0;
const manualParts = [
`claude plugin marketplace update ${pluginInstall.marketplace}`,
`claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`,
];
return {
// Truthfully false: there is no clone here. Consumers must branch on
// `install_kind`, not on this, or they will report "check manually" for an
// install the dashboard can in fact check AND update.
git_repo: false,
update_available: releaseIsNewer,
repo_root: root,
situation: "plugin_managed",
situation_note:
`This dashboard runs from the Claude Code plugin cache (marketplace ` +
`'${pluginInstall.marketplace}'). Updating is handled by Claude Code's own plugin ` +
`commands — "Update now" runs them for you and restarts the dashboard. An already-open ` +
`Claude Code session still needs '/reload-plugins' to pick up new skills and commands.`,
manual_command: manualParts.join(" && "),
// A plugin install compares versions exactly like a checkout does, so it
// gets the same sentence. Left null, `agenthub update-check` printed the
// literal "null" as its headline on every plugin-cache install — the one
// install kind that cannot fall back to a commit count.
message: releaseIsNewer
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
: comparable
? `Running the newest release (${release.tag}).`
: release.ok
? `This install reports no version, so it cannot be compared with release ${release.tag}.`
: `Could not read the latest release (${release.reason}); version comparison unavailable.`,
install_kind: "plugin",
plugin_marketplace: pluginInstall.marketplace,
plugin_name: pluginInstall.plugin,
last_plugin_update: readLastPluginUpdate(),
local_version: localVersion,
// Not a clone, so there is nothing to be shallow about and no distances to
// report. Kept present-and-null so the payload shape never varies.
shallow_clone: false,
local_sha: null,
remote_sha: null,
commits_behind: 0,
release_check: release.ok ? (localVersion ? "ok" : "no_local_version") : release.reason,
latest_release: release.ok ? release.tag : null,
latest_release_name: release.ok ? release.name : null,
latest_release_url: release.ok ? release.url : null,
latest_release_published_at: release.ok ? release.published_at : null,
latest_release_notes: release.ok ? release.notes : null,
release_sha: null,
commits_behind_release: null,
commits_ahead_of_release: null,
// A plugin install is whatever the marketplace shipped; it cannot be ahead.
dev_build: false,
};
}
async function isShallowClone(root) {
try {
const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 });
@@ -306,6 +481,17 @@ async function countCommits(root, range) {
*/
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
const root = path.resolve(gitRoot);
// A plugin-cache install is answered WITHOUT git, and this must come before
// the `.git` probe below. When a marketplace entry uses `"source": "url"`,
// Claude Code unpacks an archive and there is no `.git` at all — so the probe
// would return "not a git clone; check for updates manually" and skip the
// entire release layer, leaving update_available permanently false however
// many releases get published. Version compare needs no clone; the repository
// URL comes from the marketplace manifest.
const earlyPluginInstall = detectPluginInstall(root);
if (earlyPluginInstall) return pluginStatus(root, earlyPluginInstall, options);
const gitDir = path.join(root, ".git");
if (!fs.existsSync(gitDir)) {
return {
@@ -632,6 +818,8 @@ module.exports = {
compareVersions,
releaseApiUrl,
detectPluginInstall,
pluginMarketplaceUrl,
fetchReleaseFromUrl,
fetchLatestRelease,
isShallowClone,
};
+1 -1
View File
@@ -2471,7 +2471,7 @@ function createOpenApiSpec() {
type: "object",
additionalProperties: true,
description:
'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.',
'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing to pull: either release_check is "ok" and the published release is already installed, or the release check did not succeed (the message says which — an unknown is never reported as "nothing newer"). When a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.',
},
},
},
+90
View File
@@ -0,0 +1,90 @@
/**
* @file ai-routers.js (routes)
* @description CRUD for AI Router profiles — the endpoints behind the
* **AI Router** tab of Settings. CHOOSING a profile for a lane is not here: it
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`),
* because the write target is the lane's own directory.
*
* An edit or a delete here does reach the lanes that already chose it, though.
* Attaching COPIES the profile into the lane's own config, so a store-only
* write would be inert exactly where it matters most — a rotated key would
* never reach the lanes still authenticating with the revoked one.
*
* No response on this router carries key material. `GET /` returns the store's
* public view (`hasKey: true|false`), and there is deliberately no
* `GET /:name` — a route that returned one profile in full would be the one
* way an API key could leave this process.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const routers = require("../lib/ai-routers");
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const ERR_TO_STATUS = {
EBADNAME: 400,
EBADLABEL: 400,
EBADURL: 400,
EBADMODEL: 400,
EBADJSON: 409,
EBUILTIN: 409,
ENOTFOUND: 404,
};
function fail(res, e) {
const status = ERR_TO_STATUS[e.code] || 500;
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
// GET /api/ai-routers — every profile, built-in `native` first. Never a key.
router.get("/", (_req, res) => {
try {
res.json(routers.listRouters());
} catch (e) {
return fail(res, e);
}
});
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
router.post("/", sameOriginGuard, (req, res) => {
try {
res.status(201).json(routers.createRouter(req.body || {}));
} catch (e) {
return fail(res, e);
}
});
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
//
// Attaching COPIES a profile into the lane's own config, so an edit that only
// wrote the store here would be inert: rotating a leaked key would leave every
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
// reports per-lane failures instead of failing the edit.
router.patch("/:name", sameOriginGuard, (req, res) => {
try {
const saved = routers.updateRouter(req.params.name, req.body || {});
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
} catch (e) {
return fail(res, e);
}
});
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its
// config on disk as well as in the DB. Leaving the lanes alone would keep the
// deleted router's key and base URL live in each claude lane's settings file.
router.delete("/:name", sameOriginGuard, (req, res) => {
try {
const removed = routers.removeRouter(req.params.name);
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
} catch (e) {
return fail(res, e);
}
});
module.exports = router;
+39 -1
View File
@@ -25,6 +25,7 @@ const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket");
const runs = require("../lib/pty-run");
const { applyRouterToLane } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run");
const { preflight } = require("../lib/lane-preflight");
const {
@@ -618,6 +619,32 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
}
});
/**
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
* `native`). Body: `{ profile: "<name>" }`.
*
* The profile name is the only thing taken from the request: what gets written,
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
* lane's `router_profile` column is set in the same call so a later spawn knows
* which `-p` layer a codex pane needs.
*/
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
const profile = (req.body || {}).profile;
if (typeof profile !== "string" || !profile) {
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
}
try {
const result = applyRouterToLane(lane, profile);
lanesLib.setRouterProfile(lane.id, profile);
res.json(result);
} catch (err) {
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* Detect a Node.js project at this lane's OWN directory and scaffold
* .agenthub/profile/ if one is found — the HTTP equivalent of
@@ -898,7 +925,9 @@ function sendLifecycleError(res, err) {
if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (["ESTALE", "EUNPUSHED"].includes(err.code)) {
// ERUNLIVE: the lane's pane is busy with a live agent, so a resume/prompt
// could not be delivered — a conflict, not a bad request or a server fault.
if (["ESTALE", "EUNPUSHED", "ERUNLIVE"].includes(err.code)) {
return res.status(409).json({
error: {
code: err.code,
@@ -1309,6 +1338,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.
tool: current.tool || "claude",
// Same reasoning as `tool`: the router is the lane's fact, written
// into its config when the lane was created.
routerProfile: current.router_profile || null,
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
@@ -1358,6 +1390,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
break;
}
} catch (err) {
// A host with no tmux cannot start a run, but nothing is broken on this
// side — 503 plus the install line, not a 500 the reader can only read as a
// dashboard bug.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
+11
View File
@@ -242,6 +242,8 @@ router.post("/", (req, res) => {
laneId,
cwd,
tool: lane?.tool || "claude",
// The lane's own router, exactly like its tool — never a body field.
routerProfile: lane?.router_profile || null,
model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
@@ -256,6 +258,15 @@ router.post("/", (req, res) => {
});
return res.status(201).json(handle);
} catch (err) {
// A busy pane is a conflict with live state, not a malformed request.
if (err.code === "ERUNLIVE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
// installed. 400 would tell the user to fix their input.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
if (err.code && err.code.startsWith("E")) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
+7 -1
View File
@@ -91,7 +91,13 @@ router.post("/apply", async (_req, res) => {
result.reason === "up_to_date"
? "Already up to date."
: result.reason === "plugin_managed"
? "This install runs from the Claude Code plugin cache and nothing newer is published; run /agenthub-update to force a refresh."
? // Do not claim "nothing newer is published" unless the release
// lookup actually succeeded — saying so after a failed check
// reports an unknown as a negative, which is how a broken
// check looks identical to an up-to-date install.
result.release_check === "ok"
? `This install runs from the Claude Code plugin cache and ${result.latest_release || "the published release"} is already the newest; run /agenthub-update to force a refresh.`
: `This install runs from the Claude Code plugin cache and the release check did not succeed (${result.release_check || "unknown"}), so there is nothing to apply; run /agenthub-update to update it by hand.`
: "Current branch can't be fast-forwarded automatically.",
},
});