Commit Graph

49 Commits

Author SHA1 Message Date
nntrivi2001 6cf8a82889 feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
2026-09-24 09:44:42 +07:00
nntrivi2001 1b561c22d9 feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
2026-09-23 09:18:17 +07:00
nntrivi2001 f77049c238 feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33):
- `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a
  tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the
  column it was on) had the rest of that draw land at column 0, stamping a
  line's first characters into the left margin of the rows below — the torn
  left edge that only showed up once content scrolled.
- The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery`
  in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl).
  `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every
  attach, and tmux sizes the window to the newest client, so `claude` redrew
  its whole UI at 80 columns until the first `resize` message landed.
- The terminal takes the keyboard on mount, so typing reaches tmux without
  clicking the pane first.
- A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a
  new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console
  returns to the setup form instead of leaving a dead pane behind a
  "New run" button.

Pane environment:
- `server/lib/desktop-env.js` forwards the graphical session's DISPLAY /
  WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from
  `systemctl --user show-environment`, this process's own values winning)
  into the pane. The server normally runs as a systemd user service with none
  of them, so `claude` there could not read the clipboard and answered "no
  image found in clipboard" on every image paste.

Run setup:
- `auto` is a permission mode and is now the default everywhere — allowlists,
  the `--permission-mode` fallback, the select, and the pane's initial state.
- The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`)
  instead of pinned ids. `claude --model` resolves an alias to the latest
  model itself, so a release like Fable 5.1 needs no edit here; pricing is
  unaffected because it keys off the resolved id the transcript reports.
- **Create/update readme file** moved from the Add-lane modal into the lane's
  Tools menu: creating a lane and writing its docs are separate decisions,
  and the second is worth re-running long after the first.
- The Workspace's no-lane state is a real banner; the lane strip (add tile
  included) renders only once a lane exists.

Refs #32, #33.

Tests: npm run test:server (1828 pass), client vitest (558 pass).
2026-09-21 17:03:27 +07:00
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00
nntrivi2001 8d48a038c7 fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus
the notification and brand work asked for alongside them.

Tokens: Claude Code writes ONE JSONL line per content block of an assistant
message, and every one of those lines repeats the same message-level `usage`.
Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran
1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId`
counts a message once, carried across the transcript cache's incremental chunk
boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser
cannot correct what is already stored, so `setTokenUsage` (exact, baselines
cleared) is added for the deliberate re-parse path and `reconcileTokens` now
covers every session with a transcript on disk rather than imported ones only.

Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on
the transcript's timestamp, so each hook re-inserted the session's whole turn
history — 587k rows, 75% of a real events table, one session holding 68,747
copies of the same turns. The row now carries the transcript timestamp, which
fixes the guard and the feed's ordering together. Same class of bug in the
session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms`
are assigned from the full parse instead of `+=`, since `extract()` returns
whole-transcript totals on every call.

Events: leftover checkout hook entries in `~/.claude/settings.json` double every
event, every subagent row and every token figure. `stripLegacyHooks` has always
removed them — but only inside `bootstrap()`, which `fastPathOk` skips on
exactly the healthy installs where nothing else forces a full pass, so they
survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path.

A one-time repair at the first start after upgrading (`user_version` 3) deletes
the duplicated `TurnDuration` rows and re-parses every transcript with exact
writes. Marker 3 and not 2: an install that ran the earlier pass had its
corrected rows re-inflated by whatever old build was still ingesting hooks.

Router: switching a lane's AI Router while a pane is live only half-applies —
Claude Code re-applies `env` to a running session but keeps the model it
resolved at startup, so the old router's model name reaches the new endpoint and
the next prompt fails with "model not found". The apply route reports
`restart_required` / `run_id`, and the picker offers a Restart that respawns the
pane from the lane id alone (replaying the old `--model` would re-create the
mismatch).

