Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 470f9909b4 | |||
| 5ba9e07d78 | |||
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 | |||
| 229002cc2b | |||
| 27923db4e0 | |||
| 5ab674f134 | |||
| 00a2aedb09 | |||
| 9128352d1b | |||
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 | |||
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 | |||
| b7e81af0b6 | |||
| c90dd7b147 | |||
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 | |||
| 3ed88062d6 | |||
| 27313d4413 | |||
| d4b949a6f4 | |||
| 8930865376 | |||
| 29858ddccb |
+2
-1
@@ -346,8 +346,9 @@ graph TD
|
||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
|
||||
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
|
||||
|
||||
@@ -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 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.
|
||||
_Last synced: v1.6.9 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -25,7 +25,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `agenthub stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
|
||||
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||
@@ -36,13 +36,18 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||||
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
|
||||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||||
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/<name>.config.toml` — the layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
|
||||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
|
||||
- **A lane's card position is a viewer preference; its name is a lane fact.** The Workspace strip's drag/Alt-arrow order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||||
- **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.
|
||||
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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?**
|
||||
|
||||
+18
-1
@@ -1279,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) {
|
||||
@@ -2995,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"],
|
||||
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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,12 +291,13 @@ 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({
|
||||
const outcome = {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled"
|
||||
? profileOutcome.value.scaffolded
|
||||
@@ -240,7 +306,15 @@ 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",
|
||||
} as const;
|
||||
const anyFailed = Object.values(outcome).includes("failed");
|
||||
setSetupResult(outcome);
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
@@ -248,13 +322,23 @@ export function AddLaneModal({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Leave the modal open so the setup summary below stays on screen; the
|
||||
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||
// than racing a timer that can close before they've looked at it.
|
||||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||||
// dialog is done — leaving it up read as "still working" (the confirm
|
||||
// button is dead from here, since the lane already exists) with no
|
||||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||||
// and then the confirm button becomes a plain close, because that is the
|
||||
// one case where the user has something to read before dismissing it.
|
||||
setBusy(false);
|
||||
if (!anyFailed) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
@@ -279,21 +363,24 @@ export function AddLaneModal({
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
// the confirm button has no second creation to run: it closes instead. That
|
||||
// is the difference between a dialog that is finished and one that is stuck.
|
||||
const done = !!setupResult;
|
||||
const disabled =
|
||||
!!setupResult ||
|
||||
!sourceRepo.trim() ||
|
||||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||
!done &&
|
||||
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={t("add")}
|
||||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onConfirm={submit}
|
||||
onConfirm={done ? handleCancel : submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -390,6 +477,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 +643,11 @@ export function AddLaneModal({
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
ok: setupResult.router !== "failed",
|
||||
skipped: setupResult.router === "skipped",
|
||||
},
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
* NOT repeated here — this card only ever renders inside the Workspace
|
||||
* lane-detail section, whose header above it already shows them. The repo
|
||||
* lane-detail section, whose header above it already shows them. Neither is
|
||||
* progress: the pipeline map directly below this card draws the same number
|
||||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
@@ -14,16 +19,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* each call is five git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
@@ -134,38 +141,113 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||
/**
|
||||
* Which AI Router this lane runs against, and the one place it can be changed
|
||||
* after the lane exists — the Add-lane modal is the only other one, and a lane
|
||||
* outlives the moment it was created.
|
||||
*
|
||||
* Applying writes the lane's own config on disk as well as the DB column, so it
|
||||
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
|
||||
* the select back: the row must not claim a router the lane did not get.
|
||||
*
|
||||
* Rendered only when there is something to choose. A user who has never opened
|
||||
* the AI Router tab has exactly the built-in `native` profile, and a select
|
||||
* with one option is a control that cannot do anything.
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
function useLaneIntegrations(
|
||||
laneId: number,
|
||||
available: boolean
|
||||
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
||||
// was reset to native by a profile delete, say) has to land here too.
|
||||
useEffect(() => {
|
||||
setCurrent(lane.router_profile || "native");
|
||||
setNote(null);
|
||||
}, [lane.id, lane.router_profile]);
|
||||
|
||||
// One instance of this row serves every lane — it lives in the Workspace's
|
||||
// lane-detail header, and switching lanes swaps its `lane` prop rather than
|
||||
// remounting it. So an apply that is still in flight when the user moves on
|
||||
// would land its rollback and its note on whatever lane is showing by then:
|
||||
// lane B's failed switch putting lane B's old selection into lane A's picker.
|
||||
const shownLane = useRef(lane.id);
|
||||
useEffect(() => {
|
||||
shownLane.current = lane.id;
|
||||
}, [lane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||
.then((results) => {
|
||||
if (!alive) return;
|
||||
const next: Record<string, boolean> = {};
|
||||
INTEGRATION_NAMES.forEach((name, i) => {
|
||||
next[name] = results[i]?.enabled ?? false;
|
||||
});
|
||||
setState(next);
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setState(null);
|
||||
// Silent, same contract as the git/runtime pollers above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [laneId, available]);
|
||||
}, []);
|
||||
|
||||
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||
if (!routers || routers.length <= 1) return null;
|
||||
|
||||
const change = async (name: string) => {
|
||||
const laneId = lane.id;
|
||||
const previous = current;
|
||||
setCurrent(name);
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
await api.lanes.routerApply(laneId, name);
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.applied") });
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setCurrent(previous);
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
// `busy` belongs to the control on screen, not to the request, so it is
|
||||
// cleared either way — a lane switch must never leave the picker locked.
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
data-testid={`lane-router-${lane.id}`}
|
||||
>
|
||||
{t("router.label")}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy}
|
||||
onChange={(e) => void change(e.target.value)}
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* The applied-note is a tooltip, not a line of its own: it fires on a
|
||||
click the user just made, and a wrapped sentence under the control
|
||||
would push every row below it down for the life of the selection.
|
||||
A failure still gets visible colour, because that one is not
|
||||
self-evident from the select. */}
|
||||
{note && (
|
||||
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
|
||||
{note.ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
@@ -313,6 +395,141 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`, for the same reason this
|
||||
* file's own header says the tools dropdown was moved out of the card: a lane
|
||||
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
|
||||
* an absolutely positioned panel would be clipped by it for every card that is
|
||||
* not at the very top. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
@@ -333,10 +550,24 @@ export default function LaneCard({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||
|
||||
/**
|
||||
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||
@@ -365,23 +596,6 @@ export default function LaneCard({
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
>
|
||||
{/* Progress line: how far through the pipeline. The stage's own name
|
||||
and how long it's been running are already in the Workspace
|
||||
header above; not repeated here. Omitted at 0% — an empty bar
|
||||
is not information, just a blank row above a dead/idle lane. */}
|
||||
{lane.progress > 0 && (
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
@@ -477,30 +691,57 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
<div className="truncate">
|
||||
⑂ {git.branch}
|
||||
<span className="ml-2 text-fg-muted">{git.head}</span>
|
||||
</div>
|
||||
<div className="truncate text-fg-muted" title={git.subject}>
|
||||
{git.subject}
|
||||
</div>
|
||||
{/* One line, because every part of it is short and the panel is
|
||||
narrow: branch, head, subject, and the uncommitted counts read as a
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<div className="text-status-warning/80">
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||
<div className="truncate text-fg-muted" title={lane.cwd}>
|
||||
{lane.cwd}
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && (
|
||||
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
|
||||
⑂ {lane.branch}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
@@ -527,24 +768,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
|
||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
data-testid={`lane-integration-${name}`}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
integrations[name]
|
||||
? "bg-status-success/10 text-status-success"
|
||||
: "bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`integrations.${name}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||
in one grid row still line their buttons up. Repo tooling and the
|
||||
remove button live in the Workspace page header/pipeline row now —
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
||||
* It carries only what you need to pick a lane — which lane, is it alive,
|
||||
* what stage, how far — because the full card, its controls and its
|
||||
* working-copy facts live in the detail panel below it. Keeping the tile
|
||||
* small and fixed-width is what lets many lanes stay scannable in one
|
||||
* scrolling row.
|
||||
* what stage — because the full card, its controls and its working-copy facts
|
||||
* live in the detail panel below it. Stage badges ride on the id row rather
|
||||
* than a line of their own, and progress (bar and percent) is deliberately
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
*
|
||||
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
||||
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
||||
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
||||
* the keyboard (and touch-free) path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
@@ -43,46 +51,70 @@ function detectionLeads(lane: Lane): boolean {
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
onReorder,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("reorderHint")}`}
|
||||
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||
width and said nothing, but the number itself is how the CLI
|
||||
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
@@ -100,16 +132,16 @@ export default function LaneStripCard({
|
||||
{lane.detected_stage}
|
||||
</span>
|
||||
)}
|
||||
{lane.progress > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.progress > 0 && (
|
||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -131,8 +134,49 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [nativeRouter()] });
|
||||
});
|
||||
|
||||
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved router profile, for the tests that need the radio to have a target. */
|
||||
function savedRouter(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...nativeRouter(),
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
hasKey: true,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
renderModal();
|
||||
@@ -198,9 +242,10 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||
);
|
||||
// The modal stays open showing the setup summary until dismissed - it
|
||||
// does not close itself just because the lane was added.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
// Setup came back clean, so there is nothing to report and the dialog is
|
||||
// done. Leaving it up read as "still working" — its confirm button is dead
|
||||
// once the lane exists — which is how it looked stuck.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
@@ -261,11 +306,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
@@ -273,12 +319,13 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
// A failure is the one case worth reading before the dialog goes away.
|
||||
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||
if (!dismissButton) throw new Error("Cancel button not found");
|
||||
await user.click(dismissButton);
|
||||
// And then the primary button must close, not sit there disabled: the lane
|
||||
// already exists, so "Add lane" has nothing left to do.
|
||||
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -398,6 +445,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
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
@@ -23,14 +23,17 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
integration: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,8 +44,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.integration).mockReset();
|
||||
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
@@ -150,14 +155,12 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage", () => {
|
||||
// The pipeline map below this card draws the same progress as named nodes,
|
||||
// so the card no longer repeats it as a bar and a percent.
|
||||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
@@ -204,6 +207,11 @@ describe("LaneCard git block", () => {
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
@@ -219,6 +227,57 @@ describe("LaneCard git block", () => {
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
@@ -340,40 +399,6 @@ describe("LaneCard — named locks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — integration badges", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
// touches here.
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
up: false,
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.agenthub/profile",
|
||||
ports: {},
|
||||
} as unknown as LaneRuntime);
|
||||
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||
Promise.resolve({ enabled: name === "tracker" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows integration badges reflecting each toggle's state", async () => {
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||
expect(tracker.className).toContain("status-success");
|
||||
expect(devQc.className).not.toContain("status-success");
|
||||
});
|
||||
|
||||
it("hides integration badges when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneToolsMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
@@ -440,3 +465,117 @@ describe("LaneToolsMenu", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
|
||||
|
||||
it("stays out of the card when native is the only profile", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-router-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the lane's current profile selected", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||||
// lane-detail header), so its selected value is component state that
|
||||
// outlives a lane switch unless the sync effect puts the new lane's own
|
||||
// profile back.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/b/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
});
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
// The row is one instance shared by every lane, so an apply still in flight
|
||||
// when the user moves on would paint its rollback and its error on whoever
|
||||
// is on screen by then — lane B's failure changing lane A's picker.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
let reject: (e: Error) => void = () => {};
|
||||
vi.mocked(api.lanes.routerApply).mockImplementation(
|
||||
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
|
||||
);
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
await act(async () => {
|
||||
reject(new Error("EBADJSON"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||||
// And the picker must not be left locked by a request for another lane.
|
||||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("puts the selection back when the apply fails", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
||||
* AHEAD of the declared one, including when the declaration used a node alias
|
||||
* (which is most of what the ship-feature skill declares).
|
||||
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||
* declared one, including when the declaration used a node alias — which is
|
||||
* most of what the ship-feature skill declares), and the reorder handles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LaneStripCard from "../LaneStripCard";
|
||||
import type { Lane, LaneNode } from "../../../lib/types";
|
||||
|
||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneStripCard reorder", () => {
|
||||
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
|
||||
function dataTransfer(initial = "") {
|
||||
let payload = initial;
|
||||
return {
|
||||
setData: (_type: string, v: string) => {
|
||||
payload = v;
|
||||
},
|
||||
getData: () => payload,
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
};
|
||||
}
|
||||
|
||||
it("reports the dragged tile's index and the drop target's", () => {
|
||||
const onReorder = vi.fn();
|
||||
const dt = dataTransfer();
|
||||
render(
|
||||
<>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 1 })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 2 })}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
|
||||
expect(onReorder).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it("ignores a drop carrying no tile index", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-1");
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 1);
|
||||
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 3);
|
||||
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft" });
|
||||
expect(onReorder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the id without the 'Lane' prefix", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 7, title: "checkout flow" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-7");
|
||||
expect(tile).toHaveTextContent("#7");
|
||||
expect(tile.textContent).not.toMatch(/Lane/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,13 +426,17 @@ export function LaneConsolePane({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||
description of the panel, not a message, so it does not earn the
|
||||
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||
where the width would wrap it again. */}
|
||||
<header className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<h1 className="text-base font-semibold text-fg-primary">
|
||||
{t("title", { tool: toolLabel })}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
@@ -447,7 +451,9 @@ export function LaneConsolePane({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
||||
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||
{t("subtitle", { tool: toolLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
|
||||
@@ -147,9 +147,83 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||
settings left, source right. They used to be two rows with a divider
|
||||
between them, which spent a band of height on four labelled selects
|
||||
that are each one line tall. The resume picker below is the only part
|
||||
that still gets a row of its own, and only while resume is chosen. */}
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
@@ -166,8 +240,11 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
{props.tool === "claude" ? (
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
@@ -192,8 +269,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -214,78 +291,6 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
|
||||
const LANE: Lane = {
|
||||
id: 1,
|
||||
router_profile: null,
|
||||
title: "demo",
|
||||
cwd: "/workspace/a",
|
||||
branch: null,
|
||||
|
||||
@@ -235,6 +235,14 @@
|
||||
"filterAll": "All types"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Check quota",
|
||||
"usageChecking": "Checking…",
|
||||
"usageRefresh": "Refresh",
|
||||
"usageRetry": "Try again",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7d",
|
||||
"usageResets": "Resets {{time}}",
|
||||
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
|
||||
"addLaneRepoLabel": "Source repository",
|
||||
"addLaneRepoLabelAdopt": "Directory",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -40,6 +41,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",
|
||||
@@ -52,7 +55,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"cardId": "Lane {{id}}",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
@@ -91,12 +94,17 @@
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"git.filesHeading_one": "{{count}} file changed",
|
||||
"git.filesHeading_other": "{{count}} files changed",
|
||||
"git.filesMore_one": "… and {{count}} more file",
|
||||
"git.filesMore_other": "… and {{count}} more files",
|
||||
"git.noDelta": "new",
|
||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||
"renameLabel": "Rename this lane",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"moreActions": "More actions",
|
||||
@@ -114,9 +122,6 @@
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "not declared",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
@@ -132,5 +137,15 @@
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
|
||||
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
||||
"title": "Run {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -445,6 +445,49 @@
|
||||
"label": "Tools",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the lane form. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Small / fast",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"loading": "Loading profiles…",
|
||||
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}",
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
|
||||
@@ -230,6 +230,14 @@
|
||||
"filterAll": "Tất cả loại"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Kiểm tra quota",
|
||||
"usageChecking": "Đang kiểm tra…",
|
||||
"usageRefresh": "Làm mới",
|
||||
"usageRetry": "Thử lại",
|
||||
"usageFiveHour": "5h",
|
||||
"usageSevenDay": "7 ngày",
|
||||
"usageResets": "Reset lúc {{time}}",
|
||||
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
|
||||
"addLaneRepoLabel": "Repo nguồn",
|
||||
"addLaneRepoLabelAdopt": "Thư mục",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -40,6 +41,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",
|
||||
@@ -52,7 +55,7 @@
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"cardId": "Làn đường {{id}}",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
@@ -91,12 +94,17 @@
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"git.filesHeading_one": "{{count}} tệp thay đổi",
|
||||
"git.filesHeading_other": "{{count}} tệp thay đổi",
|
||||
"git.filesMore_one": "… và {{count}} tệp nữa",
|
||||
"git.filesMore_other": "… và {{count}} tệp nữa",
|
||||
"git.noDelta": "mới",
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"moreActions": "Thêm hành động",
|
||||
@@ -114,9 +122,6 @@
|
||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "chưa khai báo",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
@@ -132,5 +137,15 @@
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"permissionMode": "Mode",
|
||||
"session": "Session"
|
||||
},
|
||||
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
|
||||
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
|
||||
"title": "Chạy {{tool}}",
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
|
||||
@@ -445,6 +445,49 @@
|
||||
"label": "Công cụ",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
|
||||
"add": "Thêm profile",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
|
||||
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Đã lưu",
|
||||
"keyMissing": "Chưa có",
|
||||
"fieldLabel": "Tên",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Nhỏ / nhanh",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
|
||||
"loading": "Đang tải danh sách profile…",
|
||||
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file laneOrderStorage.test.ts
|
||||
* @description Tests for the Workspace lane strip's stored card order: the
|
||||
* storage round-trip, and the two rules that keep a stale stored order from
|
||||
* losing a lane — unknown ids land at the end, vanished ids are skipped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
|
||||
|
||||
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||
|
||||
describe("laneOrderStorage", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("returns an empty order when nothing is stored", () => {
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips a written order", () => {
|
||||
writeLaneOrder([3, 1, 2]);
|
||||
expect(readLaneOrder()).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("falls back to an empty order when stored JSON is malformed", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", "{not json");
|
||||
expect(readLaneOrder()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops non-numeric entries from a stored order", () => {
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, "2", null, 3]));
|
||||
expect(readLaneOrder()).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStoredOrder", () => {
|
||||
it("keeps server order when nothing is stored", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [])).toEqual(lanes(1, 2, 3));
|
||||
});
|
||||
|
||||
it("applies the stored order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3), [3, 1, 2])).toEqual(lanes(3, 1, 2));
|
||||
});
|
||||
|
||||
it("appends lanes the stored order has never seen, in server order", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 2, 3, 4), [3, 1])).toEqual(lanes(3, 1, 2, 4));
|
||||
});
|
||||
|
||||
it("skips stored ids whose lane no longer exists", () => {
|
||||
expect(sortByStoredOrder(lanes(1, 3), [3, 2, 1])).toEqual(lanes(3, 1));
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const input = lanes(1, 2, 3);
|
||||
sortByStoredOrder(input, [3, 2, 1]);
|
||||
expect(input.map((l) => l.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLane", () => {
|
||||
it("moves a card forward", () => {
|
||||
expect(moveLane([1, 2, 3], 0, 2)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it("moves a card backward", () => {
|
||||
expect(moveLane([1, 2, 3], 2, 0)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns the order unchanged for a no-op or out-of-range move", () => {
|
||||
expect(moveLane([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
|
||||
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
+154
-2
@@ -1584,6 +1584,21 @@ export const api = {
|
||||
"/cc-config/profiles/activate",
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
/**
|
||||
* POST /api/cc-config/profiles/usage - how much of this profile's account
|
||||
* quota is already spent.
|
||||
*
|
||||
* A POST because answering it is a write: a saved profile's access token has
|
||||
* almost always expired, so the server refreshes it against Anthropic first
|
||||
* and the refresh rotates the credentials stored on disk. That is also why
|
||||
* this is never called on a timer — only when the user asks for one profile.
|
||||
* No token material crosses the boundary in either direction.
|
||||
*/
|
||||
profileUsage: (name: string) =>
|
||||
request<CcProfileUsage>("/cc-config/profiles/usage", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
|
||||
deleteProfile: (name: string) =>
|
||||
request<{ ok: true; name: string }>("/cc-config/profiles", {
|
||||
@@ -1868,6 +1883,49 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also resets every lane using it back to
|
||||
* native, on disk as well as in the DB.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string; lanes: AiRouterLaneSync }>(
|
||||
`/ai-routers/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||
/** Spawn/manage headless or conversational `claude` CLI child processes
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
@@ -2434,6 +2492,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",
|
||||
@@ -2444,8 +2512,6 @@ export const api = {
|
||||
`/lanes/${id}/profile/init`,
|
||||
{ method: "POST", body: JSON.stringify({ force }) }
|
||||
),
|
||||
integration: (id: number, name: string) =>
|
||||
request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`),
|
||||
syncBaseCheck: (id: number, branch?: string) =>
|
||||
request<{
|
||||
code: number;
|
||||
@@ -2605,10 +2671,96 @@ export interface CcProfile extends CcAccountIdentity {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
|
||||
export interface CcUsageWindow {
|
||||
/** Percent of the window's allowance already spent, 0-100. */
|
||||
utilization: number | null;
|
||||
/** ISO timestamp the window rolls over at, when the account reports one. */
|
||||
resetsAt: string | null;
|
||||
}
|
||||
|
||||
/** An account's spent quota, for the profile switcher. Percentages and reset
|
||||
* times only — the OAuth token stays on the server. */
|
||||
export interface CcProfileUsage {
|
||||
name: string;
|
||||
/** True when the stored token had expired and was refreshed to answer this. */
|
||||
refreshed: boolean;
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
smallFast: string;
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** How an edit or a delete landed on the lanes already using that profile.
|
||||
* Attaching copies a profile, so the store alone is not the whole truth: a
|
||||
* lane listed in `failed` still carries the OLD key and endpoint. */
|
||||
export interface AiRouterLaneSync {
|
||||
updated: number;
|
||||
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
* as summarized by the `ccConfig` list endpoints. The list endpoints return
|
||||
* a lightweight summary (frontmatter + a preview) rather than full contents;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file laneOrderStorage.ts
|
||||
* @description Persists the Workspace lane strip's user-chosen card order to
|
||||
* localStorage, the same read/validate/fallback convention as
|
||||
* splitViewStorage.ts.
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, like the split-pane layout beside it, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "agenthub.workspace.laneOrder";
|
||||
|
||||
export function readLaneOrder(): number[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is number => typeof id === "number");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLaneOrder(ids: number[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a stored id order to a freshly fetched lane list.
|
||||
*
|
||||
* Ids the stored order never saw — a lane created since, or created in another
|
||||
* tab — keep their server order (id ascending) and land at the END, so a new
|
||||
* lane appears predictably rather than wherever a stale index puts it. Stored
|
||||
* ids naming a lane that no longer exists are simply skipped.
|
||||
*
|
||||
* @param lanes Lanes in server order.
|
||||
* @param order Stored id order.
|
||||
* @returns A new array; the input is never mutated.
|
||||
*/
|
||||
export function sortByStoredOrder<T extends { id: number }>(lanes: T[], order: number[]): T[] {
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const known = lanes
|
||||
.filter((l) => rank.has(l.id))
|
||||
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
const fresh = lanes.filter((l) => !rank.has(l.id));
|
||||
return [...known, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the lane at `from` to index `to`, returning the resulting id order.
|
||||
* Out-of-range indices yield the unchanged order rather than a hole.
|
||||
*/
|
||||
export function moveLane(ids: number[], from: number, to: number): number[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= ids.length || to >= ids.length) return ids;
|
||||
const next = [...ids];
|
||||
next.splice(to, 0, ...next.splice(from, 1));
|
||||
return next;
|
||||
}
|
||||
@@ -2420,6 +2420,11 @@ export interface Lane {
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
tool: LaneTool;
|
||||
/** Name of the AI Router profile this lane runs against, or null for the
|
||||
* vendor's own API. Unlike `tool` this IS changeable after creation, but
|
||||
* only through `POST /api/lanes/:id/router/apply` — the column and the
|
||||
* lane's own config on disk must move together. */
|
||||
router_profile: string | null;
|
||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||
source_repo: string | null;
|
||||
@@ -2587,9 +2592,23 @@ export type LaneGitFacts =
|
||||
dirty: number;
|
||||
/** Files git has never seen. */
|
||||
untracked: number;
|
||||
/** The files behind the counts, capped; the counts themselves stay exact. */
|
||||
files: LaneGitFile[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One uncommitted file, as `GET /api/lanes/:id/git` reports it. */
|
||||
export interface LaneGitFile {
|
||||
/** Path relative to the repo root; for a rename, the new path. */
|
||||
path: string;
|
||||
/** The two-character `git status --porcelain` code, e.g. ` M`, `??`, `A `. */
|
||||
status: string;
|
||||
/** Lines added, or null when git has no delta for it (untracked, binary). */
|
||||
added: number | null;
|
||||
/** Lines removed, or null for the same reasons. */
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @file AiRouterConfig.tsx
|
||||
* @description The **AI Router** tab of Settings — CRUD for the named router
|
||||
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
|
||||
* at instead of the vendor's own API.
|
||||
*
|
||||
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one
|
||||
* copies it into that lane's own config, so editing or deleting a profile here
|
||||
* does reach every lane already using it — the server re-applies it, or resets
|
||||
* those lanes to native, and reports any lane it could not touch. That report
|
||||
* is surfaced: a rotated key that silently missed two lanes would leave the
|
||||
* user believing a revoked key is out of use when it is not.
|
||||
*
|
||||
* The built-in `native` row is rendered read-only: it is the vendor's own API,
|
||||
* and it exists so that "go back to native" is the same operation as "use a
|
||||
* router" rather than a special case.
|
||||
*
|
||||
* An API key is write-only here by design — the server never returns one, so
|
||||
* the form shows whether a key is saved, never the key.
|
||||
*
|
||||
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
* ever discovered inside a lane's session otherwise, where the error surfaces
|
||||
* as a broken CLI rather than a bad URL.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
|
||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
apiKey: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
label: "",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<AiRouter[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Not an error — the profile itself saved. It is the lanes that did not all
|
||||
// follow, which the user must be told about rather than left to assume.
|
||||
const [laneWarning, setLaneWarning] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiRouter | null>(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The router's own verdict on the form as it currently stands. Cleared on
|
||||
// every field change: a green "works" left sitting above an edited base URL
|
||||
// is a claim about a configuration that no longer exists.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||
// distinction for the same reason.
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
|
||||
// stable `t`, but nothing in this component's contract guarantees that, and
|
||||
// the moment it changes identity per render this callback does too — which
|
||||
// re-fires the effect below, which calls load(), which renders again. That
|
||||
// loop also clears `error` on every pass, so a failed save would flash and
|
||||
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.aiRouters.list()).items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Any edit to the form invalidates the last verdict — see `testResult`.
|
||||
useEffect(() => setTestResult(null), [form]);
|
||||
|
||||
const startAdd = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: AiRouter) => {
|
||||
setEditing(item);
|
||||
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
||||
setForm({
|
||||
label: item.label,
|
||||
baseUrl: item.baseUrl,
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/** Report any lane the server could not re-point, or clear a stale report. */
|
||||
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
|
||||
setLaneWarning(
|
||||
lanes && lanes.failed.length
|
||||
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the endpoint itself. `name` is sent when editing so a profile whose key
|
||||
* the form cannot show is still testable — the server only honours it while
|
||||
* the base URL is unchanged, so a retyped endpoint needs a retyped key.
|
||||
*/
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.aiRouters.test({
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}),
|
||||
...(editing ? { name: editing.name } : {}),
|
||||
});
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
text: result.ok
|
||||
? t("aiRouter.testOk", { model: result.message })
|
||||
: t("aiRouter.testFailed", {
|
||||
status: result.status ?? "—",
|
||||
message: result.message,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
// Send only the tiers that differ from the headline model, so the server
|
||||
// keeps filling the rest from it — that is what makes one symbolic name
|
||||
// enough for a router that wants the same value everywhere.
|
||||
const models: Partial<AiRouterModels> = {};
|
||||
for (const tier of TIERS) {
|
||||
const value = (form.models[tier] || "").trim();
|
||||
if (value && value !== form.model.trim()) models[tier] = value;
|
||||
}
|
||||
const body: AiRouterInput = {
|
||||
label: form.label.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes);
|
||||
else await api.aiRouters.create(body);
|
||||
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: AiRouter) => {
|
||||
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
reportLanes((await api.aiRouters.remove(item.name)).lanes);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<RouterIcon className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiRouter.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiRouter.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{laneWarning && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
|
||||
{laneWarning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
{t("aiRouter.builtinHint")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
||||
{item.baseUrl || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{item.builtin
|
||||
? "—"
|
||||
: item.hasKey
|
||||
? t("aiRouter.keySet")
|
||||
: t("aiRouter.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.edit")} ${item.label}`}
|
||||
onClick={() => startEdit(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.remove")} ${item.label}`}
|
||||
onClick={() => remove(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(loading || items.length <= 1) && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
||||
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
|
||||
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field
|
||||
id="router-label"
|
||||
label={t("aiRouter.fieldLabel")}
|
||||
hint={t("aiRouter.fieldLabelHint")}
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-base-url"
|
||||
label={t("aiRouter.fieldBaseUrl")}
|
||||
hint={t("aiRouter.fieldBaseUrlHint")}
|
||||
value={form.baseUrl}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-api-key"
|
||||
label={t("aiRouter.fieldApiKey")}
|
||||
hint={
|
||||
editing && editing.hasKey
|
||||
? t("aiRouter.fieldApiKeyKeep")
|
||||
: t("aiRouter.fieldApiKeyHint")
|
||||
}
|
||||
value={form.apiKey}
|
||||
type="password"
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-model"
|
||||
label={t("aiRouter.fieldModel")}
|
||||
hint={t("aiRouter.fieldModelHint")}
|
||||
value={form.model}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
{t("aiRouter.perTier")}
|
||||
</summary>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{TIERS.map((tier) => (
|
||||
<Field
|
||||
key={tier}
|
||||
id={`router-model-${tier}`}
|
||||
label={t(`aiRouter.tier.${tier}`)}
|
||||
value={form.models[tier] || ""}
|
||||
mono
|
||||
placeholder={form.model}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
data-testid="router-test-result"
|
||||
className={`mt-3 rounded-md border px-3 py-2 text-xs ${
|
||||
testResult.ok
|
||||
? "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
: "border-status-danger/40 bg-status-danger/10 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{testResult.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="router-test"
|
||||
onClick={test}
|
||||
disabled={testing || busy || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="mr-auto rounded-md border border-border-light px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
|
||||
>
|
||||
{testing ? t("aiRouter.testing") : t("aiRouter.test")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
||||
>
|
||||
{t("aiRouter.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{t("aiRouter.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
mono,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
|
||||
mono ? "font-mono" : ""
|
||||
}`}
|
||||
/>
|
||||
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,8 @@ import type {
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
@@ -4232,6 +4234,11 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`. Empty until asked for: reading it
|
||||
* refreshes that account's stored OAuth token, so it is never fetched with
|
||||
* the list — the user asks per profile, one profile at a time. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -4276,6 +4283,26 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setBusy(`usage:${name}`);
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -4441,6 +4468,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={busy === `usage:${p.name}`}
|
||||
disabled={busy !== null}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
@@ -4459,6 +4493,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+241
-172
@@ -2,8 +2,8 @@
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -42,23 +42,26 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type {
|
||||
Lane,
|
||||
LaneFeature,
|
||||
LaneCounts,
|
||||
ProofFeature,
|
||||
Session,
|
||||
WSMessage,
|
||||
LaneTool,
|
||||
} from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
import LaneCard, {
|
||||
since,
|
||||
LIVENESS_DOT,
|
||||
LaneToolsMenu,
|
||||
LaneRouterRow,
|
||||
} from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
@@ -67,6 +70,14 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||
* each, so the toggle reads at a glance without spending three words on it. */
|
||||
const LAYOUT_CHOICES = [
|
||||
{ n: 1 as const, Icon: Square },
|
||||
{ n: 2 as const, Icon: Columns2 },
|
||||
{ n: 4 as const, Icon: Grid2x2 },
|
||||
];
|
||||
|
||||
function SplitLayoutToggle({
|
||||
splitView,
|
||||
setLayout,
|
||||
@@ -78,21 +89,29 @@ function SplitLayoutToggle({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([1, 2, 4] as const).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{tLanes("splitView.paneCount", { count: n })}
|
||||
</button>
|
||||
))}
|
||||
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
||||
// The count still reaches assistive tech and the tooltip — only the
|
||||
// rendered word is gone, and the icon says the same thing in the
|
||||
// shape every other app uses for it.
|
||||
const label = tLanes("splitView.paneCount", { count: n });
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-pressed={splitView.layout === n}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setLayout(n)}
|
||||
className={`rounded border p-1.5 ${
|
||||
splitView.layout === n
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +201,6 @@ export function Workspace() {
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
@@ -194,6 +212,9 @@ export function Workspace() {
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -241,8 +262,10 @@ export function Workspace() {
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
setCounts(keepIfSame(r.counts));
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -461,6 +484,37 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a lane card within the strip. Purely a viewer preference — it writes
|
||||
* localStorage, never the lane row, so nothing the CLI or another session
|
||||
* reads changes.
|
||||
*/
|
||||
const handleLaneReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
||||
const ids = moveLane(
|
||||
lanes.map((l) => l.id),
|
||||
from,
|
||||
to
|
||||
);
|
||||
writeLaneOrder(ids);
|
||||
setLanes(sortByStoredOrder(lanes, ids));
|
||||
};
|
||||
|
||||
/** Commit an inline lane rename. An empty name clears the title, which puts
|
||||
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
||||
const commitRename = async (id: number) => {
|
||||
const title = renameDraft.trim();
|
||||
setRenamingLaneId(null);
|
||||
const lane = lanes.find((l) => l.id === id);
|
||||
if (!lane || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
@@ -494,66 +548,33 @@ export function Workspace() {
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Page header: what this screen is, and the four numbers that say
|
||||
whether anything needs a human right now. */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
||||
{tLanes("title")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
data-testid="count-total"
|
||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
>
|
||||
{counts.total} {tLanes("countTotal")}
|
||||
</span>
|
||||
<span
|
||||
data-testid="count-running"
|
||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
>
|
||||
{counts.running} {tLanes("countRunning")}
|
||||
</span>
|
||||
{counts.needs_you > 0 && (
|
||||
<span
|
||||
data-testid="count-needs-you"
|
||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
||||
>
|
||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
||||
</span>
|
||||
)}
|
||||
{counts.dead > 0 && (
|
||||
<span
|
||||
data-testid="count-dead"
|
||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
||||
>
|
||||
{counts.dead} {tLanes("countDead")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
||||
<button
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
title={tLanes("addLane")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||
{lanes.map((l) => (
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
{!lanes.length && (
|
||||
@@ -576,27 +597,134 @@ export function Workspace() {
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
||||
>
|
||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
||||
the feature picker and the router select used to sit on a second
|
||||
row below this one; every one of them is a per-lane control that
|
||||
belongs beside the lane's own name, and stacking them cost a whole
|
||||
row of height on a panel whose point is the pipeline map further
|
||||
down. The pipeline's NAME is no longer interpolated into
|
||||
`laneHeader` either — the picker right next to it already says it.
|
||||
They stay gated on `showInfo`, same as before the merge. */}
|
||||
{currentLane && (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||
{tLanes("laneHeader", {
|
||||
id: currentLane.id,
|
||||
title: currentLane.title || currentLane.cwd,
|
||||
pipeline: currentLane.pipeline_name,
|
||||
})}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
{showInfo && (
|
||||
<>
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `agenthub stage` — that string collides with
|
||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||
actively running but has never declared a stage read as if it
|
||||
were sitting idle. Show a distinct label instead of the raw
|
||||
sentinel whenever it doesn't match any node this pipeline
|
||||
actually has. */}
|
||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||
? currentLane.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||
>
|
||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||
</span>
|
||||
)}
|
||||
{features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
@@ -607,82 +735,23 @@ export function Workspace() {
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `agenthub stage` — that string collides with
|
||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||
actively running but has never declared a stage read as if it
|
||||
were sitting idle. Show a distinct label instead of the raw
|
||||
sentinel whenever it doesn't match any node this pipeline
|
||||
actually has. */}
|
||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||
? currentLane.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||
>
|
||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||
</span>
|
||||
)}
|
||||
{features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file Tests for the AI Router settings tab — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AiRouterConfig } from "../AiRouterConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
|
||||
const nineRouter = {
|
||||
...native,
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
hasKey: true,
|
||||
model: "Highest",
|
||||
models: {
|
||||
opus: "Highest",
|
||||
sonnet: "Highest",
|
||||
haiku: "Highest",
|
||||
smallFast: "Highest",
|
||||
subagent: "Highest",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiRouters.update)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
|
||||
vi.mocked(api.aiRouters.remove)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
|
||||
vi.mocked(api.aiRouters.test)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
|
||||
});
|
||||
|
||||
describe("AiRouterConfig", () => {
|
||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a new profile with the typed endpoint, key and model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ models: { opus: "PremiumCombo" } })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
|
||||
// The API cannot return a key, so an edit that sends `apiKey: ""` would
|
||||
// silently erase the one on disk.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
|
||||
|
||||
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
|
||||
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
|
||||
expect(call?.[1]).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("warns when a rotated key did not reach every lane using the profile", async () => {
|
||||
// The whole point of pushing an edit out to the lanes is that a revoked key
|
||||
// stops being used. A lane that silently kept the old one must be named,
|
||||
// or the user believes a rotation is complete when it is not.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.aiRouters.update).mockResolvedValue({
|
||||
...nineRouter,
|
||||
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
|
||||
});
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a server error instead of closing the form", async () => {
|
||||
vi.mocked(api.aiRouters.create).mockRejectedValue(
|
||||
new Error("baseUrl must be http:// or https://")
|
||||
);
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiRouterConfig — testing an endpoint before saving it", () => {
|
||||
it("sends the typed endpoint, key and model, and reports success", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.test).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
|
||||
});
|
||||
|
||||
it("names the profile being edited so the server can reuse its stored key", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.test).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
name: "9router",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the router's own refusal rather than claiming it works", async () => {
|
||||
vi.mocked(api.aiRouters.test).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: "invalid api key",
|
||||
});
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
|
||||
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
|
||||
"aiRouter.testFailed"
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a stale verdict as soon as a field changes", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByTestId("router-test"));
|
||||
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
||||
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
},
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* @file Workspace.laneReorderRename.test.tsx
|
||||
* @description Integration tests for lane strip reordering, persistence to
|
||||
* localStorage, and inline lane rename functionality. Covers the three paths
|
||||
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
|
||||
* commit/cancel/error paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string, payload?: unknown) => {
|
||||
recordedCalls.push({ method, path, payload });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return {
|
||||
lanes: lanesToReturn,
|
||||
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
|
||||
};
|
||||
}),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
runtime: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/runtime`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn(),
|
||||
pipelines: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes/pipelines");
|
||||
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
|
||||
recordCall("PATCH", `/api/lanes/${id}`, patch);
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
const updated = { ...lane, ...patch } as LaneFixture;
|
||||
// Update in place for subsequent reads
|
||||
const idx = lanesToReturn.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) lanesToReturn[idx] = updated;
|
||||
return { lane: updated };
|
||||
}),
|
||||
features: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/features`);
|
||||
return { features: [] };
|
||||
}),
|
||||
show: vi.fn(),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — lane reordering", () => {
|
||||
it("persists lane order to localStorage when dragging a tile", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
|
||||
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
|
||||
|
||||
// Drag tile 3 (Lane C) to position 0
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
const tile3 = screen.getByTestId("lane-tile-3");
|
||||
|
||||
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
|
||||
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
|
||||
fireEvent.drop(tile1, {
|
||||
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage has the new order
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
expect(stored).toBeDefined();
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("applies stored order when lanes are fetched", async () => {
|
||||
// Pre-populate localStorage with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The lane strip should reflect the stored order: B, C, A
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
expect(laneIds).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("appends new lanes to the end, preserving user order", async () => {
|
||||
// Start with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
|
||||
});
|
||||
|
||||
it("ignores stored ids for lanes that no longer exist", async () => {
|
||||
// Store an order that includes a lane ID that will be removed
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
|
||||
|
||||
// Remove lane 2 from the server response
|
||||
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The strip should show lane 1 and 3 in stored order, skipping 999
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
|
||||
expect(laneIds).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("reorders with Alt+Arrow keyboard shortcut", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Get tile 2 (Lane B) and move it right to position 2
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
// Tile 2 was at index 1, moving right puts it at index 2
|
||||
expect(order).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane rename", () => {
|
||||
it("enters rename mode when the pencil icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Click the first lane to select it (so its detail panel shows)
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Find the rename button (displays the lane title with a pencil icon)
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
// The input should appear with the current title
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("commits a rename when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Select lane 1 and open rename
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "New Lane Name");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should have called update with trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
|
||||
});
|
||||
|
||||
// Input should be gone, button should be back
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels rename when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.type(input, "should not save");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Input should be gone
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// No API call should have been made
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Move focus away (tab)
|
||||
await user.tab();
|
||||
await settle();
|
||||
|
||||
// Input should be gone (rename mode exited)
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// The rename button should be back
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trims whitespace from the renamed title", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, " Trimmed ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
// Leave it empty (or with just whitespace)
|
||||
await user.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send empty string
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API if the title has not changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// Don't change the title, just press Enter
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should NOT have called update (or at least not with a changed value)
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows the user to retry after an error, starting fresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Enter and exit rename mode twice to verify the state resets
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Enter again to verify we can re-enter
|
||||
await user.click(screen.getByTestId("lane-rename"));
|
||||
await settle();
|
||||
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("respects max length of 200 characters (enforced by server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// HTML input has maxLength=200, so typing more should be capped
|
||||
expect(input.maxLength).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane order and rename interaction", () => {
|
||||
it("preserves custom order after renaming a lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify order is C, B, A
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["3", "2", "1"]);
|
||||
|
||||
// Click lane 2 (which is now at position 1)
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
await user.click(tile2);
|
||||
await settle();
|
||||
|
||||
// Rename it
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed B");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Wait for the refresh
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Order should still be preserved
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file Workspace.laneRouter.test.tsx
|
||||
* @description The Model (AI Router) picker in the Workspace lane-detail
|
||||
* header, exercised through the page rather than through `LaneRouterRow` on its
|
||||
* own — one row instance serves every lane, so the bug this pins is a lane
|
||||
* switch carrying the previous lane's selection with it: pick a router on one
|
||||
* lane, come back to a lane that is on native, and see the router still
|
||||
* selected there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
router_profile: string | null;
|
||||
};
|
||||
|
||||
const lane = (id: number, title: string): LaneFixture => ({
|
||||
id,
|
||||
cwd: `/workspace/${id}`,
|
||||
title,
|
||||
pipeline: "default",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
router_profile: null,
|
||||
});
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
const nativeProfile = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
return {
|
||||
lanes: [] as { id: number; router_profile: string | null }[],
|
||||
routerApply: vi.fn(),
|
||||
profiles: [
|
||||
nativeProfile,
|
||||
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const routerApply = h.routerApply;
|
||||
let lanesToReturn: LaneFixture[] = [];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => ({
|
||||
lanes: h.lanes,
|
||||
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
|
||||
})),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockResolvedValue({ available: false }),
|
||||
runtime: vi.fn().mockResolvedValue({ available: false }),
|
||||
stage: vi.fn(),
|
||||
update: vi.fn(),
|
||||
pipelines: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
|
||||
// The route the picker must use — never a lane PATCH.
|
||||
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
|
||||
h.routerApply(id, profile);
|
||||
// The row moving with the call is the server's broadcast, not a test
|
||||
// convenience: the apply route broadcasts the lane so the cached row
|
||||
// stops disagreeing with what was just written. Drop the broadcast
|
||||
// server-side and the next lane switch re-reads a stale profile.
|
||||
const row = h.lanes.find((l) => l.id === id);
|
||||
if (row) row.router_profile = profile === "native" ? null : profile;
|
||||
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
|
||||
}),
|
||||
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
|
||||
proof: {
|
||||
list: vi.fn().mockResolvedValue({ features: [] }),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
routerApply.mockClear();
|
||||
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
|
||||
h.lanes = lanesToReturn as unknown as typeof h.lanes;
|
||||
});
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
/** Click the strip tile for a lane, which is how a lane is selected. */
|
||||
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
|
||||
const tile = screen.getAllByText(title)[0];
|
||||
if (!tile) throw new Error(`no strip tile for ${title}`);
|
||||
await user.click(tile);
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("Workspace — the lane Model picker across a lane switch", () => {
|
||||
it("leaves a lane on native after another lane was pointed at a router", async () => {
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectLane(user, "Lane A");
|
||||
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
|
||||
await selectLane(user, "Lane B");
|
||||
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
await settle();
|
||||
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
|
||||
|
||||
// Back to the lane that was never switched. It is still native server-side,
|
||||
// so the picker must say so — the row is one instance shared by every lane.
|
||||
await selectLane(user, "Lane A");
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
// And nothing may have been written to lane A on the way.
|
||||
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
// The lane card's router picker. Only the built-in profile, which is the
|
||||
// shape that renders no picker — nothing here asserts on it.
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
@@ -320,19 +328,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists lanes and counters match the API response", async () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify lanes are rendered
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
|
||||
// Verify counters are rendered with correct values
|
||||
const counterTexts = screen.getAllByText((_, element) => {
|
||||
if (!element) return false;
|
||||
return element.textContent?.includes("2 lanes") || false;
|
||||
});
|
||||
expect(counterTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -489,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("shows the four counters with the values the API reported", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
||||
});
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
|
||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="space-y-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<h2
|
||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
||||
>
|
||||
Lanes
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
||||
data-testid="count-total"
|
||||
>
|
||||
0
|
||||
|
||||
lanes
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
||||
data-testid="count-running"
|
||||
>
|
||||
0
|
||||
|
||||
running
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ml-auto flex items-center gap-2"
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
||||
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
data-testid="lane-tile-add"
|
||||
title="Create a lane from a working directory"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-3.5 w-3.5"
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
@@ -5182,25 +5151,97 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
aria-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
1 pane
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
2 pane
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
4 pane
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -5208,13 +5249,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
data-testid="console-body"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5231,13 +5272,13 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
class="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-lg font-semibold text-fg-primary"
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
>
|
||||
Run Claude
|
||||
</h1>
|
||||
@@ -5251,9 +5292,9 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted max-w-3xl"
|
||||
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Spin up a Claude session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -5298,10 +5339,172 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
@@ -5341,168 +5544,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
placeholder="Type to search or paste an absolute path…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<div
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
@@ -9537,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.5.4 (rebuild client to match)
|
||||
UI build v1.6.10 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+93
-1
@@ -174,6 +174,19 @@ PATCH /api/lanes/:id
|
||||
Partially updates a lane. This route is same-origin guarded because its patch
|
||||
can set `run_id`.
|
||||
|
||||
Every patch value must be a **scalar** — a string, a number or `null`. An
|
||||
object, an array or a boolean for any column but `links` (which is stored as
|
||||
JSON and must be an object) is rejected with `400 EBADFIELD`, as is a `NOT
|
||||
NULL` column patched to `null`: such a value reaches SQLite as a datatype or
|
||||
constraint error, which Express could only report as a `500` on what is plainly
|
||||
the caller's mistake.
|
||||
|
||||
`title` is patchable, which is how the dashboard's inline rename sets a lane's
|
||||
display name. It is trimmed and capped at 200 characters, and `null` clears it
|
||||
rather than erroring — clearing a title is legitimate, since callers fall back
|
||||
to showing the lane's `cwd`. Renaming is display-only: a lane's branch name was
|
||||
slugified from its title once, at creation.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
@@ -245,6 +258,8 @@ 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 `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
|
||||
@@ -347,6 +362,41 @@ dashboard row forgotten, and its directory is never changed. `400` preserves
|
||||
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
||||
|
||||
#### Read a lane's working copy
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git
|
||||
```
|
||||
|
||||
What the lane's directory looks like to git right now:
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||||
unstaged work together — and are `null` when git has no delta to report: an
|
||||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||||
and the lane list is polled and re-broadcast on every hook. See
|
||||
`docs/LANES.md` for the full treatment.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
```http
|
||||
@@ -1598,6 +1648,7 @@ GET /api/cc-config/profiles
|
||||
POST /api/cc-config/profiles Body: { name? }
|
||||
PATCH /api/cc-config/profiles Body: { name, label }
|
||||
POST /api/cc-config/profiles/activate Body: { name }
|
||||
POST /api/cc-config/profiles/usage Body: { name }
|
||||
DELETE /api/cc-config/profiles Body: { name }
|
||||
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
||||
DELETE /api/cc-config/file Body: { scope, type, name? }
|
||||
@@ -1627,7 +1678,7 @@ That indirection is structural, not stylistic: the plugin this dashboard *itself
|
||||
|
||||
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
|
||||
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
|
||||
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
|
||||
|
||||
@@ -1647,6 +1698,10 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
|
||||
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path. **Nothing calls this on a timer** — it is one profile per user request, which is what keeps credential rewrites down to the moments the user asked for. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
|
||||
|
||||
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
|
||||
|
||||
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
|
||||
@@ -1716,6 +1771,43 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
|
||||
|
||||
---
|
||||
|
||||
### AI Router profiles
|
||||
|
||||
```
|
||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||||
DELETE /api/ai-routers/:name Delete one saved profile
|
||||
```
|
||||
|
||||
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
|
||||
|
||||
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a lane happens in the "+ Add lane" modal, or later from the lane's card in Workspace.
|
||||
|
||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||||
|
||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||
|
||||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude lane's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
||||
|
||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||
|
||||
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
||||
|
||||
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
|
||||
|
||||
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
|
||||
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
|
||||
|
||||
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
|
||||
|
||||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
|
||||
|
||||
**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
|
||||
|
||||
+1
-1
@@ -272,7 +272,7 @@ 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 |
|
||||
|
||||
+10
-4
@@ -430,11 +430,17 @@ CREATE TABLE model_pricing (
|
||||
|
||||
Standard rates and intro rates are edited independently: the pricing update path writes intro columns only when the caller sends intro fields, so a standard-rate edit never disturbs a promo (and vice versa). Clearing `intro_until` also zeroes the intro rates.
|
||||
|
||||
**Example default rule (Claude Sonnet 5, with its launch promo):**
|
||||
**Example default rules:**
|
||||
|
||||
| Pattern | Input | Output | Intro Input | Intro Output | Intro Until |
|
||||
|---------|-------|--------|-------------|--------------|-------------|
|
||||
| `claude-sonnet-5%` | $3.00 | $15.00 | $2.00 | $10.00 | `2026-08-31` |
|
||||
| Pattern | Input | Output | Cache read | Fast in/out |
|
||||
|---------|-------|--------|------------|-------------|
|
||||
| `claude-opus-5%` | $5.00 | $25.00 | $0.50 | $10 / $50 |
|
||||
| `claude-sonnet-5%` | $2.00 | $10.00 | $0.20 | — |
|
||||
| `claude-fable-5-1%` | $10.00 | $50.00 | $0.25 | — |
|
||||
|
||||
No rule ships an intro rate today: Sonnet 5's launch promo ran through `2026-08-31` and its discounted rate became the standard one, so `DEFAULT_INTRO_PRICING` is empty. `claude-fable-5-1%` exists alongside `claude-fable-5%` only because 5.1 caches cheaper — that overlap is what the longest-pattern-first match rule above resolves.
|
||||
|
||||
**Retired models.** Claude 3.x, Opus 4/4.1 and Sonnet 4 were dropped from the defaults. Because the startup top-up only *adds* missing rows, those rows are also deleted once from existing databases, guarded by `PRAGMA user_version` (0 → 1) so a model you deliberately re-add in Settings survives later restarts. The same one-shot migration corrects the pre-existing Sonnet 5 row to its post-promo $2 / $10 standard rate. Deleting a rule never touches `token_usage` — only the rates used to price it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+49
-7
@@ -43,6 +43,10 @@ The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
|
||||
|
||||
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
|
||||
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
## Lane tool: Claude or Codex
|
||||
@@ -366,11 +370,15 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
@@ -438,7 +446,12 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
"head": "9b3e74a",
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1
|
||||
"untracked": 1,
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "client/src/lib/api.ts", "status": "M ", "added": 1, "deleted": 0 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -447,10 +460,26 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
is a normal state, not a fault, and the card simply renders without a git row.
|
||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||
prettier invention.
|
||||
- `files` names the files the two counts came from, **capped at 20** — a lane
|
||||
mid-`npm install` would otherwise ship thousands of paths through a polled
|
||||
endpoint. The counts are never capped, so the panel that shows this list says
|
||||
`… and N more files` when it is showing less than all of it. Each entry
|
||||
carries its two-character `git status --porcelain` code (`status`, for a
|
||||
rename the `path` is the NEW one) and its line delta.
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD`, which covers staged
|
||||
and unstaged work together. They are `null` when git has no delta to report:
|
||||
an **untracked** file (git has never seen it — its content is deliberately not
|
||||
read off disk, since a lane mid-build can have a 100 MB artifact), a **binary**
|
||||
file, a **rename** (numstat keys those differently), or a repo with no commit
|
||||
to diff against yet.
|
||||
- Hovering (or focusing, or tapping) the `N modified · N untracked` text on a
|
||||
lane card opens this list as a panel: one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
three times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
five times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
`lane_update`. Putting it there would fire a burst of subprocesses behind every
|
||||
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||
every 30 seconds, and a failed fetch is silent.
|
||||
@@ -570,6 +599,19 @@ contributes at most one record per node, however many times it ran — so this
|
||||
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
|
||||
`client/src/components/workflows/LanePipelinePanel.tsx`).
|
||||
|
||||
**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is
|
||||
the denominator every rate on that page is read against, so what counts as a run
|
||||
decides whether the page is worth reading. Stage detection bounces by design —
|
||||
one feature's work reads `implement → tests → implement → tests` as the session
|
||||
edits, runs the suite, and edits again — and when any backwards move opened a
|
||||
run, a single feature was recorded as five or six of them. A real pass ends
|
||||
where the session says it ends: a backwards move only starts a new run when it
|
||||
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
|
||||
detection that moves backwards continues the run in flight. Reaching the last
|
||||
node still closes the run for both — work after `done` is new work by anyone's
|
||||
reading. On this repo's own lane the rule took a 26-run history down to 5, which
|
||||
is the number of features that were actually shipped through it.
|
||||
|
||||
`?lane=<id>` narrows the same aggregation to one lane and its own archived
|
||||
features, which is what the panel's lane picker sends. The `lanes` array in the
|
||||
response is never narrowed — it populates that picker, so it has to keep
|
||||
@@ -613,7 +655,7 @@ agenthub stage implement --evidence "Schema created, migrations run"
|
||||
This command:
|
||||
- **Declares the stage** the work is now at
|
||||
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
||||
- **Moves the progress bar** forward in the pipeline
|
||||
- **Advances the pipeline map** to that node
|
||||
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
||||
|
||||
### Full command signature
|
||||
@@ -1512,7 +1554,7 @@ A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED
|
||||
agenthub lanes integration tracker # exit 0 = on, 1 = off
|
||||
```
|
||||
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real.
|
||||
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
|
||||
|
||||
### Post-merge CI status: agenthub lanes ci status
|
||||
|
||||
|
||||
+134
-12
@@ -1,6 +1,6 @@
|
||||
# AgentHub — Plugin Marketplace
|
||||
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server, `agenthub` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
|
||||
|
||||
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
|
||||
|
||||
@@ -129,13 +129,14 @@ milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
2. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
3. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Legacy hook cleanup** — see above.
|
||||
6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
7. **UI build** — `client/` is copied into the runtime dir and built there (`npm 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 (API and MCP still work, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
8. **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
@@ -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.
|
||||
+85
-4
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.5.4
|
||||
version: 1.6.10
|
||||
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ĩ
|
||||
@@ -3026,6 +3026,21 @@ components:
|
||||
example:
|
||||
- /Users/son/.claude/.credentials.json
|
||||
- /Users/son/.claude.json
|
||||
CcConfigUsageWindow:
|
||||
type: object
|
||||
nullable: true
|
||||
description: One quota window as the account reports it.
|
||||
properties:
|
||||
utilization:
|
||||
type: number
|
||||
nullable: true
|
||||
description: Percent of the window's allowance already spent, 0-100.
|
||||
example: 48
|
||||
resetsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: When the window rolls over, when the account reports one.
|
||||
CodexConfigScope:
|
||||
type: string
|
||||
enum:
|
||||
@@ -8044,7 +8059,7 @@ paths:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
operationId: ccConfigReload
|
||||
responses:
|
||||
'200':
|
||||
@@ -8979,6 +8994,72 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/profiles/usage:
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: How much of a saved profile's account quota is already spent
|
||||
description: |-
|
||||
Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
A POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.
|
||||
operationId: ccConfigProfileUsage
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: work
|
||||
responses:
|
||||
'200':
|
||||
description: Percentages and reset times for the two quota windows.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
refreshed:
|
||||
type: boolean
|
||||
description: True when the stored token was expired and had to be rotated.
|
||||
fiveHour:
|
||||
$ref: '#/components/schemas/CcConfigUsageWindow'
|
||||
sevenDay:
|
||||
$ref: '#/components/schemas/CcConfigUsageWindow'
|
||||
checkedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
'401':
|
||||
description: '`EUNAUTHORIZED` - the account refused the token even after a refresh.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: '`ENOTFOUND` - no profile by that name.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: '`EUPSTREAM` - Anthropic''s token or usage endpoint did not cooperate.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/codex-config/overview:
|
||||
get:
|
||||
tags:
|
||||
@@ -10690,7 +10771,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: A lane's working-copy facts
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
description: Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.
|
||||
operationId: getLaneGitFacts
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -10700,7 +10781,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.4",
|
||||
"version": "1.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.5.4",
|
||||
"version": "1.6.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
@@ -1943,7 +1943,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
|
||||
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
|
||||
"funding": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.4",
|
||||
"version": "1.6.10",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -160,7 +160,15 @@ function reloadPanes() {
|
||||
// a shell command instead.
|
||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||
try {
|
||||
let first = true;
|
||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
||||
// Let the first command actually submit before typing the second, or
|
||||
// both land on one composer line and Claude Code runs the pair as
|
||||
// `/reload-plugins /reload-skills` — see SETTLE_MS in
|
||||
// server/lib/cc-reload.js. Blocking is fine here: this worker is
|
||||
// detached and serves no requests.
|
||||
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
||||
first = false;
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
|
||||
+282
-31
@@ -14,8 +14,14 @@
|
||||
* scoped to this session, same as before.
|
||||
*
|
||||
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
||||
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
|
||||
* on every plugin update.
|
||||
* the plugin cache, which Claude Code replaces wholesale on every plugin
|
||||
* update. The one thing this file does reach into the cache for is deleting
|
||||
* the copies those updates leave behind (`pruneRetiredPluginRoots`).
|
||||
*
|
||||
* A bootstrap that is no longer the installed build does nothing at all
|
||||
* (`supersededBy`): an old cache directory, still loaded by a Claude Code
|
||||
* process that has not been restarted, would otherwise repoint systemd at
|
||||
* itself and downgrade the dashboard once per session, forever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -40,14 +46,65 @@ function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function pluginVersion() {
|
||||
function pluginVersion(root = PLUGIN_ROOT) {
|
||||
try {
|
||||
return require(path.join(PLUGIN_ROOT, "package.json")).version;
|
||||
return require(path.join(root, "package.json")).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- superseded plugin build */
|
||||
|
||||
/** Claude Code's own record of which build of each plugin is installed. */
|
||||
function installedPluginsPath() {
|
||||
return path.join(getClaudeHome(), "plugins", "installed_plugins.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* The build Claude Code has installed IN PLACE OF this one, or null when this
|
||||
* build is the installed one (or when there is no such record to read).
|
||||
*
|
||||
* `claude plugin update` unpacks the new version into a new cache directory and
|
||||
* says "Restart to apply changes" — but a Claude Code process that was already
|
||||
* running keeps loading the OLD directory, hooks and all. Every session it
|
||||
* opens from then on runs the OLD bootstrap, which happily repoints the systemd
|
||||
* unit at itself and restarts the dashboard onto the version the user just
|
||||
* upgraded away from. The dashboard then reports the old version and offers the
|
||||
* update again — forever, once per session, until Claude Code is restarted.
|
||||
*
|
||||
* So a bootstrap that is no longer the installed build does NOTHING. It cannot
|
||||
* hand over either: the process it would have to replace is the one loading it.
|
||||
*
|
||||
* Matched by SIBLING directory rather than by `<plugin>@<marketplace>` key: the
|
||||
* builds of one plugin are exactly the directories beside this one, and that
|
||||
* holds without parsing a cache path or trusting a name. An entry naming a
|
||||
* directory that no longer exists is ignored — a phantom must never wedge a
|
||||
* bootstrap shut.
|
||||
*/
|
||||
function supersededBy(root = PLUGIN_ROOT) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
|
||||
} catch {
|
||||
return null; // no record, unreadable, or not a plugin install at all
|
||||
}
|
||||
const siblings = path.dirname(root);
|
||||
const found = [];
|
||||
for (const entries of Object.values((data && data.plugins) || {})) {
|
||||
for (const entry of entries || []) {
|
||||
const installed =
|
||||
entry && typeof entry.installPath === "string" ? path.resolve(entry.installPath) : null;
|
||||
if (!installed) continue;
|
||||
// This build IS installed (possibly at one scope among several) — the
|
||||
// whole question is settled, whatever any other entry says.
|
||||
if (installed === root) return null;
|
||||
if (path.dirname(installed) === siblings && fs.existsSync(installed)) found.push(installed);
|
||||
}
|
||||
}
|
||||
return found[0] || null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
|
||||
function statePath(rt = runtimeDir()) {
|
||||
@@ -107,6 +164,12 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
|
||||
// actual check (manifest + every user SKILL.md) runs once, in the worker.
|
||||
if (!state.skillPluginsSettled) return false;
|
||||
// A live server says nothing about whether it will come back. When our unit
|
||||
// still names a previous plugin cache directory, everything looks healthy
|
||||
// until the next reboot — and then the dashboard is simply gone, from a path
|
||||
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
|
||||
// path; `unitIsStale` deliberately spawns nothing.
|
||||
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
|
||||
return serverIsLive();
|
||||
}
|
||||
|
||||
@@ -441,16 +504,133 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ plugin-cache pruning */
|
||||
|
||||
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
|
||||
const KEEP_RETIRED_ROOTS = 2;
|
||||
|
||||
/**
|
||||
* Delete the cache directories earlier updates left behind.
|
||||
*
|
||||
* Claude Code unpacks every plugin update into a NEW directory beside the old
|
||||
* one and does not reliably remove the old. A machine that tracks releases
|
||||
* closely ends up with a dozen full copies of this repo — node_modules and all
|
||||
* — which is what this clears.
|
||||
*
|
||||
* This is the one place anything here touches the plugin cache, and the file
|
||||
* header's "never write into the cache" rule is about the RUNTIME living
|
||||
* outside it, not about leaving retired copies forever. Everything still in use
|
||||
* is protected, by four independent readings of "in use":
|
||||
*
|
||||
* - this build;
|
||||
* - every build Claude Code records as installed, at any scope;
|
||||
* - the directory our systemd unit runs from, foreign unit included;
|
||||
* - any directory still named in the user's `settings.json` hooks — a Claude
|
||||
* Code process that has not been restarted is still executing from there,
|
||||
* and deleting it would break that session's hooks mid-flight. (This is
|
||||
* exactly the directory `supersededBy` describes, seen from the other side.)
|
||||
*
|
||||
* …plus the newest `KEEP_RETIRED_ROOTS` of whatever is left, so a rollback has
|
||||
* something to roll back to. Best effort throughout: a directory that will not
|
||||
* delete is left alone, never an error.
|
||||
*
|
||||
* @returns {number} how many directories were removed
|
||||
*/
|
||||
function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
|
||||
// Only inside Claude Code's plugin cache. For a git checkout the parent
|
||||
// directory holds the user's own work and is never ours to delete from.
|
||||
const cacheMarker = `${path.sep}plugins${path.sep}cache${path.sep}`;
|
||||
if (!root.includes(cacheMarker)) return 0;
|
||||
const cacheDir = path.dirname(root);
|
||||
|
||||
const keep = new Set([root]);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
|
||||
for (const entries of Object.values((data && data.plugins) || {})) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry && typeof entry.installPath === "string")
|
||||
keep.add(path.resolve(entry.installPath));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no record to read — the other three protections still apply */
|
||||
}
|
||||
try {
|
||||
const unitRoot = require("./systemd-service").unitRoot("agenthub");
|
||||
if (unitRoot) keep.add(path.resolve(unitRoot));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
let settings = "";
|
||||
try {
|
||||
settings = fs.readFileSync(getSettingsPath(), "utf8");
|
||||
} catch {
|
||||
/* no settings file — nothing pinned by a hook */
|
||||
}
|
||||
|
||||
let dirs;
|
||||
try {
|
||||
dirs = fs.readdirSync(cacheDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const retired = dirs
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => path.join(cacheDir, d.name))
|
||||
.filter((dir) => !keep.has(dir) && !settings.includes(dir))
|
||||
.map((dir) => {
|
||||
let at = 0;
|
||||
try {
|
||||
at = fs.statSync(dir).mtimeMs;
|
||||
} catch {
|
||||
/* unreadable sorts oldest, so it goes first */
|
||||
}
|
||||
return { dir, at };
|
||||
})
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.slice(KEEP_RETIRED_ROOTS);
|
||||
|
||||
let removed = 0;
|
||||
for (const { dir } of retired) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
/* in use, read-only, whatever — leaving it is always safe */
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
log(rt, `removed ${removed} retired plugin cache ${removed === 1 ? "copy" : "copies"}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real work. Runs detached from the SessionStart hook, so its only channel
|
||||
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
|
||||
* reinstalls dependencies and restarts the server unconditionally.
|
||||
*
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
|
||||
*/
|
||||
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
// First, and ahead of the lock: a retired build has nothing to do and must
|
||||
// not take the lock to discover it. `force` does NOT override this — the
|
||||
// plugin-update chain runs `--force` from the NEW cache directory, which is
|
||||
// never superseded, so the only `--force` this can refuse is one that would
|
||||
// downgrade the user on purpose.
|
||||
const newer = supersededBy();
|
||||
if (newer) {
|
||||
log(
|
||||
rt,
|
||||
`this build (${pluginVersion()}) has been replaced by ${pluginVersion(newer)} — doing nothing. ` +
|
||||
`Restart Claude Code so it loads the new one; until then this session keeps running the old plugin.`
|
||||
);
|
||||
return "superseded";
|
||||
}
|
||||
|
||||
if (!nodeVersionOk()) {
|
||||
log(
|
||||
rt,
|
||||
@@ -465,6 +645,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
return "locked";
|
||||
}
|
||||
|
||||
// Declared out here because the restart it guards has to happen in the
|
||||
// `finally`, after the lock is released and the state is on disk: it is the
|
||||
// step that may cost this process its own life.
|
||||
let pendingSystemdRestart = false;
|
||||
|
||||
try {
|
||||
const wantDeps = depsHash();
|
||||
const state = readState(rt) || {};
|
||||
@@ -504,44 +689,85 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
log(rt, "built the dashboard UI bundle");
|
||||
}
|
||||
|
||||
const svc = require("./systemd-service");
|
||||
// Who owns the running server decides how it may be retired. A unit of
|
||||
// ours means systemd owns it — and this process is a descendant of that
|
||||
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
|
||||
// there does two fatal things at once: the clean exit(0) tells systemd
|
||||
// "meant to stop", and `KillMode=control-group` reaps this bootstrap
|
||||
// before it can write state or point the unit at the new cache dir. That
|
||||
// is the bug that left a dashboard dead after every self-update until the
|
||||
// next Claude Code session happened to bootstrap again.
|
||||
// ACTIVE matters, not merely present: a unit of ours that is installed but
|
||||
// stopped owns nothing right now. If a detached server is holding the port
|
||||
// in that state, treating systemd as the owner would skip the stop and then
|
||||
// ask systemd to start a second server onto a bound port — a crash loop,
|
||||
// courtesy of Restart=always.
|
||||
const systemdOwned =
|
||||
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
|
||||
|
||||
// After a plugin update the running server executes code from a cache
|
||||
// directory Claude Code has already discarded — retire it first.
|
||||
// directory Claude Code has already discarded — retire it first. Only
|
||||
// when nothing else owns its lifecycle; otherwise systemd retires it, in
|
||||
// one restart job, once everything below is durable.
|
||||
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
|
||||
if (movedRoot && stopDashboard()) {
|
||||
if (movedRoot && !systemdOwned && stopDashboard()) {
|
||||
log(rt, "stopped the server started from the previous plugin version");
|
||||
waitForPortsFree();
|
||||
}
|
||||
|
||||
if (movedRoot || !serverIsLive()) {
|
||||
const svc = require("./systemd-service");
|
||||
const result = svc.systemctlAvailable()
|
||||
? svc.installUserService({
|
||||
name: "agenthub",
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: PLUGIN_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
// systemd --user units get systemd's own minimal PATH, not the
|
||||
// shell's — forward it so the server can find `claude`/`codex`.
|
||||
PATH: process.env.PATH || "",
|
||||
},
|
||||
})
|
||||
: "unavailable";
|
||||
// Two different needs, deliberately not collapsed. `needsServer` is "the
|
||||
// running process must be replaced"; `unitStale` is "the unit names a cache
|
||||
// directory that is no longer this one", which is invisible until a reboot
|
||||
// and must be repaired even while a perfectly healthy server is up.
|
||||
const needsServer = movedRoot || !serverIsLive();
|
||||
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
|
||||
if (needsServer || unitStale) {
|
||||
const unit = {
|
||||
name: "agenthub",
|
||||
description: "AgentHub dashboard server (Claude Code plugin)",
|
||||
workingDir: PLUGIN_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: path.join(rt, "node_modules"),
|
||||
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
||||
// systemd --user units get systemd's own minimal PATH, not the
|
||||
// shell's — forward it so the server can find `claude`/`codex`.
|
||||
PATH: process.env.PATH || "",
|
||||
},
|
||||
};
|
||||
// Two shapes of the same step. When systemd already owns the server the
|
||||
// unit is only REWRITTEN here — pointing at the new plugin root — and
|
||||
// the restart is deferred to `pendingSystemdRestart` below, because
|
||||
// starting it now would kill this process mid-bootstrap. Otherwise
|
||||
// there is no cgroup to be reaped by, and install-and-start is fine.
|
||||
const result = systemdOwned
|
||||
? svc.writeUserService(unit)
|
||||
: svc.systemctlAvailable()
|
||||
? svc.installUserService(unit)
|
||||
: "unavailable";
|
||||
if (result === "written" || result === "unchanged") {
|
||||
if (result === "written") svc.enableLinger();
|
||||
// Only when the RUNNING process has to change. A stale unit beside a
|
||||
// healthy server is now corrected on disk and left to take effect at
|
||||
// the next start — restarting a working dashboard to fix a file that
|
||||
// only matters at boot would be churn the user feels for no reason.
|
||||
pendingSystemdRestart = systemdOwned && needsServer;
|
||||
log(
|
||||
rt,
|
||||
result === "written"
|
||||
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
||||
: "systemd --user service already up to date"
|
||||
result === "unchanged"
|
||||
? "systemd --user service already up to date"
|
||||
: unitStale && !needsServer
|
||||
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
|
||||
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
||||
);
|
||||
} else {
|
||||
} else if (needsServer) {
|
||||
// No systemd (macOS/Windows/WSL without it), or the unit exists but
|
||||
// wasn't ours to touch — same detached-spawn fallback as before,
|
||||
// scoped to this session only.
|
||||
// scoped to this session only. Guarded by `needsServer`: entering this
|
||||
// block for a stale unit alone must never spawn a second server onto
|
||||
// the port the live one already holds.
|
||||
const pid = startDashboard(rt);
|
||||
log(
|
||||
rt,
|
||||
@@ -569,9 +795,22 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
},
|
||||
rt
|
||||
);
|
||||
|
||||
// After the state is durable, so a prune that goes wrong cannot also cost
|
||||
// us the record of a bootstrap that went right.
|
||||
pruneRetiredPluginRoots(rt);
|
||||
return "ok";
|
||||
} finally {
|
||||
releaseLock(rt);
|
||||
// LAST, and after the lock: systemd stops the old main process to start
|
||||
// the new one, and this process shares its cgroup, so it may not outlive
|
||||
// this call. Everything that had to be durable already is — the state
|
||||
// file is written and the lock is gone, so even being killed here leaves
|
||||
// no debris and no half-recorded bootstrap.
|
||||
if (pendingSystemdRestart) {
|
||||
log(rt, "handing the server over to systemd (restarting the unit)");
|
||||
require("./systemd-service").restartUserService("agenthub");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +835,14 @@ if (require.main === module) {
|
||||
// npm output and the result.
|
||||
const result = bootstrap(runtimeDir(), { force });
|
||||
if (force) {
|
||||
console.log(result === "ok" ? "AgentHub runtime refreshed." : `bootstrap: ${result}`);
|
||||
console.log(
|
||||
result === "ok"
|
||||
? "AgentHub runtime refreshed."
|
||||
: result === "superseded"
|
||||
? "A newer AgentHub plugin build is already installed — this one did nothing on purpose.\n" +
|
||||
"Restart Claude Code so it loads the new build, then try again."
|
||||
: `bootstrap: ${result}`
|
||||
);
|
||||
if (result !== "ok") process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
@@ -611,6 +857,11 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
||||
* declared package, not just that node_modules exists), server liveness,
|
||||
* duplicate hook entries (the one failure that silently doubles every token and
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, whether the slash
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
|
||||
* every browser-started run depends on, whether the slash
|
||||
* commands the pipeline templates name as node drivers are actually resolvable
|
||||
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
|
||||
* build error when it is missing.
|
||||
@@ -82,6 +83,8 @@ function diagnose() {
|
||||
);
|
||||
|
||||
add(...cliStatus());
|
||||
add(...systemdStatus());
|
||||
add(...tmuxStatus());
|
||||
add(...skillPluginStatus());
|
||||
|
||||
add(...dashboardUiStatus(rt));
|
||||
@@ -218,6 +221,76 @@ function skillPluginStatus() {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this row exists for is silent by construction: a unit still
|
||||
* naming a retired plugin cache directory keeps a running dashboard looking
|
||||
* perfectly healthy, and only bites at the next reboot — when the server does
|
||||
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
|
||||
* in this report would show it.
|
||||
*
|
||||
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
|
||||
* Windows or a Linux box without systemd there is no unit to have an opinion
|
||||
* about.
|
||||
*/
|
||||
function systemdStatus() {
|
||||
const svc = require("./systemd-service");
|
||||
const unit = svc.unitPath("agenthub");
|
||||
|
||||
if (!svc.unitIsOurs("agenthub")) {
|
||||
if (fs.existsSync(unit)) {
|
||||
return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`];
|
||||
}
|
||||
return [
|
||||
"ok",
|
||||
"systemd",
|
||||
svc.systemctlAvailable()
|
||||
? "no unit — the server is started per session (install one with `agenthub service install`)"
|
||||
: "not available on this platform — the server is started per session",
|
||||
];
|
||||
}
|
||||
|
||||
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
|
||||
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
|
||||
];
|
||||
}
|
||||
|
||||
if (!svc.unitIsActive("agenthub")) {
|
||||
return [
|
||||
"warn",
|
||||
"systemd",
|
||||
"unit is installed and current but not running — start it with `systemctl --user start agenthub`",
|
||||
];
|
||||
}
|
||||
return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"];
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
||||
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
||||
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
||||
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
|
||||
* skill-plugins row: a partial feature loss must not fail a doctor run.
|
||||
*
|
||||
* This checks the doctor's own PATH. A server started as a systemd `--user`
|
||||
* service can still see a different one — `GET /api/run/tmux` is the server's
|
||||
* own answer, and `agenthub doctor` reports that.
|
||||
*/
|
||||
function tmuxStatus() {
|
||||
if (require("../server/lib/tmux").isTmuxAvailable()) {
|
||||
return ["ok", "tmux", "on PATH — the run console can start sessions"];
|
||||
}
|
||||
return [
|
||||
"warn",
|
||||
"tmux",
|
||||
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
|
||||
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
|
||||
];
|
||||
}
|
||||
|
||||
function countLegacyHookEntries() {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
||||
|
||||
+136
-13
@@ -26,9 +26,89 @@ function unitPath(name) {
|
||||
return path.join(unitDir(), `${name}.service`);
|
||||
}
|
||||
|
||||
// Test seam: swap the systemctl implementation so unit tests never talk to a
|
||||
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
|
||||
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
||||
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
|
||||
}
|
||||
|
||||
function systemctlAvailable() {
|
||||
if (process.platform !== "linux") return false;
|
||||
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0;
|
||||
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a unit by this name, and did WE write it? The distinction decides
|
||||
* whether the dashboard may be stopped by hand: a unit of ours means systemd
|
||||
* owns the process, and SIGTERM-ing it out of band is the one thing that must
|
||||
* never happen (see `restartUserService`).
|
||||
*/
|
||||
function unitIsOurs(name) {
|
||||
try {
|
||||
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
|
||||
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
|
||||
* stop during a plugin self-update — then left the dashboard down until some
|
||||
* later Claude Code session happened to run the bootstrap. An explicit
|
||||
* `systemctl stop` (what uninstall does) still stops it for good.
|
||||
*/
|
||||
/**
|
||||
* Does our unit still point at `root`? A plugin update moves the install to a
|
||||
* new cache directory, and a unit left naming the old one is a failure that
|
||||
* shows NOTHING until the next reboot — the dashboard simply does not come
|
||||
* back, from a path Claude Code garbage-collected weeks earlier.
|
||||
*
|
||||
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
|
||||
* runs on every single SessionStart and may not spawn a process.
|
||||
*
|
||||
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
|
||||
*/
|
||||
function unitIsStale(name, root) {
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return false; // no unit at all: nothing to be stale
|
||||
}
|
||||
if (!body.includes(MARKER)) return false;
|
||||
return !body.includes(`\nWorkingDirectory=${root}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory our unit actually runs from, or null when there is no unit.
|
||||
*
|
||||
* Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is
|
||||
* not ours to rewrite, but whatever directory it runs from is still a directory
|
||||
* nothing here may delete. The one caller is the plugin-cache pruner.
|
||||
*/
|
||||
function unitRoot(name) {
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(unitPath(name), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = body.match(/^WorkingDirectory=(.*)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
||||
* from the fast path. */
|
||||
function unitIsActive(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
function unitContent({ description, workingDir, execStart, env }) {
|
||||
@@ -44,7 +124,7 @@ After=network.target
|
||||
Type=simple
|
||||
WorkingDirectory=${workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
${envLines}
|
||||
|
||||
@@ -54,17 +134,18 @@ WantedBy=default.target
|
||||
}
|
||||
|
||||
function systemctl(args) {
|
||||
return spawnSync("systemctl", ["--user", ...args], { stdio: "inherit" });
|
||||
return execImpl(args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
|
||||
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
|
||||
* cache path after an update) is reloaded and restarted.
|
||||
* Writes (or refreshes) the unit file and enables it for boot — but starts
|
||||
* nothing. Split out of `installUserService` for the plugin self-update, which
|
||||
* must be able to point the unit at the new cache directory *before* anything
|
||||
* stops the running server, and defer the restart to its very last act.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function installUserService({ name, description, workingDir, execStart, env = {} }) {
|
||||
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const body = unitContent({ description, workingDir, execStart, env });
|
||||
const target = unitPath(name);
|
||||
@@ -72,22 +153,56 @@ function installUserService({ name, description, workingDir, execStart, env = {}
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
if (!current.includes(MARKER)) return "foreign";
|
||||
if (current === body) {
|
||||
systemctl(["enable", "--now", `${name}.service`]);
|
||||
return "unchanged";
|
||||
}
|
||||
if (current === body) return "unchanged";
|
||||
}
|
||||
fs.mkdirSync(unitDir(), { recursive: true });
|
||||
fs.writeFileSync(target, body, "utf8");
|
||||
systemctl(["daemon-reload"]);
|
||||
systemctl(["enable", "--now", `${name}.service`]);
|
||||
systemctl(["restart", `${name}.service`]);
|
||||
systemctl(["enable", `${name}.service`]);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask systemd to restart the unit and do NOT wait for it.
|
||||
*
|
||||
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
|
||||
* spawned by the running server, so it lives in that server's cgroup; the
|
||||
* moment systemd stops the old main process, `KillMode=control-group` reaps
|
||||
* the caller too. Blocking here means being killed mid-call, which is why the
|
||||
* restart must be the last thing the bootstrap does — by then there is nothing
|
||||
* left to lose.
|
||||
*/
|
||||
function restartUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const res = systemctl(["restart", "--no-block", `${name}.service`]);
|
||||
return res.status === 0 ? "restarted" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
|
||||
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
|
||||
* cache path after an update) is reloaded and restarted.
|
||||
*
|
||||
* Safe for `agenthub service install`, which is invoked from a user's own
|
||||
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
|
||||
* is, and uses `writeUserService` + `restartUserService` instead.
|
||||
*
|
||||
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
|
||||
*/
|
||||
function installUserService(opts) {
|
||||
const result = writeUserService(opts);
|
||||
if (result === "unchanged") {
|
||||
systemctl(["enable", "--now", `${opts.name}.service`]);
|
||||
} else if (result === "written") {
|
||||
systemctl(["enable", "--now", `${opts.name}.service`]);
|
||||
systemctl(["restart", `${opts.name}.service`]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
|
||||
function uninstallUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
@@ -110,8 +225,16 @@ function enableLinger() {
|
||||
|
||||
module.exports = {
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
uninstallUserService,
|
||||
enableLinger,
|
||||
unitPath,
|
||||
__setExecImpl,
|
||||
__reset,
|
||||
};
|
||||
|
||||
@@ -369,7 +369,7 @@ describe("agenthub CLI — import & administration", () => {
|
||||
assert.match(err, /Usage: agenthub import/);
|
||||
});
|
||||
|
||||
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
|
||||
it("doctor reports API, hooks, database, tmux, remotes, and uptime lines", async () => {
|
||||
const { code, out } = await agenthub("doctor");
|
||||
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
|
||||
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
|
||||
@@ -377,6 +377,10 @@ describe("agenthub CLI — import & administration", () => {
|
||||
assert.match(out, /Claude Code hooks/);
|
||||
assert.match(out, /Database/);
|
||||
assert.match(out, /Server uptime/);
|
||||
// Asked of the server (GET /api/run/tmux), not of this shell: a missing
|
||||
// tmux is why Start Run fails, and the server's PATH is the one that
|
||||
// decides. Reported either way, and never fatal.
|
||||
assert.match(out, /tmux/);
|
||||
assert.match(out, /Remote sources/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
|
||||
* native profile, and the guarantee that a stored API key never appears in a
|
||||
* response the browser can read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
|
||||
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
|
||||
|
||||
/** The smallest profile the API accepts. */
|
||||
function nineRouter(over = {}) {
|
||||
return {
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ai-routers — the built-in native profile", () => {
|
||||
it("is listed even before anything has been saved", () => {
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].name, "native");
|
||||
assert.equal(items[0].builtin, true);
|
||||
});
|
||||
|
||||
it("leaves the two connection variables empty rather than absent", () => {
|
||||
// The reference native settings.json sets both to "" — an absent key would
|
||||
// let a user-level router base URL leak into a lane meant to run native.
|
||||
const native = routers.getRouter("native");
|
||||
assert.equal(native.baseUrl, "");
|
||||
assert.equal(native.apiKey, "");
|
||||
});
|
||||
|
||||
it("refuses to be edited or removed", () => {
|
||||
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
|
||||
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — create", () => {
|
||||
it("saves a profile and derives its name from the label", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
assert.equal(created.name, "9Router".toLowerCase());
|
||||
assert.equal(created.label, "9Router");
|
||||
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
|
||||
});
|
||||
|
||||
it("stores the file 0600 inside a 0700 directory", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
|
||||
const dir = fs.statSync(ROUTERS_DIR);
|
||||
assert.equal(file.mode & 0o777, 0o600);
|
||||
assert.equal(dir.mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
it("fills every model slot from `model` when no per-tier value is given", () => {
|
||||
// The 9Router reference sets all six slots to one symbolic value; typing it
|
||||
// once must be enough.
|
||||
routers.createRouter(nineRouter());
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "Highest");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
assert.equal(saved.models.haiku, "Highest");
|
||||
assert.equal(saved.models.smallFast, "Highest");
|
||||
assert.equal(saved.models.subagent, "Highest");
|
||||
});
|
||||
|
||||
it("keeps a per-tier value that was given explicitly", () => {
|
||||
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "PremiumCombo");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
});
|
||||
|
||||
it("gives a second profile with the same label a free name", () => {
|
||||
const a = routers.createRouter(nineRouter());
|
||||
const b = routers.createRouter(nineRouter());
|
||||
assert.notEqual(a.name, b.name);
|
||||
});
|
||||
|
||||
it("rejects a label that is empty or carries control characters", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
|
||||
code: "EBADLABEL",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a base URL that is not http(s)", () => {
|
||||
// A `file:` or `javascript:` base URL is never a router; it is either a
|
||||
// mistake or an attempt to point the CLI somewhere it should not go.
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to shadow the built-in native name", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
|
||||
code: "EBUILTIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — the key never reaches the browser", () => {
|
||||
it("is absent from the list, which reports only that one is set", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
const { items } = routers.listRouters();
|
||||
const saved = items.find((i) => i.name === "9router");
|
||||
assert.equal(saved.hasKey, true);
|
||||
assert.equal(saved.apiKey, undefined);
|
||||
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
||||
});
|
||||
|
||||
it("is still absent when the profile has no key at all", () => {
|
||||
routers.createRouter(nineRouter({ apiKey: "" }));
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — update", () => {
|
||||
it("changes the label without changing the name a lane already references", () => {
|
||||
// A lane stores the name. Renaming the label must not orphan that lane.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
|
||||
assert.equal(updated.name, created.name);
|
||||
assert.equal(updated.label, "9Router (nhà)");
|
||||
});
|
||||
|
||||
it("keeps the stored key when the update omits it", () => {
|
||||
// The edit form cannot show the key, so it cannot send it back either.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { label: "renamed" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
|
||||
});
|
||||
|
||||
it("replaces the key when a new one is given", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
it("deletes the file", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.removeRouter(created.name);
|
||||
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
|
||||
assert.equal(routers.listRouters().items.length, 1); // native only
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
|
||||
it("lists the healthy profiles around it", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
|
||||
const { items } = routers.listRouters();
|
||||
assert.ok(items.some((i) => i.name === "9router"));
|
||||
assert.ok(!items.some((i) => i.name === "broken"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — testRouter", () => {
|
||||
const realFetch = global.fetch;
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** Capture the one request testRouter makes, and answer it however asked. */
|
||||
function stubFetch(answer) {
|
||||
const calls = [];
|
||||
global.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
|
||||
|
||||
it("posts one capped message to the endpoint's /v1/messages", async () => {
|
||||
const calls = stubFetch(ok());
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.max_tokens, 1);
|
||||
assert.equal(body.model, "Highest");
|
||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||
});
|
||||
|
||||
it("reports a router that answered with an error rather than throwing", async () => {
|
||||
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
|
||||
});
|
||||
|
||||
it("reports an endpoint that never answered", async () => {
|
||||
// The shape node's own fetch throws: a useless outer message wrapping the
|
||||
// one thing the user can act on.
|
||||
stubFetch(
|
||||
Object.assign(new Error("fetch failed"), {
|
||||
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
|
||||
})
|
||||
);
|
||||
const result = await routers.testRouter(nineRouter());
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, null);
|
||||
assert.match(result.message, /ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it("falls back to the stored key for the endpoint it was stored against", async () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await routers.testRouter({
|
||||
name: created.name,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
});
|
||||
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
|
||||
});
|
||||
|
||||
it("never sends the stored key to a different endpoint", async () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const calls = stubFetch(ok());
|
||||
await assert.rejects(
|
||||
routers.testRouter({
|
||||
name: created.name,
|
||||
baseUrl: "https://attacker.example/v1",
|
||||
model: "Highest",
|
||||
}),
|
||||
{ code: "EBADKEY" }
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("refuses a base URL that is not http(s)", async () => {
|
||||
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => {
|
||||
assert.deepEqual(backup, original);
|
||||
});
|
||||
|
||||
it("writes and backs up nothing when the patch changes nothing", () => {
|
||||
// Re-attaching the same AI Router profile sends the same eight env keys
|
||||
// every time. With an unconditional backup that left a `.bak` per apply
|
||||
// piling up inside the user's own project — a dozen untracked files in a
|
||||
// minute of switching lanes.
|
||||
const file = path.join(FAKE_HOME, "settings.json");
|
||||
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
const before = fs.readFileSync(file, "utf8");
|
||||
|
||||
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
|
||||
|
||||
assert.equal(result.backupPath, null);
|
||||
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
|
||||
assert.equal(fs.readFileSync(file, "utf8"), before);
|
||||
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
|
||||
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
|
||||
});
|
||||
|
||||
it("returns null backupPath for a fresh file", () => {
|
||||
const result = ccMutate.patchSettings({
|
||||
scope: "user",
|
||||
|
||||
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
it("types both reload commands, plugins before skills", async () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
@@ -119,27 +119,45 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
it("waits between the two commands so they cannot share one composer line", async () => {
|
||||
// Without the pause the first Enter lands on Claude Code's slash-command
|
||||
// menu, the second command is appended to the line still in the composer,
|
||||
// and the pane submits `/reload-plugins/reload-skills` as a prompt — the
|
||||
// reported bug. Timing is the behaviour, so timing is what is asserted.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
const at = [];
|
||||
const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length;
|
||||
const before = typed();
|
||||
const started = Date.now();
|
||||
const done = reloadOpenSessions({ settleMs: 40 });
|
||||
at.push(typed() - before);
|
||||
await done;
|
||||
|
||||
assert.equal(at[0], 1, "only the first command is typed before the wait");
|
||||
assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit");
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", async () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
it("ignores an unknown kind rather than typing it into a REPL", async () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
it("types into several lanes in one call", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
@@ -147,9 +165,9 @@ describe("reloadOpenSessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
it("records a pane that dies mid-send without failing the others", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
it("reports an empty result instead of throwing when nothing is open", async () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file cc-usage.test.js
|
||||
* @description Tests for per-profile quota lookup.
|
||||
*
|
||||
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
|
||||
* must be written back — the exchange retires the refresh token it was given,
|
||||
* so dropping the result leaves an account the user has to log into again. And
|
||||
* a still-valid token must NOT be refreshed, because every refresh rewrites
|
||||
* credentials on disk and the point of a read is to read.
|
||||
*
|
||||
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
|
||||
process.env.CLAUDE_HOME = TMP;
|
||||
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const usage = require("../lib/cc-usage");
|
||||
|
||||
const realFetch = global.fetch;
|
||||
let calls = [];
|
||||
|
||||
after(() => {
|
||||
global.fetch = realFetch;
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
||||
} catch {
|
||||
/* best-effort temp cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
function writeProfileFile(name, credentials) {
|
||||
const dir = profiles.profilesDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
name,
|
||||
label: "Label kept",
|
||||
savedAt: 1,
|
||||
account: { emailAddress: "a@b.c" },
|
||||
credentials,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer the token endpoint and the usage endpoint, recording both. */
|
||||
function stubFetch({ usageStatus = 200, usageBody } = {}) {
|
||||
global.fetch = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
access_token: "fresh-access",
|
||||
refresh_token: "rotated-refresh",
|
||||
expires_in: 28800,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: usageStatus === 200,
|
||||
status: usageStatus,
|
||||
json: async () =>
|
||||
usageBody || {
|
||||
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
|
||||
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
|
||||
seven_day_opus: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("profileUsage", () => {
|
||||
it("reports the two windows, and only those", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
|
||||
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
|
||||
// Nothing token-shaped may be in what a route is about to serialise.
|
||||
assert.equal(JSON.stringify(result).includes("still-good"), false);
|
||||
});
|
||||
|
||||
it("does not refresh a token that is still valid", async () => {
|
||||
writeProfileFile("live", {
|
||||
accessToken: "still-good",
|
||||
refreshToken: "r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("live");
|
||||
|
||||
assert.equal(result.refreshed, false);
|
||||
assert.equal(
|
||||
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
||||
false,
|
||||
"a read must not rewrite credentials on disk"
|
||||
);
|
||||
assert.match(calls[0].init.headers.Authorization, /still-good/);
|
||||
});
|
||||
|
||||
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
|
||||
// The exchange retires the refresh token it was handed. A refresh whose
|
||||
// result is not persisted leaves the profile holding a dead credential —
|
||||
// the account could never be activated again without a fresh login.
|
||||
writeProfileFile("stale", {
|
||||
accessToken: "expired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() - 1000,
|
||||
subscriptionType: "team",
|
||||
});
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("stale");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
const stored = profiles.readProfile("stale");
|
||||
assert.equal(stored.credentials.accessToken, "fresh-access");
|
||||
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
|
||||
assert.ok(stored.credentials.expiresAt > Date.now());
|
||||
// Everything else about the profile survives the rewrite.
|
||||
assert.equal(stored.label, "Label kept");
|
||||
assert.equal(stored.credentials.subscriptionType, "team");
|
||||
assert.equal(stored.account.emailAddress, "a@b.c");
|
||||
});
|
||||
|
||||
it("retries once through a refresh when a token that looked valid is refused", async () => {
|
||||
writeProfileFile("revoked", {
|
||||
accessToken: "revoked-but-unexpired",
|
||||
refreshToken: "original-refresh",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
let first = true;
|
||||
global.fetch = async (url) => {
|
||||
calls.push({ url: String(url) });
|
||||
if (String(url).includes("/v1/oauth/token")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
|
||||
};
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
return { ok: false, status: 401, json: async () => ({}) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
|
||||
};
|
||||
};
|
||||
|
||||
const result = await usage.profileUsage("revoked");
|
||||
|
||||
assert.equal(result.refreshed, true);
|
||||
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
|
||||
});
|
||||
|
||||
it("refuses a profile with no refresh token instead of half-answering", async () => {
|
||||
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
|
||||
stubFetch();
|
||||
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("tokenless"),
|
||||
(e) => e.code === "ENOREFRESH"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unknown profile as ENOTFOUND", async () => {
|
||||
stubFetch();
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("nope"),
|
||||
(e) => e.code === "ENOTFOUND"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,26 @@ describe("lane_stage_events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the run in flight when a DETECTION moves backwards", () => {
|
||||
// Detection bounces by design: one feature reads implement → tests →
|
||||
// implement as the session edits, runs the suite and edits again. Counting
|
||||
// each bounce as a new run recorded this repo's own lane as 26 features
|
||||
// when 5 had shipped, and `runs` is what every pass rate is divided by.
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
lanesLib.recordDetection(laneId, { nodeId: "tests", signal: "`npm test`" });
|
||||
// The bounce back only reaches the log once the standing detection has aged
|
||||
// past the TTL — forward-only holds while it is fresh (see recordDetection).
|
||||
db.prepare("UPDATE lanes SET detected_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run(
|
||||
laneId
|
||||
);
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
|
||||
assert.deepEqual(
|
||||
events().map((r) => `${r.node_id}#${r.run_seq}`),
|
||||
["implement#1", "tests#1", "implement#1"]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||
lanesLib.setStage(laneId, { stage: "done" });
|
||||
lanesLib.setStage(laneId, { stage: "intake" });
|
||||
|
||||
@@ -143,6 +143,30 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
|
||||
});
|
||||
|
||||
// The Workspace's inline rename is a text field PATCHing this route, so a
|
||||
// title is the one lane column a user types directly. Bad input on any
|
||||
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
|
||||
// raises a datatype error Express can only report as a 500.
|
||||
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
|
||||
const id = created.body.lane.id;
|
||||
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
|
||||
assert.equal(named.status, 200);
|
||||
assert.equal(named.body.lane.title, "checkout flow");
|
||||
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
|
||||
assert.equal(cleared.body.lane.title, "");
|
||||
|
||||
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
|
||||
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
|
||||
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
}
|
||||
// A NOT NULL column patched to null is the caller's mistake too.
|
||||
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
|
||||
assert.equal(notNull.status, 400);
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("rejects cross-origin lane deletion", async () => {
|
||||
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
|
||||
const id = created.body.lane.id;
|
||||
@@ -253,6 +277,50 @@ describe("/api/lanes", () => {
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on router apply", async () => {
|
||||
// Without this the client's cached row keeps the OLD router_profile: the
|
||||
// picker looks right until the user switches lanes and back, at which
|
||||
// point the effect re-reads the stale row and flips the selection.
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
|
||||
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
|
||||
const id = created.body.lane.id;
|
||||
// Start somewhere other than native, so the broadcast has to carry a
|
||||
// CHANGED value rather than the one the row already held.
|
||||
require("../lib/lanes").setRouterProfile(id, "some-router");
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(applied.status, 200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
|
||||
assert.ok(msg, "router apply should broadcast lane_update");
|
||||
// `native` is stored as null — what matters is that the pushed row is the
|
||||
// post-apply one, not the "some-router" the client still has cached.
|
||||
assert.equal(msg.data.lane.router_profile, null);
|
||||
} finally {
|
||||
// A socket left open by a failed assertion keeps the whole run alive.
|
||||
ws.close();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
|
||||
@@ -358,6 +358,48 @@ describe("lanes lib", () => {
|
||||
assert.equal(after.tool, "codex");
|
||||
assert.equal(after.title, "x");
|
||||
});
|
||||
|
||||
// The Workspace's inline rename PATCHes `title` straight from a text field,
|
||||
// so the column is now reachable by anything same-origin. A non-string would
|
||||
// reach better-sqlite3 as a datatype throw and surface as a 500 on what is
|
||||
// plain bad input.
|
||||
it("normalizes a title on create and on update", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/title-norm", title: " spaced " });
|
||||
assert.equal(lane.title, "spaced");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: 42 }).title, "42");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "x".repeat(500) }).title.length, 200);
|
||||
// null clears rather than violating the NOT NULL, and the empty string is
|
||||
// a real value: it puts the lane's cwd back in the UI's place for it.
|
||||
assert.equal(lanes.updateLane(lane.id, { title: null }).title, "");
|
||||
assert.equal(lanes.updateLane(lane.id, { title: "" }).title, "");
|
||||
});
|
||||
|
||||
// Every free-text column, not just the one the rename field exposes: a
|
||||
// non-scalar reaches better-sqlite3 as a datatype throw, which Express can
|
||||
// only report as a 500 on what is plainly the caller's mistake.
|
||||
it("rejects a non-scalar patch value on any patchable column", () => {
|
||||
const lane = lanes.createLane({ cwd: "/tmp/patch-scalar" });
|
||||
for (const patch of [
|
||||
{ title: { evil: true } },
|
||||
{ notes: { a: 1 } },
|
||||
{ branch: ["main"] },
|
||||
{ ci_status: true },
|
||||
{ session_id: () => "x" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => lanes.updateLane(lane.id, patch),
|
||||
(err) => err.code === "EBADFIELD",
|
||||
`expected EBADFIELD for ${JSON.stringify(Object.keys(patch))}`
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => lanes.createLane({ cwd: "/tmp/patch-scalar-create", title: { evil: true } }),
|
||||
(err) => err.code === "EBADFIELD"
|
||||
);
|
||||
// `links` is the one patchable column that IS an object — it is stored as
|
||||
// JSON, so it must keep working.
|
||||
assert.deepEqual(lanes.updateLane(lane.id, { links: { pr: "u" } }).links, { pr: "u" });
|
||||
});
|
||||
});
|
||||
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -423,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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }), {});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ function makePluginInstall(name, opts = {}) {
|
||||
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");
|
||||
@@ -74,7 +75,7 @@ function makePluginInstall(name, opts = {}) {
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(mktDir, ".claude-plugin", "marketplace.json"),
|
||||
JSON.stringify({ name: "sg", plugins: [{ name: "agenthub", source }] })
|
||||
JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] })
|
||||
);
|
||||
if (knownMarketplaces) {
|
||||
fs.writeFileSync(
|
||||
@@ -128,10 +129,31 @@ describe("pluginMarketplaceUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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".
|
||||
makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } });
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -176,8 +198,15 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
|
||||
|
||||
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
|
||||
|
||||
// The old behaviour: message set, everything else absent.
|
||||
assert.equal(s.message, null, "must not fall through to the not-a-git-clone payload");
|
||||
// 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");
|
||||
@@ -208,6 +237,31 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
|
||||
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") });
|
||||
|
||||
@@ -100,6 +100,16 @@ describe("worktree", () => {
|
||||
assert.equal(s.dirty, 1);
|
||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||
// The files the counts came from, each with its porcelain status code and
|
||||
// its line delta. An UNTRACKED file has no delta to report — git has never
|
||||
// seen it — so its counts are null rather than invented.
|
||||
assert.deepEqual(
|
||||
s.files.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[
|
||||
{ path: "README.md", status: " M", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
]
|
||||
);
|
||||
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||
|
||||
+57
-15
@@ -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 —
|
||||
@@ -818,29 +828,39 @@ try {
|
||||
// Each model gets its own explicit row — no catch-all grouping.
|
||||
// Rate shape mirrors Anthropic's published table: 5m write = 1.25× input, 1h write = 2× input.
|
||||
const DEFAULT_PRICING = [
|
||||
// Next-gen flagship
|
||||
// Next-gen flagship (5.1 caches cheaper than 5 — needs its own row)
|
||||
["claude-fable-5-1%", "Claude Fable 5.1", 10, 50, 0.25, 12.5, 20, 0, 0],
|
||||
["claude-mythos-5-1%", "Claude Mythos 5.1", 10, 50, 0.25, 12.5, 20, 0, 0],
|
||||
["claude-fable-5%", "Claude Fable 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
["claude-mythos-5%", "Claude Mythos 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
// Opus family (fast mode available on 4.6 / 4.7 / 4.8)
|
||||
// Opus family (fast mode available on 5 / 4.6 / 4.7 / 4.8)
|
||||
["claude-opus-5%", "Claude Opus 5", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-8%", "Claude Opus 4.8", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25, 10, 30, 150],
|
||||
["claude-opus-4-6%", "Claude Opus 4.6", 5, 25, 0.5, 6.25, 10, 30, 150],
|
||||
["claude-opus-4-5%", "Claude Opus 4.5", 5, 25, 0.5, 6.25, 10, 0, 0],
|
||||
["claude-opus-4-1%", "Claude Opus 4.1", 15, 75, 1.5, 18.75, 30, 0, 0],
|
||||
["claude-opus-4-2%", "Claude Opus 4", 15, 75, 1.5, 18.75, 30, 0, 0],
|
||||
// Sonnet family
|
||||
["claude-sonnet-5%", "Claude Sonnet 5", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
["claude-sonnet-5%", "Claude Sonnet 5", 2, 10, 0.2, 2.5, 4, 0, 0],
|
||||
["claude-sonnet-4-6%", "Claude Sonnet 4.6", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
["claude-sonnet-4-5%", "Claude Sonnet 4.5", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
["claude-sonnet-4-2%", "Claude Sonnet 4", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
["claude-3-7-sonnet%", "Claude Sonnet 3.7", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
["claude-3-5-sonnet%", "Claude Sonnet 3.5", 3, 15, 0.3, 3.75, 6, 0, 0],
|
||||
// Haiku family
|
||||
["claude-haiku-4-5%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0],
|
||||
["claude-3-5-haiku%", "Claude Haiku 3.5", 0.8, 4, 0.08, 1, 1.6, 0, 0],
|
||||
["claude-3-haiku%", "Claude Haiku 3", 0.25, 1.25, 0.03, 0.3, 0.5, 0, 0],
|
||||
// Legacy
|
||||
["claude-3-opus%", "Claude Opus 3", 15, 75, 1.5, 18.75, 30, 0, 0],
|
||||
];
|
||||
|
||||
// Retired models (Claude 3.x, Opus 4/4.1, Sonnet 4): removed from the defaults
|
||||
// AND pruned ONCE from existing DBs, since the top-up below only ever adds rows.
|
||||
// Once-only (guarded by `user_version`) so a user who deliberately re-adds one
|
||||
// in Settings keeps it. Pruning is safe — these rows only hold rates; historical
|
||||
// token_usage is untouched.
|
||||
const RETIRED_PRICING_PATTERNS = [
|
||||
"claude-opus-4-1%",
|
||||
"claude-opus-4-2%",
|
||||
"claude-sonnet-4-2%",
|
||||
"claude-3-7-sonnet%",
|
||||
"claude-3-5-sonnet%",
|
||||
"claude-3-5-haiku%",
|
||||
"claude-3-haiku%",
|
||||
"claude-3-opus%",
|
||||
];
|
||||
|
||||
// Top-up: insert any default pattern that isn't already present. Preserves
|
||||
@@ -848,6 +868,24 @@ const DEFAULT_PRICING = [
|
||||
// This runs every startup so new default models (e.g. Opus 4.8) appear in the
|
||||
// Settings UI automatically without requiring a manual "Reset Defaults".
|
||||
{
|
||||
if (db.pragma("user_version", { simple: true }) < 1) {
|
||||
const drop = db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?");
|
||||
db.transaction(() => {
|
||||
for (const p of RETIRED_PRICING_PATTERNS) drop.run(p);
|
||||
// Sonnet 5's launch discount expired 2026-08-31 and became the published
|
||||
// standard rate. The top-up below only inserts missing rows, so correct
|
||||
// the existing row here or every pre-existing install keeps billing 3/15.
|
||||
db.prepare(
|
||||
`UPDATE model_pricing SET
|
||||
input_per_mtok = 2, output_per_mtok = 10, cache_read_per_mtok = 0.2,
|
||||
cache_write_per_mtok = 2.5, cache_write_1h_per_mtok = 4,
|
||||
intro_until = NULL
|
||||
WHERE model_pattern = 'claude-sonnet-5%'`
|
||||
).run();
|
||||
})();
|
||||
db.pragma("user_version = 1");
|
||||
}
|
||||
|
||||
const existing = new Set(
|
||||
db
|
||||
.prepare("SELECT model_pattern FROM model_pricing")
|
||||
@@ -867,8 +905,9 @@ const DEFAULT_PRICING = [
|
||||
|
||||
// Known introductory promo rates: [pattern, in, out, cacheRead, cw5m, cw1h, until].
|
||||
// Standard rates live in the DEFAULT_PRICING row; these add the time-limited
|
||||
// discount on top. Claude Sonnet 5: 2/3-off launch pricing through 2026-08-31.
|
||||
const DEFAULT_INTRO_PRICING = [["claude-sonnet-5%", 2, 10, 0.2, 2.5, 4, "2026-08-31"]];
|
||||
// discount on top. Empty today — Sonnet 5's launch discount (through 2026-08-31)
|
||||
// became its standard rate, so it lives in DEFAULT_PRICING now.
|
||||
const DEFAULT_INTRO_PRICING = [];
|
||||
|
||||
// Backfill the known intro rates. Only fills rows whose intro_until is still
|
||||
// NULL, so a user who edits or clears an intro rate in Settings is never
|
||||
@@ -1584,8 +1623,11 @@ const stmts = {
|
||||
WHERE model_pattern = ?
|
||||
`),
|
||||
deletePricing: db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?"),
|
||||
// Longest pattern wins: `claude-fable-5-1%` and `claude-fable-5%` both match
|
||||
// `claude-fable-5-1`, and without an explicit order the winner is whichever
|
||||
// row happened to be inserted first. Most specific pattern is the right one.
|
||||
matchPricing: db.prepare(
|
||||
"SELECT * FROM model_pricing WHERE ? LIKE REPLACE(model_pattern, '%', '%') LIMIT 1"
|
||||
"SELECT * FROM model_pricing WHERE ? LIKE model_pattern ORDER BY LENGTH(model_pattern) DESC LIMIT 1"
|
||||
),
|
||||
toolUsageCounts: db.prepare(`
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* `testRouter` is the one thing here that talks to the network: the smallest
|
||||
* real request a claude lane would make, so "saved" and "actually works" stop
|
||||
* being the same claim. It is deliberately NOT a generic proxy — see the note
|
||||
* on its stored-key fallback.
|
||||
*
|
||||
* @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 };
|
||||
}
|
||||
|
||||
/** How long a connection test waits before calling the endpoint dead. */
|
||||
const TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Enough of a failing router's own words to be actionable, not enough to flood. */
|
||||
const TEST_BODY_MAX = 300;
|
||||
|
||||
/**
|
||||
* Probe a router endpoint with the smallest real request a claude lane makes:
|
||||
* one Anthropic-style message capped at a single output token.
|
||||
*
|
||||
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
|
||||
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
|
||||
* lane's eight env keys point at, and it is the path a router has to serve for
|
||||
* this feature to work at all.
|
||||
*
|
||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||
* this route would be a way to make the server POST a saved API key to any URL
|
||||
* a browser names, which is exactly the leak `listRouters` exists to prevent.
|
||||
*
|
||||
* A router that answers is a success even when it answers with an error: the
|
||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
|
||||
* throws.
|
||||
*
|
||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
|
||||
*/
|
||||
async function testRouter(input = {}) {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const model = normalizeModel(input.model);
|
||||
|
||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||
if (!apiKey && input.name) {
|
||||
const stored = readRouter(input.name);
|
||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||
}
|
||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// Both, because routers disagree on which one they read, and it is the
|
||||
// same key going to the same endpoint either way.
|
||||
"x-api-key": apiKey,
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
|
||||
// endpoint, so it is a result and not a 500.
|
||||
//
|
||||
// `cause` first: node's fetch reports every transport failure as the same
|
||||
// useless "fetch failed", and hides the part the user can act on
|
||||
// (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
||||
};
|
||||
}
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRouters,
|
||||
getRouter,
|
||||
createRouter,
|
||||
updateRouter,
|
||||
removeRouter,
|
||||
testRouter,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
};
|
||||
@@ -571,6 +571,17 @@ function patchSettings(args) {
|
||||
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
|
||||
}
|
||||
|
||||
// A patch that changes nothing writes nothing — and therefore backs up
|
||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||
// file's content now, the request was satisfied, there was just nothing to
|
||||
// move to get there.
|
||||
if (existed && output === raw) {
|
||||
return { path: file, backupPath: null, created: false, changed };
|
||||
}
|
||||
|
||||
let backupPath = null;
|
||||
if (existed) {
|
||||
const root = settingsBackupRoot(scope, opts);
|
||||
|
||||
@@ -368,12 +368,26 @@ function deleteProfile(name) {
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which
|
||||
* has to write a rotated OAuth token pair straight back: the exchange retires
|
||||
* the refresh token it was given, so a profile whose refreshed credentials are
|
||||
* not persisted is a profile that can no longer be activated.
|
||||
*/
|
||||
function writeProfile(name, value) {
|
||||
writeJson(profileFile(name), value);
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// Token-bearing reads/writes: for cc-usage.js, never for a route.
|
||||
readProfile,
|
||||
writeProfile,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
+41
-12
@@ -29,6 +29,20 @@ const { listRuns } = require("./pty-run");
|
||||
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/**
|
||||
* Pause between two commands typed into the SAME pane.
|
||||
*
|
||||
* Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and
|
||||
* the Enter that follows lands on that menu rather than submitting the line.
|
||||
* With no pause the next command's text is appended to the line still sitting
|
||||
* in the composer, and what gets submitted is the single string
|
||||
* `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins`
|
||||
* with `/reload-skills` as its argument, so the second reload never happens and
|
||||
* the pane shows a stray prompt turn. Measured against a live pane: 0.3s is
|
||||
* already enough on an idle one, 1s leaves headroom for a busy REPL.
|
||||
*/
|
||||
const SETTLE_MS = 1000;
|
||||
|
||||
/** The only commands this module is ever allowed to type. Not caller-supplied:
|
||||
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||
const RELOAD_COMMANDS = {
|
||||
@@ -61,30 +75,39 @@ function reloadableLanes() {
|
||||
/**
|
||||
* Type the requested reload commands into every live `claude` pane.
|
||||
*
|
||||
* Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes
|
||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
||||
* sessions the user is actually working in running stale code.
|
||||
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
||||
* REPL's composer, so the command runs when the turn ends rather than being
|
||||
* lost. That is a deliberate choice — a reload that arrives late is still a
|
||||
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
||||
* user is actually working in running stale code. It is also why SETTLE_MS
|
||||
* exists: a composer that has not submitted yet is a line the next command
|
||||
* would be appended to.
|
||||
*
|
||||
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||
* must not fail a batch that updated everything else successfully.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
||||
* disk once the plugin has been reloaded).
|
||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}}
|
||||
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
||||
* separate submissions, and waiting for that without blocking the event loop is
|
||||
* the only way a live dashboard keeps broadcasting while it types.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
|
||||
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
||||
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
||||
* is a test seam — no route passes it.
|
||||
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}>}
|
||||
*/
|
||||
function reloadOpenSessions(options = {}) {
|
||||
async function reloadOpenSessions(options = {}) {
|
||||
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||
const lanes = [];
|
||||
|
||||
for (const lane of reloadableLanes()) {
|
||||
const sent = [];
|
||||
let error;
|
||||
for (const command of commands) {
|
||||
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
||||
try {
|
||||
tmux.sendText(lane.id, command);
|
||||
sent.push(command);
|
||||
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
|
||||
return { commands, lanes, lanes_total: lanes.length };
|
||||
}
|
||||
|
||||
module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS };
|
||||
module.exports = {
|
||||
reloadOpenSessions,
|
||||
reloadableLanes,
|
||||
RELOAD_COMMANDS,
|
||||
SHELL_COMMANDS,
|
||||
SETTLE_MS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file cc-usage.js
|
||||
* @description How much of an Anthropic account's quota is already spent —
|
||||
* the number that decides which saved auth profile (`cc-profiles.js`) is worth
|
||||
* switching to.
|
||||
*
|
||||
* There is no local source for it. Nothing under CLAUDE_HOME records a
|
||||
* utilization or a reset time; the credentials file carries only
|
||||
* `subscriptionType` and `rateLimitTier`. The figure comes from the same
|
||||
* endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for
|
||||
* whichever account's OAuth token is presented.
|
||||
*
|
||||
* Two rules this module exists to hold:
|
||||
*
|
||||
* - **A profile's token is sent to exactly one place: Anthropic.** It is read
|
||||
* from the profile file, put in an `Authorization` header, and never
|
||||
* returned, logged or echoed. What leaves this module is two percentages
|
||||
* and two reset timestamps.
|
||||
*
|
||||
* - **Nothing here runs on a poll.** A saved profile's access token is short
|
||||
* lived — every profile that is not the active one is normally expired — so
|
||||
* reading its usage means refreshing it first, and a refresh ROTATES the
|
||||
* stored refresh token. Doing that on a page load would rewrite the user's
|
||||
* credentials for every account they have, repeatedly, for a number on a
|
||||
* screen. It is therefore driven by an explicit per-profile request, and the
|
||||
* rotated tokens are written straight back (`cc-profiles.writeProfile`) —
|
||||
* a refresh whose result is dropped leaves the profile holding a refresh
|
||||
* token Anthropic has already retired, i.e. an account the user must log in
|
||||
* to again.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const profiles = require("./cc-profiles");
|
||||
|
||||
/** Claude Code's own public OAuth client. A refresh is only accepted for the
|
||||
* client the token was issued to, so this is not a configurable value. */
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
|
||||
/** Treat a token expiring within this window as already expired: a refresh is
|
||||
* cheaper than a 401 that costs the whole call. */
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw err("EUPSTREAM", `${url} did not answer JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a profile's refresh token for a fresh access token AND persist what
|
||||
* comes back.
|
||||
*
|
||||
* The persist is not an optimisation. Anthropic returns a new refresh token and
|
||||
* retires the one just used, so a refresh that is not written back leaves the
|
||||
* profile file holding a dead credential — the account would have to be logged
|
||||
* into again before it could ever be activated.
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<string>} the fresh access token (never returned to a route)
|
||||
*/
|
||||
async function refreshProfile(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
if (!creds.refreshToken) {
|
||||
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
||||
}
|
||||
|
||||
const data = await postJson(TOKEN_URL, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: creds.refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
||||
|
||||
profiles.writeProfile(name, {
|
||||
...stored,
|
||||
credentials: {
|
||||
...creds,
|
||||
accessToken: data.access_token,
|
||||
// Both are rotated by the exchange; keeping the old refresh token here
|
||||
// would pin the profile to a credential that no longer works.
|
||||
refreshToken: data.refresh_token || creds.refreshToken,
|
||||
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
||||
},
|
||||
});
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** The two windows the UI reports, flattened out of a response that also
|
||||
* carries a dozen experiment-named buckets this has no use for. */
|
||||
function windowOf(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const utilization = Number(value.utilization);
|
||||
return {
|
||||
utilization: Number.isFinite(utilization) ? utilization : null,
|
||||
resetsAt: typeof value.resets_at === "string" ? value.resets_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask Anthropic what this token's account has spent. */
|
||||
async function fetchUsage(accessToken) {
|
||||
const res = await fetch(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token");
|
||||
if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage for one saved profile, refreshing its token first when that token has
|
||||
* expired (which, for any profile that is not the active one, it usually has).
|
||||
*
|
||||
* @param {string} name a saved profile's identifier
|
||||
* @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null,
|
||||
* sevenDay:object|null, checkedAt:string}>} percentages and reset times only
|
||||
*/
|
||||
async function profileUsage(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
|
||||
let token = fresh ? creds.accessToken : await refreshProfile(name);
|
||||
let refreshed = !fresh;
|
||||
if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`);
|
||||
|
||||
try {
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
} catch (e) {
|
||||
// A token that looked fresh but was revoked server-side is worth one
|
||||
// retry through the refresh path; a second 401 is a real answer.
|
||||
if (e.code !== "EUNAUTHORIZED" || refreshed) throw e;
|
||||
token = await refreshProfile(name);
|
||||
refreshed = true;
|
||||
const usage = await fetchUsage(token);
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
|
||||
+105
-7
@@ -138,6 +138,28 @@ function hydrate(row) {
|
||||
return { ...row, stages, links, ports };
|
||||
}
|
||||
|
||||
/**
|
||||
* A lane's display name, as a column value. The title column is NOT NULL TEXT
|
||||
* and the dashboard's rename field writes it straight through, so it is
|
||||
* trimmed and capped — a pasted file is not a lane name. `null` clears the
|
||||
* title rather than violating the NOT NULL, because clearing is legitimate:
|
||||
* every render is `lane.title || lane.cwd`. A non-scalar is bad input and is
|
||||
* rejected the same way every other patch value is (see `bindable`).
|
||||
*
|
||||
* @param {unknown} value - Whatever the caller offered as a title.
|
||||
* @returns {string} A safe column value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
const TITLE_MAX = 200;
|
||||
function normalizeTitle(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
const t = typeof value;
|
||||
if (t !== "string" && t !== "number" && t !== "bigint") {
|
||||
throw Object.assign(new Error("title must be a string"), { code: "EBADFIELD" });
|
||||
}
|
||||
return String(value).trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
function createLane({
|
||||
title = "",
|
||||
cwd,
|
||||
@@ -148,6 +170,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,10 +180,10 @@ 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,
|
||||
normalizeTitle(title),
|
||||
cwd.replace(/\/+$/, ""),
|
||||
branch,
|
||||
pipeline,
|
||||
@@ -169,6 +192,7 @@ function createLane({
|
||||
base_branch,
|
||||
slug,
|
||||
tool,
|
||||
router_profile || null,
|
||||
nowIso()
|
||||
);
|
||||
return getLane(info.lastInsertRowid);
|
||||
@@ -182,6 +206,31 @@ function getLane(id) {
|
||||
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch value, as something better-sqlite3 will actually bind.
|
||||
*
|
||||
* It binds numbers, strings, bigints, buffers and null — and THROWS on
|
||||
* anything else, including the `true` and the `{}` a hand-rolled PATCH can
|
||||
* send for any free-text lane column. That throw is indistinguishable from a
|
||||
* server fault by the time it reaches Express, so the route answered 500 to
|
||||
* what is plainly bad input. Rejecting here gives every PATCHABLE field the
|
||||
* one rule, rather than a special case per field the UI happens to expose.
|
||||
*
|
||||
* @param {string} key - The column being patched, for the error message.
|
||||
* @param {unknown} value - Whatever the caller sent for it.
|
||||
* @returns {string|number|bigint|null} A bindable value.
|
||||
* @throws {Error} code `EBADFIELD` when the value is not a scalar.
|
||||
*/
|
||||
function bindable(key, value) {
|
||||
if (key === "title") return normalizeTitle(value);
|
||||
if (value === null || value === undefined) return null;
|
||||
const t = typeof value;
|
||||
if (t === "string" || t === "number" || t === "bigint") return value;
|
||||
throw Object.assign(new Error(`${key} must be a string, a number or null`), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
}
|
||||
|
||||
function updateLane(id, patch = {}) {
|
||||
// Validate kind before building the UPDATE if it's being set
|
||||
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
|
||||
@@ -195,7 +244,8 @@ function updateLane(id, patch = {}) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!PATCHABLE.has(k)) continue;
|
||||
cols.push(`${k} = ?`);
|
||||
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
|
||||
if (k === "links" && v !== null && typeof v === "object") vals.push(JSON.stringify(v));
|
||||
else vals.push(bindable(k, v));
|
||||
}
|
||||
if (cols.length) {
|
||||
cols.push("updated_at = ?");
|
||||
@@ -205,6 +255,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
|
||||
@@ -311,9 +398,17 @@ function reservedPorts(excludeLaneId = null) {
|
||||
*
|
||||
* The run a row belongs to is decided here rather than stored on the lane, so
|
||||
* there is one rule and no column to fall out of sync. A new run starts when
|
||||
* the lane moves BACKWARDS (the next feature begins at plan) or when the last
|
||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
||||
* other move continues the run in flight.
|
||||
* the lane is DECLARED backwards (the next feature begins at plan) or when the
|
||||
* last event sat on the pipeline's final node (the previous feature finished).
|
||||
* Any other move continues the run in flight.
|
||||
*
|
||||
* Only a declaration may open a run by going backwards. Detection bounces by
|
||||
* design — one feature reads `implement → tests → implement` as the session
|
||||
* edits, runs the suite and edits again — so counting every backwards detection
|
||||
* as a new run recorded a single feature as five or six of them, and `runs` is
|
||||
* the denominator the Workflows page reads every pass rate against. A pass ends
|
||||
* where the session says it ends. Reaching the final node still closes the run
|
||||
* for a detection too: work after `done` is new work however it was observed.
|
||||
*
|
||||
* Never throws: a lane's stage must still advance if this log cannot be
|
||||
* written — the dashboard losing a statistic beats an agent's pipeline dying.
|
||||
@@ -328,7 +423,8 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
if (last) {
|
||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
||||
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const declared = source !== "detected";
|
||||
const wentBack = declared && lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||
}
|
||||
@@ -752,6 +848,8 @@ module.exports = {
|
||||
updateLane,
|
||||
deleteLane,
|
||||
setStage,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
clearLane,
|
||||
recoverInterruptedProvisioning,
|
||||
|
||||
+33
-2
@@ -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 = () => {
|
||||
@@ -226,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 });
|
||||
@@ -238,7 +269,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @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) {
|
||||
// The backup directory beside it, for the same reason plus one more: those
|
||||
// copies hold the API key too, and they are what a user sees as a dozen
|
||||
// untracked files appearing in their project while they switch routers.
|
||||
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\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
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* 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: when a
|
||||
* marketplace entry declares `"source": "url"` (this project's own does) Claude
|
||||
* Code unpacks an archive, so the cache directory has no `.git` and there is no
|
||||
* 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`) and
|
||||
* 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
|
||||
@@ -346,11 +347,24 @@ function pluginMarketplaceUrl(marketplace, plugin) {
|
||||
const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin);
|
||||
if (!entry) return null;
|
||||
|
||||
// `source` is either a string (a bare URL or owner/repo) or an object with
|
||||
// its own `source` discriminator plus `url`/`repo`.
|
||||
// `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") return src.includes("://") ? src : null;
|
||||
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
|
||||
@@ -402,7 +416,17 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
`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(" && "),
|
||||
message: null,
|
||||
// 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,
|
||||
|
||||
+60
-4
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
||||
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||
const STATUS_FILE_CAP = 20;
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
@@ -546,14 +548,54 @@ async function removeWorktree(lane) {
|
||||
await deleteBranchSafely(sourceRepo, branch, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file line deltas against HEAD, keyed by path.
|
||||
*
|
||||
* One `git diff --numstat HEAD` covers staged AND unstaged work in a single
|
||||
* subprocess, which is the whole reason it is done this way: the alternative
|
||||
* is one process per file. It says nothing about UNTRACKED files — git has
|
||||
* never seen them, so they have no delta to report and their counts stay null
|
||||
* rather than being invented by reading the file off disk (a lane mid-build
|
||||
* can have an untracked 100 MB artifact).
|
||||
*
|
||||
* A repo with no commits yet has no HEAD to diff against; that throw is not a
|
||||
* fault, it just means every count is unknown.
|
||||
*/
|
||||
async function numstatByPath(dir) {
|
||||
const stats = new Map();
|
||||
try {
|
||||
const result = await git(dir, ["diff", "--numstat", "HEAD"]);
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
// A binary file reports "-" for both, not a number.
|
||||
if (m) {
|
||||
stats.set(m[3], {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no HEAD to diff against -> no per-file deltas */
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||
* Lines starting with ?? are untracked; others are dirty.
|
||||
*
|
||||
* `files` names WHICH files the counts mean, each with its porcelain status
|
||||
* code and its line delta, capped because this rides a polled endpoint and a
|
||||
* lane mid-`npm install` can report thousands. The COUNTS stay exact — only
|
||||
* the listing is capped.
|
||||
*/
|
||||
async function statusCounts(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const stats = await numstatByPath(dir);
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
const files = [];
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -562,29 +604,43 @@ async function statusCounts(dir) {
|
||||
} else {
|
||||
dirty++;
|
||||
}
|
||||
if (files.length < STATUS_FILE_CAP) {
|
||||
// "XY path", and for a rename "XY old -> new" — the new path is the one
|
||||
// that exists now and the one numstat is keyed by. git quotes a path
|
||||
// containing anything unusual; the quotes are presentation, not name.
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const path = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
files.push({
|
||||
path,
|
||||
status: line.slice(0, 2),
|
||||
added: null,
|
||||
deleted: null,
|
||||
...stats.get(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get short commit hash
|
||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||
const head = headResult.stdout.trim();
|
||||
|
||||
return { dirty, untracked, head };
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
*
|
||||
* Read-only and cheap, but it is three subprocesses, which is why it lives
|
||||
* Read-only and cheap, but it is five subprocesses, which is why it lives
|
||||
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||
* payload. A detached HEAD reports the literal `HEAD` that git returns — the
|
||||
* caller shows what git says rather than inventing a nicer word for it.
|
||||
*/
|
||||
async function gitFacts(dir) {
|
||||
const { dirty, untracked, head } = await statusCounts(dir);
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked };
|
||||
return { branch, head, subject, dirty, untracked, files };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1297,6 +1297,25 @@ const schemas = {
|
||||
},
|
||||
},
|
||||
},
|
||||
CcConfigUsageWindow: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "One quota window as the account reports it.",
|
||||
properties: {
|
||||
utilization: {
|
||||
type: "number",
|
||||
nullable: true,
|
||||
description: "Percent of the window's allowance already spent, 0-100.",
|
||||
example: 48,
|
||||
},
|
||||
resetsAt: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
nullable: true,
|
||||
description: "When the window rolls over, when the account reports one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters (inlined per-operation to match base style) ────
|
||||
@@ -1634,7 +1653,7 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||
description:
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
operationId: "ccConfigReload",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -2649,6 +2668,74 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/profiles/usage": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "How much of a saved profile's account quota is already spent",
|
||||
description:
|
||||
"Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.\n\nA POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.",
|
||||
operationId: "ccConfigProfileUsage",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string", example: "work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Percentages and reset times for the two quota windows.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
refreshed: {
|
||||
type: "boolean",
|
||||
description: "True when the stored token was expired and had to be rotated.",
|
||||
},
|
||||
fiveHour: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
sevenDay: { $ref: "#/components/schemas/CcConfigUsageWindow" },
|
||||
checkedAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "`EUNAUTHORIZED` - the account refused the token even after a refresh.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
502: {
|
||||
description: "`EUPSTREAM` - Anthropic's token or usage endpoint did not cooperate.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -268,13 +268,13 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's working-copy facts",
|
||||
description:
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
operationId: "getLaneGitFacts",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
||||
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @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 = {
|
||||
EBADKEY: 400,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers/test — does this endpoint + key + model actually answer?
|
||||
// Body: `{ baseUrl, model, apiKey?, name? }`. Registered before `/:name` for
|
||||
// clarity; no collision today, since `/:name` takes no POST.
|
||||
//
|
||||
// A reachable router is a 200 here whatever it replies — `{ ok:false, status }`
|
||||
// carries the router's own words, which is the whole point. Only bad input is
|
||||
// a 4xx. The response never echoes the key, including on the path where the
|
||||
// stored one was used (see `testRouter` on why that path is narrow).
|
||||
router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
res.json(await routers.testRouter(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;
|
||||
@@ -30,6 +30,7 @@ const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
const ccMcpCli = require("../lib/cc-mcp-cli");
|
||||
const ccProfiles = require("../lib/cc-profiles");
|
||||
const ccUsage = require("../lib/cc-usage");
|
||||
const projectMemory = require("../lib/project-memory");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { broadcast } = require("../websocket");
|
||||
@@ -334,7 +335,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = reloadOpenSessions();
|
||||
reloaded = await reloadOpenSessions();
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
@@ -351,7 +352,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", (req, res) => {
|
||||
router.post("/reload", async (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
@@ -359,7 +360,7 @@ router.post("/reload", (req, res) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
||||
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
@@ -577,6 +578,31 @@ router.post("/profiles/activate", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /profiles/usage — how much of this profile's account quota is spent.
|
||||
*
|
||||
* A POST, and behind the same-origin guard, because it is not a read: a saved
|
||||
* profile's access token has almost always expired, so answering means
|
||||
* refreshing it, and the refresh rotates the stored credentials on disk (see
|
||||
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
|
||||
* per profile, when the user asks.
|
||||
*
|
||||
* The response is two percentages and two reset timestamps. No token material
|
||||
* crosses this boundary, in either direction: the name is all the caller sends.
|
||||
*/
|
||||
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
||||
const name = (req.body || {}).name;
|
||||
try {
|
||||
res.json(await ccUsage.profileUsage(name));
|
||||
} catch (e) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, EUNAUTHORIZED: 401 }[
|
||||
e.code
|
||||
] || 502;
|
||||
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
||||
router.delete("/profiles", (req, res) => {
|
||||
try {
|
||||
|
||||
+57
-6
@@ -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 {
|
||||
@@ -411,7 +412,12 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
|
||||
if (
|
||||
err.code === "EBADCWD" ||
|
||||
err.code === "EBADPIPELINE" ||
|
||||
err.code === "EBADKIND" ||
|
||||
err.code === "EBADFIELD"
|
||||
) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
@@ -431,12 +437,18 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
||||
// fault — every sibling route answers 400 here, so this one must too
|
||||
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
|
||||
// here for the same reason: a NOT NULL column patched to null is the
|
||||
// caller's mistake, and enumerating which columns those are in JS would
|
||||
// just be the schema written twice.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
@@ -618,6 +630,36 @@ 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);
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
broadcastLane(lane.id);
|
||||
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
|
||||
@@ -663,7 +705,7 @@ router.get("/:id/integrations/:name", (req, res) => {
|
||||
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
||||
* exists for the destructive actions.
|
||||
*
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git five
|
||||
* times, and that payload is polled and re-broadcast on every hook-driven
|
||||
* lane_update. Any failure — no such directory, not a repo, git itself
|
||||
* erroring — is reported as `available: false` rather than a 500, because a
|
||||
@@ -1311,6 +1353,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,
|
||||
@@ -1360,6 +1405,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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -260,6 +262,11 @@ router.post("/", (req, res) => {
|
||||
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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user