diff --git a/.env.example b/.env.example index d0486a68..729a6b51 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,11 @@ # Override this if your Claude Code data is in a different location, e.g.: # CLAUDE_HOME=~/.codefuse/engine/cc # CLAUDE_HOME=~/.claude +# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR) +# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins +# over it, and the real environment wins over both; a key set here or in the real +# environment is reported as env-owned in Settings (a value saved there would be +# overridden again at the next start). # ── Server / network ──────────────────────────────────────────────────────── # Port to listen on (default: 4820) @@ -66,6 +71,27 @@ # Set to 0 to disable the poll while leaving the watcher running (default: 30000). # DASHBOARD_SESSION_SYNC_MS=30000 +# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's +# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000). +# DASHBOARD_ANTIGRAVITY_POLL_MS=15000 +# Where agy keeps its data, when not ~/.gemini (agy itself has no override). +# AGENTHUB_GEMINI_DIR=~/.gemini + +# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**): +# drives both the codex lane running/idle mirror and Codex sessions + tokens +# (0 disables both; default: 15000). +# DASHBOARD_CODEX_POLL_MS=15000 + +# Automatic pricing: a model seen in token usage with no rate row of its own +# gets one from LiteLLM's public price list. 0 / false / off disables it +# (default: enabled). +# AGENTHUB_PRICING_SYNC=0 + +# Where settings saved from the dashboard persist (default: +# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env). +# Here or in the real environment; the file is located after this one loads. +# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env + # ── Remote Data Sources (SSH multi-machine collection) ─────────────────────── # The dashboard can pull Claude Code history from other machines over SSH: # it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 19d5109d..6b905519 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -316,7 +316,7 @@ graph TD | `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) | | `server/websocket.js` | WebSocket server on `/ws` path, 30s heartbeat with ping/pong dead connection detection, typed broadcast function. Upgrades run through the same Host-header allowlist and optional `DASHBOARD_TOKEN` check as the HTTP surface (`isWebSocketAuthorized`) | | `server/lib/security.js` | Network-hardening module (fix for GHSA-gr74-4xfh-6jw9). `resolveHost()` picks the bind address — `127.0.0.1` by default, widened only by `DASHBOARD_HOST` (logs a warning for non-loopback binds). `hostGuard` rejects requests whose `Host` header isn't in the loopback set or `DASHBOARD_ALLOWED_HOSTS` (DNS-rebinding defense). `corsOptions()` allows only loopback origins while letting No-Origin (curl/CLI) requests through. `tokenGuard` + `isWebSocketAuthorized` enforce the optional `DASHBOARD_TOKEN` on `/api/*` and WebSocket upgrades (accepted as `Authorization: Bearer`, `x-dashboard-token`, or `?token=`; off by default). Exempt paths and token-matching helpers (`tokensMatch`, `extractToken`) live here too | -| `routes/hooks.js` | Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic `Compaction` events. Manages the agent state machine plus the `awaiting_input_since` overlay (stamped on SessionStart for fresh CLIs — `startup`/`resume`/`clear` only, since a `compact`-source SessionStart fires mid-turn while Claude is working and is deliberately skipped so a working session stays Active — on non-error Stop, and on permission Notifications (which now also set agent status to `waiting`); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the `index.js` row — since `Esc` fires no hook). After `res.json()` returns on `SubagentStop`, fires a fire-and-forget `scanAndImportSubagents` (from `scripts/import-history.js`) that parses every `subagents/agent-*.jsonl`, pairs `tool_use` ↔ `tool_result` blocks by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under each subagent's own `agent_id` — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the `import-history.js` row); it returns `{ created, reparented }`, and the follow-up `new_event` refetch nudge fires when **either** is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to **its own model** (resolved from the subagent transcript) and stamps `metadata.model` on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses `DASHBOARD_STALE_MINUTES` (default 180). Uses a shared `TranscriptCache` instance (`server/lib/transcript-cache.js`) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via `isCompactSummary` in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (`baseline_*` columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. **SessionEnd preserves error state** — but only when the error is still unrecovered at the transcript tail (`isErrorAtTail`: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as `completed` instead of freezing in a stale `error`. **Error recovery**: `UserPromptSubmit` and `PreToolUse` recover a session from `error`; additionally the 15 s watchdog now scans `error` sessions and self-heals one back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in `error` forever. **Session naming**: on every event, syncs `sessions.name` from the transcript title surfaced by `TranscriptCache` and broadcasts `session_updated` — an explicit `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) always wins, an `ai-title` (auto / plan-accept) only fills a placeholder/auto name (`Session ` or a cwd-folder import name) so a user-chosen name is never clobbered. When neither title exists, the session's **first user prompt** (surfaced by `TranscriptCache` as `firstUserMessage`; tool-result / meta / slash-command plumbing entries skipped) fills the placeholder session name plus the main agent's placeholder name and empty task (issue #201) — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through (the shared `updateAgent` statement writes that column verbatim) so it is never wiped mid-turn. The guarded `updateSessionName` no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename. **Dead-session liveness reap**: the same watchdog completes any `active` session whose `cwd` has no running `claude` CLI process (probe via `lib/session-liveness.js`) — recovering a `SessionEnd` lost while the dashboard was down (e.g. Ctrl+C) that previously left the session in Waiting until the 3 h stale sweep; watchdog ticks are gated on the transcript mtime (fallback `updated_at` when no transcript exists) being older than `DASHBOARD_LIVENESS_IDLE_SECONDS` (default 60 s); the boot passes — immediately at startup and again ~5 s later (post-import) — skip the gate so a session quit moments before launch clears at once, disabled via `DASHBOARD_LIVENESS_PROBE=0` / on Windows / in containers, and a false completion self-heals through hook reactivation. Sessions whose `cwd` is not POSIX-absolute (household-hook-forwarded from another machine, e.g. a Windows `D:\…` path a local `/proc`/`lsof` scan can never match) are skipped by the reap — so a mixed local + forwarded deployment stays correct without disabling the whole probe. **Remote Data Source sessions** (`sessions.source` ≠ `local`) are excluded from the reap, the error/interrupt watchdog scan, and both stale sweeps (all gated on `source = 'local'`): their POSIX-absolute `cwd` lives on another machine, so local process/clock heuristics would wrongly terminate a running remote session — their status is reconciled from the SSH mirror by `remote-sync.js` instead | +| `routes/hooks.js` | Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic `Compaction` events. Manages the agent state machine plus the `awaiting_input_since` overlay (stamped on SessionStart for fresh CLIs — `startup`/`resume`/`clear` only, since a `compact`-source SessionStart fires mid-turn while Claude is working and is deliberately skipped so a working session stays Active — on non-error Stop, and on permission Notifications (which now also set agent status to `waiting`); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the `index.js` row — since `Esc` fires no hook). After `res.json()` returns on `SubagentStop`, fires a fire-and-forget `scanAndImportSubagents` (from `scripts/import-history.js`) that parses every `subagents/agent-*.jsonl`, pairs `tool_use` ↔ `tool_result` blocks by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under each subagent's own `agent_id` — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the `import-history.js` row); it returns `{ created, reparented }`, and the follow-up `new_event` refetch nudge fires when **either** is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to **its own model** (resolved from the subagent transcript) and stamps `metadata.model` on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses `DASHBOARD_STALE_MINUTES` (default 180). Uses a shared `TranscriptCache` instance (`server/lib/transcript-cache.js`) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via `isCompactSummary` in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (`baseline_*` columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. **SessionEnd preserves error state** — but only when the error is still unrecovered at the transcript tail (`isErrorAtTail`: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as `completed` instead of freezing in a stale `error`. **Error recovery**: `UserPromptSubmit` and `PreToolUse` recover a session from `error`; additionally the 15 s watchdog now scans `error` sessions and self-heals one back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in `error` forever. **Session naming**: on every event, syncs `sessions.name` from the transcript title surfaced by `TranscriptCache` and broadcasts `session_updated` — an explicit `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) always wins, an `ai-title` (auto / plan-accept) only fills a placeholder/auto name (`Session ` or a cwd-folder import name) so a user-chosen name is never clobbered. When neither title exists, the session's **first user prompt** (surfaced by `TranscriptCache` as `firstUserMessage`; tool-result / meta / slash-command plumbing entries skipped) fills the placeholder session name plus the main agent's placeholder name and empty task (issue #201) — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through (the shared `updateAgent` statement writes that column verbatim) so it is never wiped mid-turn. The guarded `updateSessionName` no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename. **Dead-session liveness reap**: the same watchdog completes any `active` `tool = 'claude'` session whose `cwd` has no running `claude` CLI process (probe via `lib/session-liveness.js`) — recovering a `SessionEnd` lost while the dashboard was down (e.g. Ctrl+C) that previously left the session in Waiting until the 3 h stale sweep; watchdog ticks are gated on the transcript mtime (fallback `updated_at` when no transcript exists) being older than `DASHBOARD_LIVENESS_IDLE_SECONDS` (default 60 s); the boot passes — immediately at startup and again ~5 s later (post-import) — skip the gate so a session quit moments before launch clears at once, disabled via `DASHBOARD_LIVENESS_PROBE=0` / on Windows / in containers, and a false completion self-heals through hook reactivation. Sessions whose `cwd` is not POSIX-absolute (household-hook-forwarded from another machine, e.g. a Windows `D:\…` path a local `/proc`/`lsof` scan can never match) are skipped by the reap — so a mixed local + forwarded deployment stays correct without disabling the whole probe. **Remote Data Source sessions** (`sessions.source` ≠ `local`) are excluded from the reap, the error/interrupt watchdog scan, and both stale sweeps (all gated on `source = 'local'`): their POSIX-absolute `cwd` lives on another machine, so local process/clock heuristics would wrongly terminate a running remote session — their status is reconciled from the SSH mirror by `remote-sync.js` instead A hook posted with `tool: "codex"` (Codex hooks) creates a `tool = 'codex'` session, reaches only the codex lane at its cwd, skips every Claude-transcript read, and has `PermissionRequest` → `Notification`, `Interrupt` → `Stop`; one with `tool: "antigravity"` is mapped by `antigravityHookInput` (see **Antigravity Sessions and Hooks**). A Notification is tagged `Compaction` for `tool = 'claude'` only. The reap, the error/interrupt watchdog scan and the startup cleanup of stale `active` sessions touch `tool = 'claude'` rows only (the periodic 3 h stale sweep takes every tool). | | `routes/sessions.js` | Standard CRUD with pagination. GET includes agent count via LEFT JOIN. POST is idempotent on session ID. GET `/:id/transcript` also surfaces `custom-title` lines as synthetic `session_event` (rename) messages — deduped, with `ai-title` excluded — so TUI-only `/rename` (which writes no user/assistant turn) is still visible in the conversation viewer. It also surfaces `system`/`local_command` lines: newer Claude Code builds write a local slash command's invocation and captured output (``, ``/`stderr`) as `system`/`local_command` entries with the TUI markup in a top-level `content` string (older builds used `user` messages), so the route re-emits those as user-side text and the client's `tuiSegments` parser renders the command pill + its output (e.g. `/color` → a `/color` pill plus "Session color set to: cyan"). Content-less `local_command` lines (e.g. `/clear`) and every other `system` subtype (`turn_duration`, `stop_hook_summary`, …) are dropped as noise | | `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total | | `routes/events.js` | Read-only event listing with session_id filter and pagination | @@ -336,7 +336,7 @@ graph TD | `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub `) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason | | `scripts/import-history.js` | Batch history importer used by (a) server startup auto-import, (b) the `/api/import/*` routes, (c) the `import-history` CLI, and (d) live `SubagentStop` ingestion via the exported `scanAndImportSubagents(dbModule, sessionId, transcriptPath)`. Exposes `importAllSessions(dbModule)` for the default `~/.claude/projects` tree, `syncDefaultProjects(dbModule, {mtimeCache})` (the incremental, mtime-fingerprinted re-sweep that backs the continuous background sync — parses only new/changed files and reports `[{sessionId, isNew}]`), and the generalized `importFromDirectory(dbModule, rootDir, {onProgress})` which walks any directory recursively, classifies each `.jsonl` as session vs subagent (with `findSessionSubagents` probing both `//subagents/*` and `/subagents//*` layouts), and funnels everything through the shared `parseSessionFile` + `importSession` pipeline. The durable transcript snapshot (`snapshotTranscript`) additionally preserves **nested** Workflow-tool inner-agent transcripts (`subagents/workflows//agent-*.jsonl`) via the separate `findSessionWorkflowSubagents` probe — mirroring the run subpath so the read route resolves the snapshot identically to the live file, without pulling those nested agents into the flat sub-agent import (no double-count). After each batch imports, `importAllSessions` / `importFromDirectory` also call `ingestWorkflowsForSession` (from `server/lib/workflow-ingest.js`) per session — outside the SQLite transaction, since the ingest is async — so a **Workflow-tool** run whose journal never reached a live server (a headless `claude -p` run, a CI job, or an HPC/cluster node emits no hooks) still links its inner agents to their `run_id` on a plain `agenthub import rescan` / `agenthub import path`, instead of leaving them orphaned (`workflow_run_id = NULL`) with the run stuck at 1 agent — see "Workflow-Tool Run Ingestion". `parseSubagentFile` extracts ordered `toolEvents` (tool_use + tool_result paired by `tool_use_id`) so `importSubagentFromJsonl` can emit per-tool `PreToolUse` + `PostToolUse` rows under each subagent's own `agent_id`. The importer dedups against live hook-created subagent rows via `findLiveSubagentForJsonl` (session + subagent_type + start-time within 30 s) so backfill never produces parallel `-jsonl-*` rows. It also **skips `importSubagents` entirely when subagent transcripts exist** — the main-transcript `Agent`-block rows (`-subagent-N`) and the transcript rows (`-jsonl-*`) would otherwise both be created and only deduped by a fragile type+timing match, doubling every subagent; when transcripts are present the richer `-jsonl-` rows are authoritative and the `-subagent-N` fallback runs only when there are none. `importSession` now also **persists `transcript_path`** on the session row (via `setSessionTranscriptPath`) so the abandon sweep, compaction scanner, and per-agent cost backfill can locate the transcript later, and **stamps each subagent's own token buckets into `metadata.tokens`** (used for per-agent cost). `backfillSubagentTokenMetadata` (a deferred, self-limiting startup pass) fills `metadata.tokens` on subagents that predate per-agent cost, deriving the transcript path from `//.jsonl` when `transcript_path` is null and covering both subagent-dir layouts — metadata-only, so it never touches session `token_usage`. `classifyJsonl` treats any file under a `subagents/` ancestor at any depth (including the `subagents/workflows//` tree) as a subagent, so workflow inner-agent transcripts are never misimported as top-level sessions. **Nested-subagent hierarchy** is rebuilt by `reconcileSubagentParents`: every subagent is inserted flat under the main agent (no single hook/JSONL carries the spawner id), then `parseSubagentFile` also returns `spawnedChildren` — the child agent ids named on each Task tool result (`toolUseResult.agentId`) — which are inverted to a child→parent map and used to repoint `parent_agent_id` (via the `setAgentParent` statement) so a subagent that spawns its own subagents nests under its true spawner instead of collapsing flat under main; any subagent no other subagent claims stays under main. It resolves both the child and parent to their live-or-jsonl DB id (mirroring `importSubagentFromJsonl`), so it also corrects the live hook heuristic's guesses once the transcripts land. Idempotent and additive (only rewrites `parent_agent_id`), it runs in all three group-import paths (`importSession` ×2, live `scanAndImportSubagents`); `scanAndImportSubagents` returns a `reparented` count alongside `created`. **Re-import is fully incremental**: for each existing session a per-event-type high-water mark (`MAX(created_at) GROUP BY event_type`) is read up-front and only JSONL entries with `ts > cutoff[type]` are inserted for Stop / PostToolUse / TurnDuration / ToolError — so long-running sessions whose transcripts grow across multiple days continue to receive new events on every re-run instead of being blocked by the old "if zero of type X then dump all" check. `sessions.ended_at` is rolled forward to the JSONL's last activity when it surpasses the stored value, and `metadata.user_messages` / `assistant_messages` / `turn_count` are refreshed on every pass. `parseSessionFile` also captures the transcript title (`custom-title` / `ai-title`) and `importSession` prefers it for `sessions.name` over the cwd-folder fallback, backfilling existing auto/placeholder names on re-import (same precedence as the live hook sync). Other idempotency keys are unchanged: `data LIKE '%"tool_use_id":"X"%'` skips any tool event already inserted, compaction agents/events dedup by uuid, API errors dedup by summary, and `baseline_*` columns preserve pre-compaction token totals. Token totals, per-model cost, compactions, subagents, tool events, API errors, and turn durations are identical to live ingestion. Creates `APIError`, `TurnDuration`, and `ToolError` event types during import; subagent tool events carry `imported: true, source: "subagent_jsonl"` in their data payload so analytics can distinguish backfilled rows when needed | | `server/routes/import.js` | Express router for the Import History feature. Three endpoints funnel into the same pipeline: `POST /api/import/rescan` (default projects dir), `POST /api/import/scan-path` (arbitrary absolute dir with `~` expansion), `POST /api/import/upload` (multer multipart accepting `.jsonl`, `.meta.json`, `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.gz`). `GET /api/import/guide` returns OS-aware instructions + archive command + default-dir stats. Each request uses a per-request temp dir (`req._agenthubUploadDir` for multer staging, a separate `workDir` for extraction) that is reclaimed in `finally`. Progress is broadcast as `import.progress` websocket messages throttled at ~150 ms. Limits configurable via `AGENTHUB_IMPORT_MAX_BYTES` / `AGENTHUB_IMPORT_MAX_FILES` | -| `server/lib/pricing-sync.js` | Automatic pricing: a Claude model seen in `token_usage` with no `%` row of its own gets one INSERTED (never updated) from LiteLLM's public price list — fetched only when something is missing, cached 24 h, run from `startBackgroundServices` every 15 min, off with `AGENTHUB_PRICING_SYNC=0`. See docs/DATABASE.md § model_pricing | +| `server/lib/pricing-sync.js` | Automatic pricing: a Claude, OpenAI (`gpt-…`, `o…`, `codex-…`, from Codex sessions) or Gemini (`gemini-…`, from Antigravity sessions) model seen in `token_usage` with no `%` row of its own gets one INSERTED (never updated) from LiteLLM's public price list — fetched only when something is missing, cached 24 h, run from `startBackgroundServices` every 15 min, off with `AGENTHUB_PRICING_SYNC=0`; each entry only from its own vendor (`anthropic`/`openai`, `vertex_ai-language-models`/`gemini`), OpenAI and Gemini rows with no cache-write charge unless the list names one, Gemini's >200k rate not modelled. `POST /api/settings/reset-pricing` syncs straight after reseeding (up to 3 s); `{scope: "claude"}` resets only `claude-…` rows. See docs/DATABASE.md § model_pricing | | `server/lib/ai-tools.js` | The AI Tools catalog: one JSON per AI coding CLI (`server/data/ai-tools/`, the synced newer-release copy, `DASHBOARD_AI_TOOLS_DIR`, rising precedence), PATH detection, enable/default state (`/ai-tools.json`). `launcher`/`install`/`update` are honoured only in built-in and release files. | | `server/lib/shell-path.js` | Merges the login shell's PATH into the server's (`$SHELL -ilc`, 5 s) and keeps the shell's own order (`shellPath()`) for install jobs. | | `server/lib/ai-tool-jobs.js` | The one-at-a-time install/update runner behind `POST /api/ai-tools/:id/install|update` and `/update-all`: argv only, stdin closed, login-shell npm, vendor scripts downloaded and run with `bash`, a tool's own updater; log in memory. | @@ -360,6 +360,10 @@ graph TD | `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`/skills//SKILL.md`), agents (`/agents/*.toml`), rules (`/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store | | `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override | | `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved | +| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. | +| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. | +| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. | +| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. | | `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes | | `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer | | `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned | @@ -613,7 +617,7 @@ graph LR | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) | | `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map (only while a pipeline template is installed — none ships), and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | -| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | +| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer, plus an **AgentHub** sub-tab (`components/settings/ClaudeAgentHubSettings.tsx`) holding the dashboard's own Claude Code settings — Model Pricing (`GET/PUT/DELETE /api/pricing`, `POST /api/settings/reset-pricing`), hook status + Reinstall (`GET /api/settings/info`, `POST /api/settings/reinstall-hooks`) and CLAUDE_HOME (`GET/PUT /api/settings/claude-home`; a PUT answers `routers.missing`, the lanes naming an AI Router profile the new home's store lacks, and the UI warns); its **Reset Defaults** sends `{scope: "claude"}`. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through `components/settings/HomeDirSetting.tsx` and are saved to AgentHub's settings file (`server/lib/settings-env.js`: `~/.config/agenthub/settings.env`, never the plugin cache's `.env`; load order real environment > the checkout's `.env` > that file), with a warning when the real environment or `.env` owns the variable (`env_owned`); both PUTs carry `sameOriginGuard`. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Its **AgentHub** sub-tab holds Model Pricing for OpenAI models (`components/settings/PricingSettings.tsx`, the same table Claude Code's AgentHub sub-tab shows filtered to `claude-…` rows, each with a **Show all models** switch) CODEX_HOME (`GET/PUT /api/settings/codex-home`; a save re-writes every codex lane's AI Router layer into the new home via `reapplyCodexLayers` in `server/lib/router-apply.js`) and the **Codex hooks** card (`components/settings/CodexHooksSettings.tsx`, `GET/POST/DELETE /api/settings/codex-hooks`). **antigravity** tab (`?tool=antigravity`) → `AntigravityConfig`: Model Pricing for Gemini models (`PricingSettings vendor="google"`) and the **Antigravity hooks** card (`components/settings/AntigravityHooksSettings.tsx`, `GET/POST/DELETE /api/settings/antigravity-hooks`). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | | `/*` | NotFound | None (static 404 page) | ### Activity Feed Interaction Model @@ -1510,15 +1514,16 @@ changes. --- -## Codex Lane Status (no hooks) +## Codex Lane Status, Sessions and Hooks -A `codex` lane has no hook stream at all — Codex does not implement Claude -Code's hook protocol — so `touchLaneFromHook` (the only caller of -`stage-detect.detect()`) can never see one, and is deliberately pinned to -`"claude"` so a Claude hook cannot touch a codex lane sharing the same -directory. Codex lanes carry **no pipeline**: nothing detects, advances or -nudges their stage, and the Workspace hides the stage chip, time-on-stage, -pipeline/feature pickers and PipelineMap for them. +A Claude hook reaches only a `claude` lane and a Codex hook only a `codex` +lane: `touchLaneFromHook` resolves the lane by `(cwd, tool)`, so two lanes +sharing a directory never see each other's hooks, and it runs +`stage-detect.detect()` for `claude` lanes only. Codex lanes carry **no +pipeline**: nothing detects, advances or nudges their stage, and the Workspace +hides the stage chip, time-on-stage, pipeline/feature pickers and PipelineMap +for them. Codex's hooks are opt-in (see **Codex hooks** below); without them a +codex lane's only signal is its rollout journal. `server/lib/codex-ingest.js` only mirrors a codex lane's `status`. Codex appends a rollout journal per thread at @@ -1532,11 +1537,15 @@ payload carrying the thread's `cwd`. One pass: it is written once, at thread creation; the cache is pruned to the current scan once it exceeds 4 × the 40-file cap) and matches it to a `codex` lane by deepest path-boundary prefix. -3. The lane is `running` while that journal's mtime is under two minutes old, - `idle` once older **or in the future** (a clock stepped back would otherwise - read as live), under the identical guard hook ingestion uses (no `run_id`, - only from `idle`/`running`). Nothing else is written: not `stage`, not - `notes`, not `slot`/`ports`. +3. The lane is `running` while ANY of its journals has an mtime under two + minutes old (one verdict per lane — judging file by file flapped a lane with + an old journal beside the live one), `idle` once they are older **or more + than a second in the future** (a clock stepped back would otherwise read as + live; the second is for a nanosecond mtime against `Date.now()`), under the + identical guard hook ingestion uses (no `run_id`, only from + `idle`/`running`). A lane whose bound session got a hook event in the last + two minutes is skipped: its hooks are current. Nothing else is written: not + `stage`, not `notes`, not `slot`/`ports`. `startCodexPoll(broadcastLane)` runs it on an unref'd 15 s interval from `server/index.js`, tunable via `DASHBOARD_CODEX_POLL_MS` (`0` disables). Every @@ -1547,8 +1556,173 @@ event loop. The existing `lane_update` broadcast carries the change. Codex 0.149 also mirrors threads into version-suffixed SQLite files (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still points at a live rollout file, so reading the JSONL avoids coupling to a schema the -next Codex release renames. `rolloutFiles()` is the single function to re-point -if that changes. +next Codex release renames. `allRolloutFiles()` is the single function to +re-point if that changes. + +**Codex sessions and token usage** come from the same journals, in +`server/lib/codex-usage.js` (`startCodexUsagePoll(broadcast)`, same +`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the +background at startup). Each file's read offset and parse state live in the +`codex_rollouts` table, so an unchanged file is never re-read and a grown one is +read from where it stopped. `token_count` events are summed per `turn_context` +model — `input_tokens` includes `cached_input_tokens` (stored as input − cached +plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its +parent's whole history into its own file, token events included, so every total +vector found in the parent's journal is skipped in the child; a **sub-agent +thread** (`session_meta.session_id` ≠ `id`) is summed into its root session. +The result is one `sessions` row per root session with `tool = 'codex'`, no +`transcript_path` (the Claude transcript parsers and sweeps skip it), `status` +`active` while any of its journals was written in the last two minutes, and +exact per-model totals written with `stmts.setTokenUsage`. A row whose `tool` +is not `codex` is never touched, and a Codex session whose hooks are current +(`hooksCurrent`: its newest event at or after its newest journal write minus +two minutes) keeps the status, `ended_at` and `updated_at` its hooks gave it +(the poll still writes its usage, model and name); once the hooks stop — +removed, or untrusted in Codex — the journal decides again, in `settleActive` +too. The Workspace's external-session list +(attach, `claude --resume`) and the run form's resume picker offer +`tool = 'claude'` rows only. + +**Codex hooks** (`server/lib/codex-hooks.js`, Settings → Codex → AgentHub → +Codex hooks, `GET/POST/DELETE /api/settings/codex-hooks`) are installed only on +a click. Install merges one group per event — `SessionStart`, +`UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, +`Interrupt`, `SessionEnd`, `timeout: 3` — into `$CODEX_HOME/hooks.json`, keeping +every other entry (a file that is not a JSON object is refused, `EBADJSON`); +a forwarder path holding anything not AgentHub's (other content, an unreadable +file, a directory — `scriptIsOurs`) is never overwritten (`EFOREIGN`, 400) or +removed, hooks.json keeps its mode, and a symlinked hooks.json is emptied +through the link instead of unlinked; +remove takes out only AgentHub's own hooks — recognised by their exact +`'/agenthub-hook.sh' ` command, so a user's hook added to the same +group or with a similar name stays — and the routes carry `sameOriginGuard`. +A symlinked `CODEX_HOME` is matched both as given and resolved. Codex runs a hook only after the user +trusts its exact definition — it asks at startup ("Hooks need review → Trust +all and continue"), or via `/hooks` — and records that as +`[hooks.state.":::"] trusted_hash` in +`config.toml`, which AgentHub reads to report and never writes; it never +passes `--dangerously-bypass-hook-trust`. Because trust is keyed on the +definition, the command is a stable path, `'/agenthub-hook.sh' +`: that script (AgentHub's own) runs this build's +`scripts/hook-handler.js codex` with the server's node and +`CLAUDE_HOME`, and every server start rewrites its content when stale or no +longer executable — only when the file carries AgentHub's marker line +(`refreshScript`), so an AgentHub update never costs the user's trust. The +handler posts `tool: "codex"`, which `POST /api/hooks/event` reads through an +allowlist. For a Codex hook the route: + +- creates the session as `tool = 'codex'` (named `Codex <8>` until the poll + names it) and stores no `transcript_path`; the Claude transcript block + (tokens, titles, errors, compactions), the sub-agent JSONL scan and the + workflow ingest are skipped — the poll owns a Codex session's tokens; +- turns `PermissionRequest` into the `Notification` Claude sends for a + permission prompt (`Codex needs your permission: `), so + the awaiting flag, the lane's `needs_action`, alerts and the browser + notification follow unchanged, and `Interrupt` (a declined permission, Esc) + into `Stop`; +- puts a sub-agent thread's hooks (its rollout, which must be a `rollout-*.jsonl` + under `$CODEX_HOME/sessions`, names another root session) on the root session, + dropping its `SessionStart`/`SessionEnd`/`Stop`/`Interrupt`/`UserPromptSubmit`; + its `PreToolUse`/`PostToolUse` are marked `_codexChild` and record work only — + they neither reactivate the session, clear its waiting flag or set the main + agent working, nor clear the lane's `needs_action` or move its status; +- never tags a Notification as `Compaction` (that is for `tool = 'claude'`), so + a permission request quoting `tar --compress` stays a permission prompt. + +A hook-fed Codex session whose process dies without `SessionEnd` stays active +until the stale sweep: the liveness reap probes `claude` processes only. + +## Antigravity Sessions and Hooks + +`antigravity` lanes run Antigravity's `agy` CLI (1.2.x). Nothing about its +files is documented; what AgentHub reads was verified against a real run and +the binary's own proto descriptors. + +**Sessions and tokens** (`server/lib/antigravity-usage.js`, +`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default +15000, `0` disables). agy writes one SQLite file per conversation, +`~/.gemini/antigravity-cli/conversations/.db`. Each is opened +read-only whenever its mtime (or its non-empty `-wal`'s) changes; +`antigravity_conversations` holds the mtime read, so a session the user deleted +stays deleted until agy writes to it again. A read-only open still lets SQLite +leave an empty `-wal` and a `-shm` beside a cleanly closed file, which is why an +empty `-wal` is not activity. A file that is not agy's (`SQLITE_NOTADB`, +`SQLITE_CORRUPT`, `no such table`) is recorded with `session_id` `''` and tried +again only once it changes or at the next server start, which deletes those +rows; any other failure (a busy file, AgentHub's own error) is retried on the +next pass. From each file: + +- the file name — the session id, the same `conversationId` the hooks report; +- `trajectory_metadata_blob` — protobuf; its first `file://` URI is the + session's `cwd`; +- `gen_metadata` — one protobuf row per model call. Field 1 holds the model + name (field 19) and `ModelUsageStats` (field 4): input tokens (2, cache reads + NOT included), output (3, thinking included), cache write (4), cache read (5). + They are summed per model and written whole with `setTokenUsage`. + +The name (first prompt line, 60 characters) and start come from +`brain//.system_generated/logs/transcript_full.jsonl`. The session is +`tool = 'antigravity'`, has no `transcript_path`, and is `active` while its last +activity — the file (a non-empty `-wal` included), the transcript, or its +newest hook event — is recent: ten minutes for a session with any hook event +(mid-turn a long tool call writes nothing until it ends, and after a `Stop` agy +may be waiting for the user — it fires no exit hook, so an open agy and a +closed one look the same), two minutes for one without. An `error` a failed run's Stop gave it is kept. +agy fires no hook when it exits, nor when a run ends on an API error or on a +permission that headless mode refused. So a session settling to `completed` +also completes its agents, clears its waiting flags and sets `idle` its lane that the hooks left +`running`, when no dashboard pane (`run_id`) owns the lane's status. Only the CLI's directory is read, never the Antigravity IDE's +(`~/.gemini/antigravity/`). Gemini rates come from `pricing-sync.js` +(`gemini-…` families, from LiteLLM's Vertex AI or Gemini API entries). + +**Antigravity hooks** (`server/lib/antigravity-hooks.js`, Settings → +Antigravity → **Antigravity hooks**, `GET/POST/DELETE +/api/settings/antigravity-hooks`, `sameOriginGuard` on the writes) are installed +only on a click. +- **Where:** `~/.gemini/config/hooks.json` maps hook names to their events. It + is global, and the Antigravity IDE loads it too. AgentHub owns the name + `agenthub` and leaves every other name alone. A file that is not a JSON + object is refused (`EBADJSON`). +- **Trust:** agy asks for none. +- **Events:** only three, and all of them observational: `PreInvocation`, + `PostToolUse` (a `{matcher: ".*", hooks}` group) and `Stop`, each with + `timeout: 5`. +- **Never `PreToolUse`:** its answer is a permission decision. A `{}` answer + makes agy DENY the tool, `allow` would override the user's permission rules, + and `ask` would force a prompt. +- **No permission event:** agy has none, so an Antigravity lane never gets + `needs_action`. +- **The command:** `'/config/agenthub-hook.sh' `. The script + runs this build's `scripts/hook-handler.js antigravity`, then prints + `{}`, and it is refreshed at every server start when it is ours and stale. A + file at that path that is not ours is never overwritten or removed + (`EFOREIGN`, 400), and a rewritten hooks.json keeps its mode — the same holds + for Codex's forwarder and hooks.json. + +In `POST /api/hooks/event` (`tool: "antigravity"`, allowlisted), a hook is +ignored unless: +- its `conversationId` is a UUID; +- its `transcriptPath` lies under the CLI's own directory, as configured or + as resolved through a symlink (`agyCliDirs`). The IDE's hooks fail this + check. + +What each event becomes: +- `session_id` is the `conversationId`; +- `cwd` is `workspacePaths[0]`; +- a `PreInvocation` becomes `UserPromptSubmit` when it is a run's first + (`invocationNum: 0`), the session has no event yet, its last event is a + `Stop`, the session is not `active` (a turn that ended with no Stop, one the + poll settled), or its last event is more than two minutes old; any other is + dropped (inside a turn a model call follows a hook within seconds); +- `PostToolUse` carries `toolCall.name`/`args` as `tool_name`/`tool_input`, + plus `stepIdx`, so two identical calls stay two events; +- `Stop` stays `Stop`, with `stop_reason: "error"` when it carries a non-empty + `error` or a `terminationReason` matching `/error|fail/i`, and is dropped while `fullyIdle` is + false (background work still running). Every other event is dropped. + +The session is created as `tool = 'antigravity'` (named `Antigravity <8>` until +the poll names it), the lane is resolved by `(cwd, 'antigravity')`, and no stage +is ever inferred. --- @@ -2303,7 +2477,7 @@ graph LR ## Model Name Formatting (Client) -The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Settings page, which shows raw patterns for pricing rule configuration). +The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Model Pricing table in Settings → Claude Code → AgentHub, which shows raw patterns for pricing rule configuration). **Transformation rules:** diff --git a/CLAUDE.md b/CLAUDE.md index 0a82f8db..83f03d94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,13 @@ # Claude Code Working Guide -_Last synced: v1.8.12 (2026-09-28) + AI Tools INSTALL/UPDATE (#40 part A2: catalog fields `install`/`update` honoured only in built-in and release files; `server/lib/ai-tool-jobs.js` runs ONE job at a time as an argv — the login shell's npm, a downloaded vendor script via `bash `, or a tool's own updater; `server/lib/ai-tool-versions.js` offers an npm update only when the binary resolves under `npm prefix -g`; `server/lib/ai-tools-sync.js` copies the newest release's catalog daily when it is newer than the build, off with `AGENTHUB_AI_TOOLS_SYNC=0`; `server/lib/ai-tool-icons.js` caches raster favicons server-side; `shellPath()` in `shell-path.js`; `repoUrlFor` in `update-check.js`; six `/api/ai-tools` routes; Install/Update/Update tools in `AiToolsSettings.tsx`) + the pipeline layer going DORMANT (no template ships: `server/data/pipelines/` deleted, `EMPTY_PIPELINE`, `ENOPIPELINE`, test templates under `server/__tests__/fixtures/pipelines/`) and the workflow skills leaving the plugin (`skills/ship-feature`, `skills/ship-feature-lane`, the 18 stage/ship commands, all four `agents/`, `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`, `POST /api/skills/install`, `POST /api/lanes/:id/agents/install`, and the Superpowers auto-install with `server/lib/skill-plugins.js` and its doctor row) + the AI TOOLS catalog (#40 part A1: `server/lib/ai-tools.js` loads one JSON per AI coding CLI from `server/data/ai-tools/` — 38 tools, data from Orca, MIT — plus overrides in `DASHBOARD_AI_TOOLS_DIR`, detects each by walking PATH, and keeps enable/default in `/ai-tools.json`; `server/lib/shell-path.js` merges the login shell's PATH (`$SHELL -ilc`, 5 s, off with `AGENTHUB_SHELL_PATH=0`) at startup and on Refresh; `/api/ai-tools` GET/refresh/PUT; Settings → Dashboard → **AI Tools** (`client/src/components/settings/AiToolsSettings.tsx`) and the shared `client/src/hooks/useAiTools.ts`, which replaced `LANE_TOOL_LABEL`; the Add-lane modal's **Default ()** + **Other…** picker; the built-in router profile now labelled plain `Native`, rendered **Native ()**; the Claude Code/Codex settings tabs hidden while that tool is disabled; a per-run **Bypass permissions** switch; `POST /api/run` now forwarding `sandbox`/`approval`/`resumeLast`, which it had dropped) + `antigravity` lanes (a third `lanes.tool`, running Antigravity's `agy` CLI; no pipeline, no AI Router, no model/effort picker; the lane card no longer renders a bare tool chip, and the prompt box names the lane's CLI via `LANE_TOOL_LABEL` in `client/src/lib/types.ts`) + a lane whose pane EXITED being released on a server beat (`healAllRunIds` in `server/routes/lanes.js`, every `DASHBOARD_RUN_HEAL_MS`=10 s from `server/index.js`) — a codex/antigravity pane fires no hook, so nothing else re-read it and the lane stayed `running` + a codex Start no longer sending `resumeLast` (only **Resume last session** does) + per-CHANGE-BLOCK review bars (issue #50: `stage-hunk`/`discard-hunk` take an optional `block` — the index of a run of consecutive +/- lines inside the `@@` hunk — and `onlyBlock` in `server/lib/git-diff.js` cuts git's fresh hunk down to it, folding every other block into the side being checked; `changeBlocks`/`LaneDiffChange` in `LaneDiffView.tsx` hang one bar per block, and the arrows land on `data-change-index`) + `codex` lanes losing their pipeline (`codex-ingest.js` mirrors only idle↔running — no stage detection or auto-advance, no `keepDetection`; `codex-nudge.js` deleted; every server start runs `clearCodexPipelines` (`server/lib/lanes.js`: a codex lane's stage columns, `lane_stage_events` and `lane_features` wiped) and `removeStageDoc` (`server/lib/lane-agents.js`: the old `` block taken out of each codex lane's `AGENTS.md`, the file deleted when nothing else is left); `agenthub lanes agents install` installs nothing on a codex lane; the Workspace hides stage, time-on-stage, pipeline/feature pickers and PipelineMap for one) + `info/exclude` entries AgentHub writes into a lane going to the COMMON git dir with the lane's path prefix (`server/lib/git-exclude.js`, used by `router-apply.js` and `lane-mcp.js` — a linked worktree's own `info/exclude` is never read by git, and a subdir lane has no `.git`) + macOS start-at-login (`scripts/launchd-service.js`: the LaunchAgent `~/Library/LaunchAgents/io.smartgift.agenthub.plist`, same API as `systemd-service.js`, chosen by `scripts/service-manager.js` — which the bootstrap, `agenthub service` and `/agenthub-doctor` now require instead of `systemd-service` directly) + v1.8.6 (2026-09-24) + four fixes found by the #37 evaluation, shipped without #37 itself: tmux targets are EXACT (`=name` for has-/kill-/attach-session, `=name:` for pane and option targets, in `server/lib/tmux.js`, `pty-attach.js` and `scripts/cc-update-all-helper.js`) because a bare `-t agenthub-lane-1` resolves by prefix to `agenthub-lane-12` once lane 1 is gone; `getRun`/`killRun` in `server/lib/pty-run.js` refuse any id that is not `agenthub-lane-`, so `DELETE /api/run/main` can no longer kill a user's own tmux session; `POST /api/lanes/:id/router/apply` reports `restart_required` only when the pane's status is `running` (it was always true, since `getRun` returns a `gone` handle); and a lane's `needs_action` is cleared only by the session that raised it (new nullable `lanes.needs_action_session`, `server/routes/hooks.js`) + macOS support, code-audited and untested on a Mac (#22): `server/index.js` deletes a DENYLIST of parent-session variables at startup (`PARENT_SESSION_VARS`: CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_*, CLAUDE_PLUGIN_ROOT, AI_AGENT, …, plus GIT_EDITOR only when it is Claude Code's `true`; user config such as CLAUDE_CONFIG_DIR/CLAUDE_CODE_USE_BEDROCK/ANTHROPIC_* passes through), because a server started from a Claude Code session handed them through tmux to every lane pane; the `~/.local/bin/agenthub` launcher falling back to `node` on PATH when its pinned node is gone (`linkCli` in `scripts/plugin-bootstrap.js`); `liveServers` ignoring a discovery entry whose `startedAt` predates this boot, so a recycled pid is never a live dashboard; lane cwds stored by real path (`canonicalCwd` in `server/lib/lanes.js`, used by `createLane`, `POST /api/lanes/ensure` and the liveness reaper — `/tmp` becomes `/private/tmp`, a not-yet-existing path through its deepest existing ancestor, existing rows untouched); an `__setExecImpl` seam in `server/lib/session-liveness.js`; and the `INSTALL.md` § macOS section with the Mac checklist + issue #34's empty-repo half — `publish` as a git write (`server/lib/git-ops.js`: `remote add origin -- ` + `push -u origin HEAD`, only in a repo with a commit and NO remote, URL allowlisted by `validateRemoteUrl` to https-without-userinfo/ssh:///scp-like, `origin` removed again on a failed push; `ENOCOMMITS`/`EREMOTEEXISTS`/`EBADURL`), an unborn repo being readable (`gitFacts`/`statusCounts` in `server/lib/worktree.js`: `head: null`, new `unborn`, branch via `symbolic-ref`), `POST /api/lanes/worktree` answering `400 EEMPTYREPO` instead of leaving a `failed` row, and `LaneGitBar.tsx` gaining **Publish…** (no remote), **Publish branch** (remote but no upstream) and a commit-first hint (unborn) while `AddLaneModal.tsx` steers an empty repo to Repo mode + a dashboard fetch/pull/push that needs a git LOGIN asking for one in the git bar (issue #45: `409 EGITAUTH` with `auth`, resent with `credentials`; AgentHub's own shared store `server/lib/git-credentials.js` + helper `server/lib/git-credential-helper.js`, `/api/git-credentials`, Settings → **Git credentials** = `client/src/pages/GitCredentialsConfig.tsx`) + MERGE and conflict resolution from the dashboard (issue #45 part B: `merge`/`resolve`/`merge-commit`/`merge-abort` in `server/lib/git-ops.js`, `gitFacts.merging`/`conflicts`, the git bar's **Merge into current** and diverged-upstream **Merge**, the Changes panel's merge banner and **Merge conflicts** group, and the per-block conflict view `client/src/components/lanes/LaneConflictView.tsx` over the pure parser `client/src/lib/conflicts.ts` — keep mine/theirs/both per block, saved through the whole-file editor's `PUT /git/file` + `resolve done`, no new API) + drag-and-drop of an image onto the lane terminal (`TerminalView.tsx` uploads to `POST /api/run/drop-image` in `server/routes/run.js` — raw body, png/jpeg/gif/webp only, random name under `/agenthub-drops-/`, never a name from the request — then `term.paste()`s the returned path, which is how `claude` attaches it) + Claude Opus 5.5 getting its own pricing row (`claude-opus-5-5%` at 4/20/0.2/5/8, fast 8/40 — `claude-opus-5%` had been pricing it as Opus 5) + AUTOMATIC pricing (`server/lib/pricing-sync.js`, started in `startBackgroundServices`: 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`) + the Changes panel at **Full screen** moving the whole Files view (finder + expand-all + tree, one `browsePane`) into a left SIDEBAR and dropping the Files tab and in-place editing (`full` prop on `LaneChangesPanel` from `Workspace.tsx`; a whole file opens for READING and enters the editor only on a click on its text — `onEditAt` on `LaneFileView`, caret at the clicked line, a drag-selection is not a click; Cancel returns to reading, and only a diff keeps an Edit button) and height-locking the page (`viewportLocked` also true at full, else the panel shrank to content with no live run) + the overview splitting into **Staged Changes** / **Changes** groups in the `work` range + a changed tree row tinted whole (`treeMark` tone carries a `bg-status-*/10`) + the Files tree listing untracked-not-ignored files and carrying each entry's git status (`treeStatus`/`treeEntries`/`TREE_LS` in `server/lib/git-diff.js`: file `status` = porcelain code, folder `changed: true`, from ONE `laneStatus` per tree read; `treeMark` in `LaneChangesPanel.tsx` paints M/U/A/D/R) + split panes on ONE lane (every Workspace pane shows the strip's selected lane, each in its own tmux session: `slot` 0–3 on `POST /api/lanes/:id/start`, `agenthub-lane-` for slot 0 = `run_id`, `agenthub-lane--` for 1–3 — `runIdForLane(id, slot)`/`MAX_PANE_SLOT` in `server/lib/pty-run.js`, the `-[1-3]` suffix accepted by `pty-attach.js` and `websocket.js`; `stop`/`reset`/`remove` kill every slot via `laneRunIds`; a pane's Active runs hides what the other panes show; no per-pane lane picker) + the server suite running on a PRIVATE tmux server (`npm run test:server` = `scripts/test-server.js`: drops `TMUX`, sets `TMUX_TMPDIR` to a temp dir, kills that server after — suites spawn and kill real `agenthub-lane-` sessions with ids from their own DB, and on the default server that killed the user's real lane panes, including the one running the tests) + the whole-file EDITOR (`PUT /api/lanes/:id/git/file`, `server/lib/lane-file-write.js`: the read path's guards plus existing-text-file-only and a `baseSha` stale check → `409 ESTALE`; `readFileAt` returns that `sha`) + the per-hunk controls moving into an editor-style review bar under each hunk's last changed line (`HunkBar` in `LaneDiffView.tsx`: ^/v, `n of N`, Undo, Keep) + **Unstage all** on the ONE commit/Stage-all row + Changes sizes Narrow (30%) / Medium (50/50) / Full screen, no 70/30 + the lane card's needs-you banner gone (the strip tile keeps it) + per-HUNK Stage/Undo in the Changes panel's Review and single-file views (`stage-hunk`/`discard-hunk` in `server/lib/git-ops.js`: the hunk is named by its `@@` line and re-matched against a FRESH `git diff -U HEAD` by `hunkPatch` in `git-diff.js`, then `git apply --cached` / `git apply -R` from a temp file — `409 EHUNKGONE` when it moved, `400 EUNTRACKED` on an untracked file; `discard-hunk` is the SECOND irreversible git action and carries the same `confirm: true` gate and two-click UI; the controls reach every `HunkHeader` through a context in `LaneDiffView.tsx`), each Review file header sticky with its own stage/unstage/discard (`FileActions`, shared with the overview row) + the Changes panel gaining a FULL-width step (`CHANGES_SIZES` in `Workspace.tsx`: narrow → 50/50 → 70/30 → full → closed; at full the `lane-detail` section gets `hidden` — CSS, so the one console render site stays mounted), **Stage all** plus the staged-only commit box ABOVE the overview list and sticky (as is the Files finder), and **Expand all folders** being ONE `GET /api/lanes/:id/git/tree-all` (`listTreeAll` in `server/lib/git-diff.js`: one `ls-files`, every level shaped like `listTree`'s) instead of a request — and a `git ls-files` — per folder + the lane's git section MOVING from the lane card to the top of the Changes panel (`client/src/components/lanes/LaneGitHeader.tsx`: `useLaneGitFacts`, the working-copy line, `LaneGitFilesPanel` and `LaneGitBar`, sharing ONE facts read with `LaneChangesPanel`, which refreshes it after every stage/unstage/discard/commit while a git bar action re-reads the diff; `onNewLane` comes from `Workspace.tsx`, and `LaneCard` has no git row and no `onNewLaneFromRepo` any more) and the git bar losing its commit UI — the overview's staged-only box is the ONE commit path, the server's `commit` without `only` is unchanged; the git line and its icon-only actions are ONE row in one card, the panel toolbar ONE wrapping row, and `LaneCard` hides itself (`empty:hidden`) when it has nothing to show + the run form's `/` and `@` autocomplete being REAL again (`PromptEditor` in `client/src/components/run/RunSetup.tsx` — the `2f39f4e` rewrite had left a bare textarea: `/` offers the run cwd's project + user commands and skills, via `api.ccConfig.commands|skills(scope, cwd)`, plus the CLI built-ins, claude lanes only; `@` offers `GET /api/run/files`; Enter picks while the list is open and sends otherwise) + the console's **Active runs** listing only the CURRENT lane's runs and external sessions (`LaneConsolePane.tsx`: runs by `laneId`, sessions by the longest path-boundary lane cwd, `owningLaneId`) + v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + `LaneDiffView.tsx` WINDOWING a diff above `WINDOW_THRESHOLD` (500) items at a fixed `ROW_PX` row height (`visibleRange`, spacers, a `ch` min-width off the longest line; at or below the threshold the DOM is unchanged) and clamping its mirrored split scroll (`clampScroll`/`follow`) + a git action that git itself refuses (a numeric exec exit status) answering 409 `EGIT` instead of 500 with a number for a code (`gitErrorCode` in `server/routes/lanes.js`), and one `parseNumstat` (`server/lib/git-diff.js`) shared by the Changes panel and `gitFacts.files` + the Dashboard heatmap ramp mixing `--accent` into `--surface-0` so it reads on every theme + the lane git surface being HARDENED (`server/lib/git-diff.js`, `server/lib/git-ops.js`, `server/lib/worktree.js`: every lane git call runs with `GIT_LITERAL_PATHSPECS=1` and `-z` porcelain, is scoped to the LANE's cwd — a subdir lane sees and acts on only its own paths, `gitFacts.files` included — and reads through `lanePath` at every rev; any file whose basename is `settings.local.json`, the lane router key file, is refused as `ESECRET`/403 on disk and at every rev; `commit` refuses with `ESTAGEDOUTSIDE` while anything outside the lane is staged, checked with `--no-renames`; `push` without a resolvable remote is `ENOREMOTE`; git writes serialize on the repo's absolute git dir, not the lane id) + the xterm terminal following the selected DARK theme (`client/src/lib/terminalTheme.ts`, live via `term.options.theme`) and chart colours using structural tokens + the Add-lane modal asking `POST /api/lanes/ensure` with `exact: true` (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with `created: false`, so the modal closed on the parent and no new card ever appeared; `exact` counts only a lane on that very directory, and a `created: false` answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → **Appearance** (`client/src/components/settings/AppearanceSettings.tsx`: mode Dark/Light/**System** — `prefers-color-scheme`, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in `client/src/index.css` keyed by `data-theme` on `` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `` too), and `--tint-base`/light `--syn-plain` are now `var(--surface-3)`/`var(--fg-primary)` so no theme restates them; `hooks/useTheme.ts` is a `useSyncExternalStore` reader over `localStorage` (`theme` = `dark|light|system`, `agenthub.theme.dark|light`) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance `useState` there; an inline `