diff --git a/CLAUDE.md b/CLAUDE.md index 2b4909e6..f6c03131 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -25,7 +25,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `agenthub stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion. - **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate ` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it. - **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life. -- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. +- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. - **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not. - **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them. - **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above. @@ -38,6 +38,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/.config.toml` — the layer `codex -p ` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Re-pointing a lane is allowed and is not a PATCH:** the lane card's AI Router select calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`. - **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`. +- **A lane's card position is a viewer preference; its name is a lane fact.** The Workspace strip's drag/Alt-arrow order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input. - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane. diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 59d4da07..930e702e 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -19,17 +19,18 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { keepIfSame } from "../../lib/keepIfSame"; import type { AiRouter } from "../../lib/api"; -import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types"; +import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types"; /** How often a mounted card re-reads its working-copy facts. Slow on purpose: - * each call is three git subprocesses server-side, and a branch name does not + * each call is five git subprocesses server-side, and a branch name does not * change on the timescale the lane list is polled at. */ const GIT_REFRESH_MS = 30_000; @@ -379,6 +380,141 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) { ); } +/** Porcelain status code -> the letter shown and the colour it is shown in. + * Keyed on the code's first meaningful character, so an index-side change + * (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */ +const GIT_STATUS_STYLE: Record = { + M: "text-status-warning", + A: "text-status-success", + D: "text-status-danger", + R: "text-accent", + C: "text-accent", + U: "text-status-danger", + "?": "text-fg-muted", +}; + +/** + * The file list behind a lane's uncommitted counts: which files, what happened + * to each, and how many lines it gained and lost. + * + * It replaced a native `title` tooltip, which could show the same paths but + * neither colour them nor align the deltas — and a plain porcelain dump is not + * something you read at a glance. + * + * PORTALLED to `document.body` and `position: fixed`, for the same reason this + * file's own header says the tools dropdown was moved out of the card: a lane + * card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and + * an absolutely positioned panel would be clipped by it for every card that is + * not at the very top. It flips above its anchor rather than off the bottom of + * the window, and clamps to the right edge. + */ +function LaneGitFilesPanel({ + files, + hidden, + anchor, + onPointerEnter, + onPointerLeave, +}: { + files: LaneGitFile[]; + hidden: number; + /** The counts button this panel hangs off. */ + anchor: HTMLElement | null; + onPointerEnter: () => void; + onPointerLeave: () => void; +}) { + const { t } = useTranslation(["lanes"]); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + // After the first paint, when the panel's real height is known: place it + // under the anchor, or above it when that would run off the bottom. + useLayoutEffect(() => { + if (!anchor || !panelRef.current) return; + const a = anchor.getBoundingClientRect(); + const p = panelRef.current.getBoundingClientRect(); + const gap = 4; + const top = + a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap; + setPos({ + top: Math.max(8, top), + left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)), + }); + }, [anchor, files]); + // Totals are over the LISTED files only — the cap means the rest are unknown, + // and a total that silently omits them would be a wrong number rather than a + // partial one. `hidden` says how many are missing. + const added = files.reduce((n, f) => n + (f.added ?? 0), 0); + const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0); + + return createPortal( + , + document.body + ); +} + export default function LaneCard({ lane, onAction, @@ -399,6 +535,21 @@ export default function LaneCard({ const [menuOpen, setMenuOpen] = useState(false); const [runtimeBump, setRuntimeBump] = useState(0); const [bootLine, setBootLine] = useState(null); + const [filesOpen, setFilesOpen] = useState(false); + const [countsAnchor, setCountsAnchor] = useState(null); + // Closing is DELAYED: the panel is portalled away from the trigger, so the + // pointer leaves the trigger before it can land on the panel it is heading + // for. Any enter — trigger or panel — cancels the pending close. + const filesCloseTimer = useRef(undefined); + const openFiles = useCallback(() => { + window.clearTimeout(filesCloseTimer.current); + setFilesOpen(true); + }, []); + const closeFiles = useCallback(() => { + window.clearTimeout(filesCloseTimer.current); + filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150); + }, []); + useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []); const git = useLaneGitFacts(lane.id); const runtime = useLaneRuntime(lane.id, runtimeBump); const locks = useLaneLocks(lane.slot); @@ -530,31 +681,42 @@ export default function LaneCard({ sentence rather than a four-row block. The cwd is NOT here — the lane-detail header above this card already spells it out. */} {git?.available && ( -
- ⑂ {git.branch} - {git.head} - {git.subject} - {(git.dirty > 0 || git.untracked > 0) && ( - git.files.length - ? `\n… +${git.dirty + git.untracked - git.files.length}` - : "") - : undefined - } - > - {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} - +
+
+ ⑂ {git.branch} + {git.head} + {git.subject} + {(git.dirty > 0 || git.untracked > 0) && ( + + )} +
+ {filesOpen && git.files?.length > 0 && ( +
)} diff --git a/client/src/components/lanes/LaneStripCard.tsx b/client/src/components/lanes/LaneStripCard.tsx index 529a2ddd..063e3c81 100644 --- a/client/src/components/lanes/LaneStripCard.tsx +++ b/client/src/components/lanes/LaneStripCard.tsx @@ -7,9 +7,15 @@ * absent: the detail panel's pipeline map below shows the same thing with the * stage names attached. Keeping the tile small and fixed-width is what lets * many lanes stay scannable in one scrolling row. + * + * The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no + * dependency. Ceiling: desktop pointers only — there is no touch drag and no + * auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as + * the keyboard (and touch-free) path. * @author Nguyễn Ngọc Trí Vĩ */ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { Lane } from "../../lib/types"; @@ -45,33 +51,69 @@ function detectionLeads(lane: Lane): boolean { export default function LaneStripCard({ lane, + index, selected, onSelect, + onReorder, }: { lane: Lane; + /** Position in the lane array — the unit both reorder paths speak in. */ + index: number; selected: boolean; onSelect: () => void; + onReorder: (from: number, to: number) => void; }) { const { t } = useTranslation(["lanes"]); + const [dropTarget, setDropTarget] = useState(false); return (