Commit Graph

58 Commits

Author SHA1 Message Date
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

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

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

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

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

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

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

Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md,
server/README.md, OpenAPI fragment + regenerated openapi.yaml.
2026-09-29 08:21:05 +07:00
nntrivi2001 2b8f83d2dd refactor(lanes): drop the workflow skills and Superpowers link; pipelines go dormant
The plugin stops shipping its workflow layer:
- skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash
  commands (plugin.json keeps agenthub-doctor/update/open), and all four
  agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer).
- CLI `agenthub skills install`, `agenthub lanes agents install`,
  `agenthub ship-full|ship-quick`; routes POST /api/skills/install and
  POST /api/lanes/:id/agents/install; the Settings button, the lane Tools
  menu item and the Add-lane setup step that called them.
- The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's
  autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row.

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

Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE,
docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
2026-09-28 17:49:05 +07:00
nntrivi2001 9d096b61f7 feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
  carries the first prompt, `--continue` resumes; `agy` has no model,
  effort or permission flag, and `--dangerously-skip-permissions` is never
  emitted. No pipeline, no agents install, and no AI Router (native only,
  else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
  probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
  and the prompt box names the lane's CLI ("Ask Codex anything…"), via
  LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
  (healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
  hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
  continues the previous thread.
2026-09-28 10:22:07 +07:00
nntrivi2001 952e8584f0 feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
2026-09-25 17:30:28 +07:00
nntrivi2001 1da46f10f4 feat(git): per-block conflict view with mine/theirs choices (#45)
Opening a conflicted file in the Changes panel now shows each conflict
block as its two sides, labelled from git's markers and tinted apart,
with Keep mine / Take theirs / Keep both per block (undoable, ^/v to
jump). Save and mark resolved writes the chosen text through the
whole-file editor's PUT /git/file (baseSha stale check) then runs
resolve done. The Merge conflicts group's buttons are text, and a
conflicted file is marked with a red ! in the Files tree.

Fix: ls-files --cached lists an unmerged path once per stage, so the
tree showed a conflicted file three times; lsFiles now dedupes.
2026-09-25 13:35:50 +07:00
nntrivi2001 99a2a8277c fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found
- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
2026-09-25 11:30:02 +07:00
nntrivi2001 04b6b98d97 fix(git,macos,runs): close the review findings on #22, #34 and #38
- validateRemoteUrl checks the literal scheme prefix git will receive and
  refuses %/backslash in the authority (WHATWG normalises https:/host and
  percent-escapes that git reads differently); route test for EBADURL.
- Parent-session scrub also drops AI_AGENT, and GIT_EDITOR when it is
  Claude Code's `true`.
- canonicalCwd resolves a not-yet-existing path through its deepest
  existing ancestor, so a symlinked parent still yields one lane.
- Note the clock-step ceiling on liveServers' boot-time filter.
- Docs/header wording: the Active runs count is lane-scoped.
2026-09-25 11:26:31 +07:00
nntrivi2001 aff79a787c feat(lanes,git): publish a repo with no remote, and read a repo with no commits
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.

What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
  `head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
  subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
  `push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
  all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
  `validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
  local paths, file://, ext::, fd::, git://, http://, leading `-`:
  EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
  commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
  no remote offers Publish… with an inline URL box; a remote but no
  upstream relabels Push as "Publish branch". AddLaneModal disables Add
  on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
  now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
  docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
2026-09-25 11:21:30 +07:00
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00
nntrivi2001 158783d5d5 feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all,
  tree) becomes a left sidebar and the Files tab is dropped; a working-copy
  file opens straight into the editor (Save/Cancel live once the text
  differs, Cancel restores it, Whole file toggles back to the diff), and the
  page is height-locked so the editor no longer collapses with no live run.
- Overview splits into Staged Changes / Changes groups in the work range.
- A changed file's whole tree row takes its status tint.
- Drag an image onto the lane terminal: POST /api/run/drop-image saves it
  (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path
  is pasted into the pane, which is how claude attaches it.
- Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40);
  claude-opus-5% had been pricing it as Opus 5.
- server/lib/pricing-sync.js: a model family used in token_usage with no
  row of its own is inserted from LiteLLM's public price list, never
  updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly
  that one third-party GitHub raw URL.
2026-09-24 17:05:15 +07:00
nntrivi2001 d397d3a1f6 feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
2026-09-24 15:49:39 +07:00
nntrivi2001 cfe832383c feat(lanes): stage/undo one hunk at a time, sticky review file headers
Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
2026-09-24 14:40:31 +07:00
nntrivi2001 0737786faf feat(lanes): full-width Changes, Stage all, one-read expand-all
- The Changes panel's width control gains a full-width step (narrow →
  50/50 → 70/30 → full → closed); the console beside it is hidden with
  CSS, never unmounted.
- "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one
  `git ls-files`, every level shaped like /git/tree) instead of one
  request and one git process per folder.
- Overview: a Stage all button and the staged-only commit box sit above
  the file list and stay pinned while it scrolls; the Files finder is
  pinned too.
2026-09-24 14:40:31 +07:00
nntrivi2001 428519335d fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header
- The run form's prompt box offers suggestions again: `/` lists the run
  cwd's project and user commands and skills plus the CLI built-ins
  (claude lanes only), `@` lists files under the cwd; Enter picks while
  the list is open and sends otherwise. The 2f39f4e rewrite had left a
  bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
  (sessions matched to the lane by longest path-boundary cwd), instead of
  every lane's.
- The Changes panel's git line and its actions are one compact row in one
  card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
  leaving an empty frame where the git row used to be.
2026-09-24 13:32:43 +07:00
nntrivi2001 faa56f5c75 feat(lanes): move the lane's git section into the Changes panel
- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
2026-09-24 11:44:31 +07:00
nntrivi2001 7bc4594b94 fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js):
- Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a
  file name is a file name, never a glob, and odd names parse correctly.
- A lane that is a subdirectory of its repo sees and acts on ONLY its own
  paths (reads, stage/unstage/discard/commit, and gitFacts.files).
- Any file named settings.local.json (the lane's router API key) is refused as
  403 ESECRET on disk (symlinks resolved) and at every committed rev.
- commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is
  staged (checked with --no-renames, so a move into the lane is caught).
- push resolves its remote (upstream → only remote → origin), else 400
  ENOREMOTE; unstaging a rename unstages both halves.
- Git writes serialize on the repo's git dir, not the lane id, so two lanes on
  one index cannot collide on index.lock.
- git refusing an action (a numeric exit status) answers 409 EGIT with git's
  stderr instead of a 500 carrying a numeric code; one shared parseNumstat.

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
2026-09-24 10:30:25 +07:00
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00
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 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00
nntrivi2001 1f5c04e765 feat(ui): warm light theme and pre-mixed status fills
- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
2026-09-23 13:23:00 +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 eab7c437df feat(skills,pipelines): make the review node review, and make a long run followable and affordable
Closes #24.

**The review node ran nothing.** Both templates declared
`/superpowers:requesting-code-review` — the skill about how to HAND OVER a
review, not one that reviews anything — so `agenthub lanes node review`
performed no review at all. They now declare the bare `/code-review`, Claude
Code's own. It carries no `plugin:` prefix, so `requiredSkillPlugins` skips it
and the doctor's required-plugin row is unchanged (verified against the live
function: still exactly the five superpowers skills the templates name).

**The rest is the brainstorm → implement → review span, made cheaper to run and
harder to lose track of.** `ship-feature` gains four sections it never had:

- **Right-size the run.** The pipeline is not free — every dispatching phase
  costs a model call and minutes, and the fix-loop pays it again each lap. A
  typo does not enter the pipeline at all; a small single-layer change skips the
  plan-debate, independent-test and gate agents and lets the user's acceptance
  be the gate; medium/large runs everything. Sizing is a claim made out loud,
  and a task that grows re-sizes upward.
- **Dispatching a judgement.** The fresh-context/FOREGROUND/never-`fork`/
  hand-only-verifiable-artifacts/one-parseable-line contract, written once
  instead of re-argued in three phases — plus the rule that was missing
  entirely: never dispatch what a command answers. An agent is for judgement,
  not for typing.
- **Resuming after a compaction.** Long runs outlive the context window; the
  lane skill had this and this one did not. Re-derive position from the declared
  stage and the diff, and treat a phase whose evidence you cannot find as one
  that did not happen.
- **A `Leave when:` line on every phase.** Objective exit criteria are what stop
  both the silent skip and the endless polish: intake leaves when the answers
  are in hand, review leaves when the latest pass over the CURRENT diff is
  clean, the gate leaves when verification really ran AND the user said yes.

And, narrower: intake asks only what the repo cannot answer (a question the code
could have answered spends the user's round and earns a worse answer);
implement works the approved plan task by task, re-checks each acceptance
criterion, heartbeats on long work, and treats a plan that turns out wrong as a
return to `plan` rather than a quiet substitution; tests runs the suite the diff
needs rather than both by reflex.
2026-09-18 10:39:01 +07:00
nntrivi2001 26b0fe53c7 feat(skills,pipelines): ask the user at the two points being wrong is cheap, and stop paying for reviews the diff does not need
The `default` 8-node pipeline walked past its own intake: the node declared
`agenthub stage intake`, a stage report with no work attached, so "intake" was
a label the run passed through without ever asking a question. It now declares
`/superpowers:brainstorming` (and detects it — that rule moves off `plan`),
and both driving skills make the brainstorm mandatory, asked with
AskUserQuestion and waited on rather than answered on the user's behalf.

Three stops replace three silent slides:

- After the plan is written and adversarially challenged, it goes to the USER
  for approval. Implementation does not start without it, and work that turns
  out to need a different approach comes back for a fresh one.
- Review picks its reviewers from `git diff --name-only`: Claude Code's own
  `code-review` always, a domain reviewer only for a layer the diff actually
  touched, and — new — only once the change is big enough to have seams. Three
  agents re-reading twenty lines buys one opinion at three times the tokens, on
  every fix-loop lap. Review is also a loop now: it ends on a clean pass over
  the current diff, not on one pass having happened.
- The gate asks the user to accept before anything is claimed done (and in the
  lane pipeline, before the PR is published — publishing is outward-facing).
  A NO-GO re-enters by SIZE: a defect goes back to implement, a wrong approach
  back to plan. Nothing carries a NO-GO forward with a note about it.

Also here, from a real stall seen on a lane: `ship-feature` had no waiting rule
at all, and a session at the gate invented one with `ScheduleWakeup` — a
/loop-only primitive that paces nothing here and is refused outright when
`auto` mode cannot reach its safety classifier. The rule the lane skill already
had now exists in both.

Cost, measured in what each agent is actually for: the suite run leaves its
agent (nothing is judged, so nothing is delegated — an agent there buys a
second model's tokens to read output the session can read for free), and
`backend-reviewer` drops from opus to sonnet like its frontend twin; both read
a scoped diff against a fixed checklist, and the senior gate above them is
still opus.

Skill bugs found in the same sweep: doc-map told agents not to recreate the
removed translations and then, three lines later, to mirror every README change
into them; the docs mapping still carried `+VN+CN` and the punctuation left by
their removal; Procedure jumped 2 → 6; Verify required `scripts/doc-coverage.sh`,
which does not exist in this repo.
2026-09-18 10:21:03 +07:00
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +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 27923db4e0 refactor(lanes): compact the lane strip tile and the lane-detail header
The Workspace lane panel spent four rows saying what fits in two.

Strip tile: the stage badges move onto the id row and the progress bar
and percent go away — the detail panel's pipeline map right below shows
the same progress with the stage names attached.

Lane detail: the pipeline picker, stage badges, feature picker, router
select and the remove button merge into the single header row instead of
a second row under it, and the pipeline's name is no longer interpolated
into `laneHeader` — the picker beside it already says it. The git block
collapses from four lines to one and drops the cwd, which the header
above it already spells out.

The router select is relabelled Model, moves out of LaneCard into that
header row (`LaneRouterRow` is now exported), and reports its apply
outcome as a ✓/⚠ marker with the message in a tooltip rather than a
wrapped sentence that pushes every row below it down.

Drops the tracker / dev QC / CI wait badge row and everything feeding it
(`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*`
strings): two of the three toggles have no consumer at all, and a badge
reporting a toggle nothing acts on costs a row of height to say nothing.
`agenthub lanes integration` and the API route are untouched.
2026-09-17 09:41:03 +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 29a5da3e4e fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`claude plugin update` unpacks the new version into a new cache directory and
says "Restart to apply changes". A Claude Code process that was already running
does not: it keeps loading the OLD directory, hooks and all.

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

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

Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy
of the repo — node_modules and all — per release; twelve of them measured 1.7 GB
on one machine. It runs after writeState, so a bad prune cannot cost the record
of a good bootstrap, and it protects four independent readings of "in use":
this build, every installPath Claude Code records, the directory
agenthub.service names (`unitRoot`, foreign unit included), and any directory
still named in the user's settings.json hooks — that last one is the un-restarted
process above, seen from the other side. Plus the 2 newest survivors as a
rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
2026-09-15 10:30:00 +07:00
nntrivi2001 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 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

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

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

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

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

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

installUserService keeps its old semantics for `agenthub service install`,
which runs from a user's shell and is not in the cgroup.
2026-09-11 16:30:10 +07:00
nntrivi2001 30bc6debde fix(updates): check releases for a plugin install with no .git
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.

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

UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
2026-09-10 16:46:06 +07:00
nntrivi2001 cc85e35439 feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look".

Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless.

POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process.

The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard.

Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist.

Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
2026-09-10 16:03:07 +07:00
nntrivi2001 c7dbaf2d9f refactor: finish the AgentHub rename and drop the MCP server
Two leftovers from the rebrand, done together because they touch the same
docs and manifests.

**Data directory.** `agent-dashboard` could not simply be renamed in the
source: it names a real directory holding the live SQLite database. Startup
now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js`
— a same-filesystem rename, so it is instant even at multiple gigabytes and
cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to
the legacy path until the move happens; the first version of this put the
rename inside the getter, which meant `npm run test:server` relocated the
developer's real database as a side effect. The call sits behind
`require.main === module` and ahead of the router requires, because those pull
in db.js, which opens SQLite at module load. A failed rename is silent and the
old directory keeps being used: an unfashionable directory name beats a blank
database beside an unreachable one. Same treatment for the port-discovery file
(`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write).

Not renamed on purpose: the Sagi localStorage keys (renaming silently resets
everyone's prefs) and the PWA manifest `id` (changing it orphans an installed
app).

**MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556
lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard
that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills
and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`,
`docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section
describing it.

Kept, because they are unrelated things sharing the name: the Config
Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config
plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that
analytics must keep attributing.

Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both
directions of the migration proven — a sandbox-HOME test run leaves the legacy
directory untouched, and a real boot moves it with its contents intact.
2026-09-08 09:48:40 +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 3223c8edac feat(lanes): run ONE pipeline node, from the terminal or a slash command
A pipeline was all-or-nothing: `ccam ship-full` / `ship-quick` hand the whole
thing to a skill, and a node's own `command` — added so the map would stop
hiding the driver behind its detect regexes — was display-only. Re-running just
the CI gate, or redoing the brainstorm, meant clicking copy on the node and
pasting the string into a session by hand.

`ccam lanes node [<id>] [extra…] [--print]` runs it, in the lane's own cwd. A
slash-command node spawns `claude` with it as the prompt (same shape as
`ccam ship-full`); anything else is spawned directly. No id lists every node
and its command, which is also what the two error paths print.

The node is matched by id OR alias, the same way `ccam stage` matches one:
`default` calls the step `implement` and `ship-feature` calls it `implementing`,
and one command name must not depend on which template the lane happens to be
on. That needs the template's `aliases`, which the lane payload's
`pipeline_nodes` does not carry — so the two are merged, template for the
definition, lane for the `current` marker.

Both shipped templates now fill EVERY node's `command`. The seven that were
empty are the nodes whose only work IS the declaration — a terminal `done`, a
gate another agent declares — so they get `ccam stage <id>`, which keeps all 16
(and all 8) runnable instead of leaving holes a caller has to know about.

Slash commands for the same thing, because a session should not shell out to a
nested `claude`: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`,
`/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`,
`/reported`, `/watching-pr`, `/merged`, `/done`, plus `/ship-full` and
`/ship-quick`. Each resolves its node through `ccam lanes node <id> --print` and
runs the result IN the current session — invoking the slash command itself when
that is what the node names, otherwise running the shell argv. Resolving at call
time rather than baking the string into the file is what stops the 18 command
files from drifting when a template changes.

Two limits are deliberate:
  - The command is split on whitespace and spawned WITHOUT a shell. A template
    is data — anyone can drop a file into DASHBOARD_PIPELINES_DIR — so its
    `command` must never be interpreted as shell. A node needing a pipe names a
    script that contains one.
  - Running a node does not declare its stage. A command executing is not proof
    the node passed, same boundary as the console never writing a lane's stage.

Verified: npm run test:server (1403, +3 covering alias matching, full command
coverage of both templates, and every command file being listed in the
manifest), npm run test:client (459), prettier and check-headers clean. Docs:
docs/LANES.md § Running ONE node, docs/PLUGINS.md commands table, README +
README.vi CLI blocks, CLAUDE.md marker.
2026-09-04 15:14:53 +07:00
nntrivi2001 6cb7f4f843 feat(plugin): install the pipelines' skill plugins on SessionStart
The ship-feature pipelines drive `/superpowers:*` commands as their per-node
drivers, so a fresh install had a dependency it was only told about — the
`/ccam-doctor` WARN row and a line in the README.

`server/lib/skill-plugins.js` now carries the whole thing, shared by the
doctor (reports) and the bootstrap (installs) so the two cannot disagree
about what is missing. The bootstrap installs at the very end of its DETACHED
worker — the install clones from the network and nothing in the 10 s
SessionStart hook may wait on it — retrying once behind a `marketplace add`,
logging a failure with the manual command, and never throwing: the dashboard,
hooks, CLI and MCP all work without these plugins. The outcome is recorded as
`state.skillPluginsSettled`, which `fastPathOk()` now also requires, so the
check runs exactly once per install and `/ccam-update` is the retry.
`CCAM_AUTO_INSTALL_PLUGINS=0|false|off` opts out.

Reporting is derived, installing is allowlisted. Requirements come from the
loaded templates, which is right for a report; installing from that same
source would not be, because a template is a file anyone can drop into
`DASHBOARD_PIPELINES_DIR` — it would turn "edit a JSON file" into "run this
vendor's install command on the next session". Auto-install consults
`AUTO_INSTALLABLE`, a constant in code (`superpowers` today), and reports
every other requirement as skipped.

Tests inject a `run` seam so no case reaches a real `claude plugin install`,
and cover the refusal explicitly.
2026-09-04 14:03:12 +07:00
nntrivi2001 965c62c5cf fix(workspace): stop the console remounting, and the idle polling re-renders
The Workspace page rendered `ConsoleArea` from two sites, picked by
`currentLane`. Every flip of that value unmounted the whole console
subtree and mounted it again at the other site: the terminal died, the
run history went with it, and `LaneConsolePane`'s lane-switch effect
wiped a half-typed prompt. It flipped on every page load (lanes arrive,
`refreshLanes` selects `lanes[0]`), on a lane being removed, and on any
refetch that re-picked the selection.

`lane-detail` is now always rendered and holds the only `ConsoleArea`;
the header and info block are the conditional parts. Same rule the
RunConsole already followed one level down — collapse with CSS, never by
unmounting — now enforced above it too.

Second half: every poller fed `setState` a brand-new object graph each
tick, re-rendering the lane strip, the card and the console area several
times a minute on a completely idle dashboard. `keepIfSame` keeps the
previous reference when the payload is value-identical, applied to the
5 s run/session poll, the lane list and counts, the `lane_update` WS
branch, and LaneCard's git/runtime/locks pollers.

Verified: npm run test:client (44 files / 454 tests), tsc --noEmit
clean, check-headers.sh clean. The Run screen snapshot was regenerated —
the diff is the added `<section>` wrapper and one indent level, no
content change.
2026-09-04 10:48:22 +07:00
nntrivi2001 e109d2804c feat(lanes): auto-advance a codex lane's stage from its rollout detection
A codex session emits no hooks and rarely calls `ccam stage` on its own, so
its card sat at `idle` forever while the amber "detected" badge was the only
thing that ever moved. `codex-ingest.js` now advances the declared `stage`
from its own written detection — the one place in CCAM where an inference
moves the blue `current` ring, and only for `codex` lanes.

It stays honest: gated on `recordDetection` having written (so forward-only,
declared-wins and the detection TTL are inherited unchanged), passes NO
evidence (the node renders `passed-no-evidence` amber, never `done`), and no
template's last node carries detect rules, so a lane can never be
auto-declared `done`. `setStage` grows a `keepDetection` option — its sole
caller — so the detection columns survive the advance and the card keeps
showing which command moved it instead of looking like an agent's own claim.

Two levers then get the evidence auto-advance cannot supply:

- `codex-nudge.js` now nudges on a stage held 2+ min with no evidence,
  instead of on a detection sitting ahead of the declared stage — after
  auto-advance the old condition can never fire, since the two now match.
- `ccam lanes agents install` on a codex lane writes the marker-delimited
  `<!-- ccam:stage:start -->` block into the lane's own AGENTS.md — the one
  instruction channel Codex reads — instead of copying agent templates into
  `.claude/agents/`, which it never loads. Appended, user prose preserved,
  reinstall replaces exactly that block.

Verified with a real `codex exec` run: stage moved to `watching-pr` with
`evidence: null` and the detection signal intact. 1364 server tests pass.
2026-09-03 16:51:02 +07:00
nntrivi2001 8a812a174b fix(lanes): keep stage detection honest across rollbacks and feature switches
Three defects let a lane's pipeline map claim progress that was not this
pass's work, all of them on the "going back" path a new request takes:

- `setStage` cleared the detection columns on a real transition but never
  pruned the `stages` records AHEAD of the new stage. A lane that ran through
  `done` and was then declared back at `plan` rendered `plan` as `current`
  while `tests`/`review`/`done` still read `done`, carrying the previous
  pass's evidence, and contradicting `progress` (computed from the declared
  index alone). A backwards declaration now drops every record it can prove
  is ahead; a key matching no node is unplaceable and left alone.
- `activateFeature` rewrote the live lane row for the incoming feature but
  carried the outgoing one's `detected_*` across. A never-seen slug starts at
  `stage='idle'`/`stages={}`, exactly the shape `inferredDisplayStage` renders
  from detection, so the new feature opened painted with the old one's
  progress — and forward-only rejected every real detection for it until the
  carried-over one aged past DETECTION_TTL_MS. Now cleared, like every other
  real transition; deliberately not archived, since detection is live-only.
- `codex-ingest` classified a rollout journal by raw `now - mtime`, which goes
  negative for a future mtime (clock stepped back, NTP correction, a restore
  that preserved timestamps) and read as "written milliseconds ago". A
  finished thread's journal could replay as live work; a file claiming to be
  from the future is now idle.

Also fixes `check-headers.sh`, which audited via `find` and so reported local
gitignored runtime state (a lane's own `.ccam/profile/hooks/*.sh`) that is not
this repo's source and can never be made compliant here. It now lists files
through git (tracked + untracked-but-not-ignored), so a file an agent just
wrote is still caught before it is staged while ignored state is not.

Tests: 1359 pass (4 new). Header audit exits 0.
2026-09-03 15:29:08 +07:00
nntrivi2001 fd7f57929c fix(lanes): auto-detect and report stage for codex lanes (issue 11)
A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:

1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
   `stage-detect.detect()`, and correctly pinned to `claude` — could never
   see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
   rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
   tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
   already understands, and feeds the existing detect path plus an
   idle/running status mirror under the same guard hooks use. Polled every
   `DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
   at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
   call could drive the sibling lane's card. It now resolves by
   `--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
   errors naming both candidates rather than picking one. Every pane the
   dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
   assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
   pipeline's last node rejected every detection for the rest of its life.
   A declaration on the LAST node no longer vetoes — the pipeline is over
   there, so a later detection is new work. Nothing else is relaxed, and
   detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
   codex panes, which Codex reads as prose — so the skill that would have
   called `ccam stage` never ran. Claude lanes only now.

A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.

Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
2026-08-27 16:38:23 +07:00
nntrivi2001 ccf2fdb8eb feat(lanes): let claude and codex lanes coexist at the same cwd
Repo/Adopt mode (POST /api/lanes/ensure) treated lanes.cwd as globally
unique, so adopting a path already driven by one CLI silently ignored a
different tool choice. Uniqueness moves to the composite (cwd, tool):
one directory can now back a claude lane and a codex lane side by side.

- server/db.js: drop the column-level UNIQUE on cwd, migrate existing
  DBs via table rebuild, add idx_lanes_cwd_tool.
- resolveLaneByCwd gains an optional tool filter; hook ingestion always
  pins to "claude" since Codex has no hook mechanism here, so a hook
  event never touches a codex lane sharing the path.
- POST /ensure resolves the existing owner by (cwd, tool) instead of
  cwd alone, so a different tool creates a sibling lane.
- LaneConsolePane's cwd-only lane lookups now also match tool, so a
  shared cwd doesn't attach a run to the wrong sibling lane.

Worktree mode is unaffected (already allocates a unique directory per
lane). docs/LANES.md and CLAUDE.md updated to describe the new
composite uniqueness.
2026-08-27 13:06:08 +07:00