Notifications (#29): the two moments Claude is BLOCKED on the user now have
their own switches and default to on — `onAwaitingInput` (a permission prompt or
a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable
and the second hung off a preference that defaults to off, so the one thing a
user walks away from went unannounced. Prefs shape/defaults/storage move to
`client/src/lib/notifPrefs.ts`; Settings and the hook had private copies.

Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under
it, the collapsed rail shows the mark, and the favicon is vendored to match.
2026-09-18 16:52:05 +07:00
nntrivi2001 1aee080fca fix(cc-config): read the live account's quota from the CLI, and ask for every account at once
The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
2026-09-17 23:17:06 +07:00
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00
nntrivi2001 686b80bd99 fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25.

`runs` is the denominator every pass rate on the Workflows page is read
against, and any backwards move opened a new one. Stage detection bounces by
design — one feature reads `implement → tests → implement` as the session
edits, runs the suite and edits again — so a single feature was recorded as
five or six runs, and a lane in constant use reported a handful of passes
spread across dozens of phantom runs. A backwards move now opens a run only
when it is DECLARED; a backwards detection continues the run in flight.
Reaching the final node still closes the run for either, since work after
`done` is new work however it was observed. Replayed over this repo's own lane
the rule takes 26 recorded runs down to the 5 features that actually shipped
through it. The panel itself is unchanged: passes are still declarations only.

`/reload-plugins` and `/reload-skills` were typed back to back. Claude Code
opens its slash-command menu the moment `/` is typed and the Enter that follows
lands on that menu, so the second command was appended to the line still in the
composer and the pane submitted `/reload-plugins/reload-skills` — which Claude
Code runs as `/reload-plugins` with an argument, never reloading skills, and
leaving a stray prompt turn in the scrollback. Reproduced and fixed against a
live pane: SETTLE_MS (1s) between the two sends, which makes
`reloadOpenSessions` async rather than block the event loop, and the same pause
in the detached update-all worker.
2026-09-17 16:16:31 +07:00
nntrivi2001 5bd1c0323f feat(lanes): reorder and rename lane cards, and show each file's line delta
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, 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.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
2026-09-17 15:07:37 +07:00
nntrivi2001 00a2aedb09 feat(ai-router): test an endpoint before saving it, and re-point a live lane
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
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 (`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.

Network failures report `error.cause`, not `error.message`: 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.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
2026-09-16 11:26:00 +07:00
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

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

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

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

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

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

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

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

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00
nntrivi2001 27313d4413 fix(run): refuse a run with ETMUXMISSING when the host has no tmux
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.

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

Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
2026-09-11 15:32:24 +07:00
nntrivi2001 8b8582f231 fix(runs): persist tmux-backed runs so a resumed session shows its own info
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by
design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever
persisted — the newest row in a live database dated from before pty-run.js
replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview,
started_at and session_id off that row and the handle itself is recomputed
from tmux, so the runs modal rendered a live row with no cwd, no model, no
prompt and a start date of Jan 1 1970. The same missing row is why a resumed
session kept its separate `external` badge: the client dedupes an external
session against the session ids carried by run rows, and no run row ever
carried one.

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

Also closes the other half of the same lie: adopting a pane that is already
running an agent now throws ERUNLIVE (409 from both the lane start action and
POST /api/run) when the request carried a prompt or a resumeSessionId. That
argv cannot be typed over a live agent, so answering 200 reported a resume
that never spawned. A start asking for nothing but a pane still adopts.
2026-09-10 17:07:23 +07:00
nntrivi2001 aca35dd4cd feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect.

Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement.

readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race.

Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: 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 - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command.

Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action.

Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted.

Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
2026-09-10 16:05:07 +07:00
nntrivi2001 876c6de6d2 fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
2026-09-09 15:32:09 +07:00
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.

Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:

  - the directory must be absolute and already exist — nothing is
    created for you;
  - the filename comes from the route, never from the body;
  - an existing file, symlink included, is a 409 — this can only
    create, never overwrite;
  - it sits behind the loopback-Origin guard /api/run uses, so a page
    in the user's browser cannot plant agent instructions in a project
    directory. That guard moves to lib/same-origin.js to be shared.

The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.

Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
2026-09-09 01:45:00 +07:00
nntrivi2001 c34931c3f5 fix(cc-config): .claude.json sits BESIDE ~/.claude by default, not inside it
The previous commit routed the read through getClaudeHome(), which appends
/.claude when CLAUDE_HOME is unset — pointing the dashboard at
~/.claude/.claude.json, a file the CLI never writes by default. Verified
against the real binary: with CLAUDE_CONFIG_DIR unset the CLI writes
$HOME/.claude.json; with it set, .claude.json sits flat inside that dir.
Branch on CLAUDE_HOME accordingly.

The test now drives a fake claude that writes where the real one does and
asserts getClaudeJsonPath() names that exact file, under both branches —
the previous test only restated the implementation.
2026-09-09 10:00:05 +07:00
nntrivi2001 6d27c115d8 fix(cc-config): MCP scope was inverted — expose user/local, not user/project
readMcpServers only ever parses ~/.claude.json (top-level = user scope,
projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself
calls `local`). The previous user/project scope choice was the exact
inverse: `--scope project` writes .mcp.json, which readMcpServers never
reads (a successful add would vanish), and every "projectScoped" row was
actually `local`, so Remove sent a scope the CLI could never match.

- cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local
  mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a
  plugin install and an MCP add can't spawn `claude` concurrently; drop the
  String(v) coercion in env/header flags (a wrong-typed value is now a 400,
  not a written "[object Object]"); assertEmbeddedValue allows empty strings
  (a legitimate empty stdio arg was previously a 400).
- cc-plugin-cli.js: export `serialize` for reuse.
- CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's
  McpCard now sends scope="local"; rows sourced from
  ~/.claude/settings.json (no CLI scope addresses them) no longer render a
  Remove button. Cleaned up i18n keys my own earlier change made dead
  (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its
  own short option labels instead of reusing full-sentence section headings.
2026-09-09 09:36:52 +07:00
nntrivi2001 fd4edaac98 feat(cc-config): add and remove MCP servers through the claude CLI
Mutation goes through `claude mcp add|remove`, never a direct edit of
~/.claude.json — the running CLI rewrites that file concurrently, so a
dashboard write would race it. Reads stay on readMcpServers.

The plan specified a --url flag; the real CLI (v2.1.266) has none —
commandOrUrl is positional, and --header must trail it or the variadic
flag swallows the url. Built against the verified shape.
2026-09-09 09:15:53 +07:00
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00
nntrivi2001 8015730adf feat(codex): create and customize Codex config from /settings
Adds the Codex config panel (skills/agents/rules/memory/config editing,
mcp/plugins/marketplaces add-remove via the codex CLI) as the last piece
of parity with the Claude Code settings tab, plus the codex_config_changed
WebSocket type the client was missing, and docs for the new
/api/codex-config surface.
2026-09-08 21:09:28 +07:00
nntrivi2001 11dcc04ac4 fix(cc-config): one account is one profile, and rename it in place
Four defects, all from one design mistake: a profile's display name and its
filename were the same string.

- Saving the logged-in account made a SECOND profile of an account another
  profile already held. Both then reported themselves active, so there was
  nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and
  names the profile that already holds the account. Re-saving into the
  account's own profile stays allowed - that is the token-refresh path.
- Worse, an accidental duplicate could not be cleaned up: two profiles of one
  account are both active, and the delete guard refused any active profile, so
  neither was ever deletable. The guard now blocks only the LAST saved copy of
  the live account, which is the actual credential-loss condition it exists
  for. The trash button had to follow, or the server would allow what the UI
  hid.
- A name could not contain spaces or diacritics, because it had to be
  filename-safe. A profile now carries `name` (internal identifier, still the
  filename, still what every route takes) and `label` (what the user reads and
  edits: 1-60 free-form characters, control characters rejected, stored
  trimmed, unique case-insensitively). A label never touches a path, which is
  what makes "Tài khoản chính" a legal name.
- Naming happened up front in a "save current account as" field. That field is
  gone. The server derives the identifier, the save button lives on the live
  account's card and appears only while it is unsaved, and the label is edited
  by clicking it (Enter saves, Escape cancels).

Renaming rewrites the label in place and deliberately 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`, so nothing needs migrating.

Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`.

Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised
end to end against a COPY of the real profile store on this machine: the
duplicate deleted, the last copy still refused, a Vietnamese label accepted,
and the stored access/refresh tokens and 0600 mode intact after a rename.
2026-09-07 16:29:11 +07:00
nntrivi2001 4bc77ded17 feat(cc-config): switch between Claude Code accounts, keeping the workspace
Adds a "Switch profile" button left of Backups on /settings?tool=claude-code.
A *profile* is one Anthropic account - one quota - saved under a name, so the
local `claude` CLI can be pointed at a different account without disturbing
anything else.

A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth`
inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside
the `.claude.json` config file(s). Those files are key-merged in place, and only
where they already exist, 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: those are
the machine's MCP server tokens, not account identity.

Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir
0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route
ever returns token material - `GET /profiles` answers identity metadata only.

The dashboard cannot log an account in; only `claude auth login` mints tokens.
Adding a profile is therefore always "log in from a terminal, then snapshot what
the CLI wrote". `activateProfile` re-snapshots the outgoing account first so
tokens Claude Code refreshed since its last save 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. A switch applies to new sessions only,
since a running session rewrites the config on exit.

Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate.

Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700
permissions), client 459/459.
2026-09-07 15:13:38 +07:00
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00
nntrivi2001 677cdf007b feat(cc-config): list a plugin's skills, agents and commands by name
A plugin card showed "7 skills, 5 agents" and nothing more — the counts
were the whole story, so finding out WHICH skills a plugin ships meant
opening its install directory by hand. The contribution chips are now
buttons: clicking one expands a list naming each artifact with its
description, one surface open at a time. Commands render with their
slash. Hooks stay a plain chip — a plugin's hooks are matcher/command
pairs declared inline in plugin.json, not named artifacts.

`contributes.items` carries the four surfaces server-side; the existing
counts keep their type and are now derived from those lists, so a chip
reading "3 commands" can never sit beside a list holding two.

That also fixes a real blind spot. Each surface was read only from the
conventional install subdirectory, but plugin.json may declare its own
paths — this repo's own `ccam` lists three files under
plugins/ccam/commands/ and keeps commands/ empty, so it reported zero
commands. Both sources are now read and de-duplicated. A declared path
resolving outside the plugin's install directory is dropped: a manifest
is third-party data and must not turn the dashboard into an arbitrary
file reader.

Also pins the Refresh button's width: both labels share one grid cell so
the button is always as wide as "Refreshing…" / "Đang làm mới…" and no
longer resizes the toolbar when the state flips. Measuring the longest
real string beats a min-width that would be wrong in the next language.

Verified: npm run test:server (1370 tests, +6 for the new discovery),
npm run test:client (457, +3 for the expand behaviour), tsc --noEmit,
prettier and check-headers clean. Docs synced: docs/API.md, openapi.yaml
(new CcConfigPluginItem schema), ARCHITECTURE.md, docs/PLUGINS.md.
2026-09-04 11:21:12 +07:00
nntrivi2001 ce5eadba89 fix(run): keep the tmux pane exactly as wide as xterm
Scrolling a lane console repainted history with stray characters glued to
the left edge of blank lines. Reproduced against a real tmux attach: a pane
WIDER than xterm makes every over-long row autowrap its tail onto the next
row's left margin, and tmux — believing that row unchanged — never repaints
over it. One column of difference leaves one stray character per wrapped
line, two columns leave two; matched widths render clean.

TerminalView only reported its size on WS open and on the container's
ResizeObserver, so nothing corrected:
- cols moving with no container resize (webfont load, page zoom, scrollbar)
- tmux's `window-size latest` letting a second attach client (another tab,
  `ccam lanes shell`) resize the window out from under this one

Size is now reported from xterm's own onResize, re-asserted when the
terminal regains focus, and never reported as zero — the console collapses
with CSS, and fitting a 0x0 container would resize the real pane to nothing.

client 447/447, tsc clean.
2026-08-28 10:07:12 +07:00
nntrivi2001 b31da64d4f fix(docs): correct lane start action API and sandbox/approval enum values 2026-08-27 10:56:40 +07:00
nntrivi2001 24de68cfe1 feat(lanes): badge codex lanes and document the lane tool 2026-08-27 10:55:27 +07:00
nntrivi2001 20d85d3450 feat(workflows): pick a lane in the pipeline pass-rate panel
The panel aggregated every lane at once, which answers "does this stage get
proved across the project" but never "does THIS lane prove it". A lane picker
now narrows the same aggregation through ?lane=<id> on
GET /api/lanes/pipeline-stats, counting that lane plus its own archived
features — dropping the features would hide most of a lane's history the moment
it starts its next one.

The response carries a `lanes` array that the filter never narrows: it
populates the picker, so it has to keep offering every other lane even while
the counts show one. For the same reason the picker renders above the empty
branch rather than inside the populated one — narrowing to a lane that has
recorded nothing would otherwise be a one-way trip, with no control left to
select anything else. A lane filter that is not a positive integer is rejected
with 400 EBADLANE rather than silently aggregating everything.
2026-08-26 11:19:39 +07:00
nntrivi2001 348c645ef3 feat(workflows): pipeline pass rates and skill/plugin usage
Restores the five analytics sections that turned out to be worth keeping
(patterns, model delegation, error propagation, session complexity, compaction
impact) and reworks what the page opens on.

The lane panel no longer mirrors the Workspace's live lane strip. It now
answers the question the strip cannot: how often each pipeline node actually
passes. GET /api/lanes/pipeline-stats aggregates every stage record of every
lane AND every archived feature — a lane's own `stages` only holds its current
feature, so without the archive the history resets each time a lane starts new
work. Evidence-backed passes are counted apart from bare claims, the same line
the pipeline map draws between `done` and `passed-no-evidence`.

Beside it, GET /api/workflows/skill-usage reports which skills and plugins the
sessions actually reach for. Only PreToolUse rows are counted: every call also
emits a PostToolUse, and counting both would double every number. A plugin's
total includes calls to the MCP tools it ships, so one used purely through MCP
does not read as unused.

Still dropped from issue #6: the agent collaboration network and the subagent
effectiveness scorecards.
2026-08-26 10:32:13 +07:00
nntrivi2001 341197aac0 feat(settings): host Claude config as a tab in Settings
Settings becomes a tabbed shell (SettingsHub) with one tab per AI tool:
Dashboard (existing settings), Claude Code (CcConfig), plus Codex and
Cursor placeholders. Active tab lives in the ?tool= query param, and the
old /cc-config route redirects to /settings?tool=claude-code. The
standalone sidebar entry is dropped.

Adding a tool is one entry in TOOL_TABS plus its i18n label.
2026-08-26 09:01:00 +07:00
nntrivi2001 008ebf1c5c feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard
The Claude Config page was read-only for these four surfaces. It can now:

- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
  absolute path, and install / enable / disable / update / uninstall plugins,
  all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
  layers.

Server:

- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
  argv array — never a shell string. Every argument is validated before the
  spawn (shape allowlist per kind, no control characters, no leading `-`, no
  `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
  closed so a prompt errors instead of hanging, and mutating calls are
  serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
  layer. It re-reads the file unredacted, so keys the UI never displayed —
  including secret-valued ones — survive byte-for-byte. A payload carrying the
  `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
  `prototype` / `constructor` segments; a settings file that does not parse is
  reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
  the CLI's own message, a missing binary a 503.

Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.

Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
2026-08-25 15:58:03 +07:00
nntrivi2001 8ce20e8163 feat(dashboard): merge Analytics into Dashboard, remove Kanban Board
Closes issue #5 (Dashboard | Update menu & page):
- Analytics content (heatmap, sparkline, token/cost/workflow/productivity
  charts) moves into a new "Analytics" tab on the Dashboard page, alongside
  the existing Monitor/Health tabs. The standalone Analytics page, its
  /analytics route, and its sidebar menu entry are removed.
- The Kanban Board page, its /kanban route, and its sidebar menu entry are
  removed entirely (page content deleted, not just hidden).
- Updated ARCHITECTURE.md, README.md, client/README.md, docs/API.md, and
  the affected client tests/snapshots to match.
2026-08-25 11:45:39 +07:00
nntrivi2001 b1bca07c47 feat(sagi): spawn a real claude session for Sagi's Ask handoff
Unmatched Ask questions now adopt a dedicated "Sagi" lane (POST
/api/lanes/ensure, cwd defaults to $HOME) and spawn a tmux-backed claude
run there instead of just prefilling the Run page composer.
2026-08-24 09:39:28 +07:00
nntrivi2001 ce2797b01f feat(run): make bypass permissions selectable in a lane pane's shift+tab cycle
`claude` only offers bypass in its shift+tab permission cycle when started
with `--allow-dangerously-skip-permissions`, which the pane argv never passed.
A dashboard-started run was therefore stuck cycling plan/auto/manual/
accept-edits, while the same session attached from a real terminal could
reach bypass.

The flag makes bypass SELECTABLE, not enabled — `claude --help`: "Enable
bypassing all permission checks as an option, without it being enabled by
default." The starting mode is still whatever `--permission-mode` says
(`acceptEdits` by default) and `--dangerously-skip-permissions`, which would
actually turn it on, is still never passed. Entering bypass remains an
explicit human shift+tab in the pane, or an explicit
`permissionMode: "bypassPermissions"` on POST /api/run.
2026-08-20 08:32:43 +07:00
nntrivi2001 c7e934b4b4 fix(run): stop the terminal wheel from typing arrow keys, re-attach dropped sockets
Three separate reasons the Workspace terminal looked unscrollable:

- xterm falls back, on an alt-screen buffer whose program has not enabled
  mouse tracking, to translating each wheel notch into a cursor-key press.
  Inside a `claude` pane that reads as arrow up/down, so scrolling walked the
  prompt history. A custom wheel handler returning false suppresses only that
  emulation branch; real mouse reports come from a separate listener xterm
  registers when the program does request wheel events.
- TerminalView never reconnected, so a server restart left the pane frozen on
  its last painted frame — still live-looking, but swallowing every keystroke
  and mouse report. It now re-attaches 1.5s after an unexpected close and
  stops only on the server's `exit` frame; tmux keeps the session, so the
  re-attach repaints in full.
- tmux repaints the whole pane, so xterm's own scrollback is always empty and
  its viewport scrollbar renders as a groove with no thumb that cannot move.
  Hidden, since scrolling there goes through mouse reports, not the DOM.
2026-08-19 12:02:01 +07:00
nntrivi2001 c25008ab19 fix(run): stop Start/Resume from silently no-oping on an idle lane session
spawnRun adopted any existing `ccam-lane-<id>` tmux session without looking
at it, so a Resume issued while the session sat at a bare shell prompt (left
by `ccam lanes shell`, or by a `claude` that had already exited) dropped the
whole argv: no `--resume` ran, no initial prompt was typed, and the API still
answered 200. The lane's DB `run_id` is not set in that case, so the ERUNLIVE
guard in the start route never saw it either.

Reuse the pane instead of erroring: when the session exists and
`#{pane_current_command}` is a shell, type the argv into that pane and record
the run. A pane running a program (a live `claude`, an editor, a build) is
still adopted untouched, so attaching shows what is running rather than typing
over it. `sendCommand` POSIX single-quotes every argument and uses
`send-keys -l`, the only place in tmux.js that composes a command line.
2026-08-18 10:50:28 +07:00
nntrivi2001 9c3331c843 docs: fix Task 12 Round 1 accuracy findings
- Fix POST /api/run param: change 'prompt' to 'initialPrompt' (Finding 1)
- Fix POST /api/run response shape: show actual fields from publicRun() (Finding 2)
- Delete stale 'Running and releasing lanes' section with old POST /api/lanes/:id/start (Finding 3)
- Delete stale POST /api/lanes/:id/message section — endpoint now returns 400 EUNSUPPORTED (Finding 4)

All changes verified against actual code:
- server/routes/run.js:285 uses body.initialPrompt
- server/lib/pty-run.js:144-156 publicRun() returns {id, laneId, status, cwd, model, ...}
- server/routes/lanes.js:1134 message action returns 400 error
2026-08-12 15:48:10 +07:00
nntrivi2001 31af7aefbf docs: update Run feature docs for the tmux+PTY terminal (was stream-json)
- README.md: replace stream-json bullet with tmux+PTY description
- ARCHITECTURE.md: update /run Workspace page section to reflect PTY transport, xterm.js, and ccam lanes shell
- docs/API.md: replace stream-json endpoint docs with /api/run/*+/ws-pty/:runId PTY endpoint docs
- docs/LANES.md: add "Attaching a real terminal to a lane" section for ccam lanes shell; update lane "start" action docs and /run Workspace description
- Remove stale references: run_stream, run_input_ack, conversation/headless modes, RunConsole components
2026-08-12 15:35:57 +07:00
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00
nntrivi2001 8fcef5a10b feat(lanes): let Add Lane choose the pipeline template
Creation is the only point the UI could ever set a lane's template, and it
never offered the choice — so every lane added from "+ Add lane" was born
on `default` and rendered an 8-node map for a 16-node workflow, with no
screen able to change it afterwards. That is the defect that made the
ship-feature template unreachable from the browser.

The modal now shows a *Pipeline template* select fed by
`GET /api/lanes/pipelines`, labelled with each template's node count so the
consequence of the choice is visible. A failed fetch degrades to a `default`
option rather than blocking lane creation.

`pipeline` was already accepted by `POST /api/lanes` but silently dropped by
`/ensure` and `/worktree`, which build their own createLane payloads; both
now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
2026-08-07 09:44:48 +07:00
nntrivi2001 67edda77eb feat(lanes): make the pipeline map track a skill's real progress
A lane's pipeline map only ever moved when a skill remembered to call
`ccam stage`, and the ship-feature template shipped with no detection rules
at all — so a lane driven by Superpowers skills sat at whatever stage it
last declared, and the `gates` node was never declared by anything.

Detection (`detect` rules on each node) now covers the Superpowers skill
invocations and the `ccam`/`gh` commands the ship-feature-lane skill
actually runs. It stays a safety net, not the mechanism: forward-only,
never `done`, never overriding a declaration. Two rules were deliberately
left out — `git diff` on `review` (this repo's own tests record it pinning
a lane at `review` on a real session) and anything on `merged`/`done`.

Stage vocabulary grows to 50 names over the same 16 nodes, following
Shipyard's PHASES shape: sub-states like `migration-collision`,
`e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the
map growing a node per reason. Every alias has a source — the skill
declares it, `default.json` uses it, or Shipyard's PHASES lists it.

Two silent failures fixed along the way:

- `lane.stages` is keyed by the raw declared string, so a stage declared
  under an alias lost its `--evidence` and rendered amber instead of
  green. `stageRecords` resolves each key onto its node.
- `ccam stage <typo>` stored fine and then rendered nowhere. It now warns
  on stderr while still exiting 0.

`ccam lanes pipeline` closes the gap that made all of this invisible: a
lane could only be assigned a template at creation, and no screen in the
web UI offers the choice, so every lane added from "+ Add lane" was stuck
on `default`'s 8 nodes. An unknown template id is now refused rather than
silently falling back to `default` on read.

Also merges the repo's own `ship-feature` skill into the Superpowers
workflow: it delegates planning/TDD/review/verification instead of
restating them, and declares a stage at each phase.
2026-08-07 09:34:20 +07:00
nntrivi2001 5d5ea32db6 docs(lanes): document proof gallery (C) 2026-08-04 16:41:56 +07:00
nntrivi2001 fcc7a8f2f3 fix(lanes): stop archive/activate from clobbering feature title and links
archiveActiveFeature was overwriting a feature's own title with the
LIVE lane's title on every clear/switch — a --title set on activation
silently disappeared. activateFeature restored stage/status/gate/CI/
stages/notes onto the live lane when switching back to a past feature,
but never links, so they vanished on reactivation. Both are fixed, with
a regression test for each (full activate/archive/reactivate round trip
for links).

Also fixes server/routes/lanes.js: the doc comment explaining GET
/:id/git's rationale had been left sitting above the newly-inserted
/:id/features routes instead of its own route.

docs/API.md's Lane features section described fields and behavior that
don't exist in the real routes (an "active" boolean, a POST response
containing "archivedPrevious", a "409 ESTALE" concurrency response) —
rewritten to match the actual request/response shapes exactly.
2026-08-04 15:17:14 +07:00
nntrivi2001 917f0794d5 docs(lanes): document per-feature state and archive (B) 2026-08-04 15:06:05 +07:00
nntrivi2001 465dca35e5 fix(docs): correct fabricated API/behavior claims in the locks docs
docs/API.md's Locks section described a timeoutMs request param, a
408-timeout response, and field names (acquiredAt/acquiredMs) that
don't exist anywhere in the actual routes.js — the server is single-shot
and never blocks; timing out is a CLI-only concept. Also fixes the
section being spliced into the middle of the pre-existing Sessions
heading and its content.

docs/LANES.md said the owner file stores an epoch in milliseconds
(it's seconds), that the CLI polls every ~1s (it's ~2s), that the lane
card shows a "waiting" state (no such server-side concept exists, only
who currently holds), and included a fabricated "manually transfer a
lock's holder identity" procedure that also contradicted the
never-touch-the-lock-directory etiquette rule stated right above it.
2026-08-04 14:02:40 +07:00
nntrivi2001 6c7554dbdc docs(locks): document cross-lane named locks (D) 2026-08-04 11:41:54 +07:00
nntrivi2001 4d9a385c5e feat(lanes): lane runtime UI, docs, and env additions (A1+A2)
Client-side rendering for the per-lane runtime facts (slot, ports,
database, Redis index, service liveness) added in the server-side
A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md,
client/server READMEs) describing the new profile.env keys, hook
environment contract, and REST endpoints.
2026-08-04 10:04:58 +07:00
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00