From 6c605849d8fc169913467c91bd61cc671d748871 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 6 Oct 2026 11:31:26 +0700 Subject: [PATCH] feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount. --- .agents/skills/repo-onboarding/SKILL.md | 20 - .../skills/repo-onboarding/agents/openai.yaml | 4 - .../repo-onboarding/references/module-map.md | 8 - .../references/verification-map.md | 8 - .codex/README.md | 1 - .codex/skills/repo-onboarding/SKILL.md | 20 - .../skills/repo-onboarding/agents/openai.yaml | 4 - .../repo-onboarding/references/module-map.md | 8 - .../references/verification-map.md | 8 - CLAUDE.md | 9 +- INSTALL.md | 8 +- README.md | 32 +- README.vi.md | 32 +- bin/agenthub.js | 127 ++-- client/src/components/ImportHistory.tsx | 20 +- client/src/components/PageTabs.tsx | 4 +- client/src/components/PathPickButton.tsx | 86 +++ client/src/components/RemoteSources.tsx | 20 +- client/src/components/StatusBar.tsx | 109 ++- client/src/components/WorkspaceTree.tsx | 107 ++- .../__tests__/PathPickButton.test.tsx | 69 ++ .../components/__tests__/StatusBar.test.tsx | 84 ++- .../__tests__/WorkspaceTree.test.tsx | 83 ++- .../components/config/ProjectMemoryModal.tsx | 22 +- client/src/components/lanes/AddLaneModal.tsx | 677 +++++++++--------- .../src/components/lanes/LaneChangesPanel.tsx | 48 +- client/src/components/lanes/LaneGitHeader.tsx | 158 ++-- client/src/components/lanes/WorkspaceHome.tsx | 21 +- client/src/components/lanes/WorkspaceRow.tsx | 37 +- .../src/components/lanes/WorkspaceRowMenu.tsx | 69 +- .../components/lanes/WorkspaceSessionList.tsx | 105 ++- .../lanes/__tests__/AddLaneModal.test.tsx | 41 ++ .../lanes/__tests__/LaneChangesPanel.test.tsx | 28 + .../lanes/__tests__/LaneGitHeader.test.tsx | 28 +- .../__tests__/SessionResumeButton.test.tsx | 4 +- .../lanes/__tests__/WorkspaceHome.test.tsx | 7 +- client/src/components/lanes/startSession.ts | 12 +- client/src/components/run/ConsoleTabs.tsx | 342 ++++++--- client/src/components/run/LaneConsolePane.tsx | 4 +- client/src/components/run/PaneGrid.tsx | 33 +- .../components/settings/AiToolsSettings.tsx | 2 +- .../components/settings/HomeDirSetting.tsx | 8 + .../components/settings/PricingSettings.tsx | 9 +- .../__tests__/ClaudeAgentHubSettings.test.tsx | 6 +- .../__tests__/PricingSettings.test.tsx | 27 +- .../workflows/LanePipelinePanel.tsx | 2 +- .../__tests__/LanePipelinePanel.test.tsx | 4 +- client/src/i18n/locales/en/lanes.json | 20 +- client/src/i18n/locales/en/nav.json | 3 +- client/src/i18n/locales/vi/lanes.json | 20 +- client/src/i18n/locales/vi/nav.json | 3 +- .../lib/__tests__/laneOrderStorage.test.ts | 29 +- client/src/lib/__tests__/paneLayout.test.ts | 135 +++- client/src/lib/api.ts | 40 +- client/src/lib/laneOrderStorage.ts | 23 +- client/src/lib/paneLayout.ts | 173 ++++- client/src/pages/Dashboard.tsx | 14 +- client/src/pages/Workspace.tsx | 155 ++-- client/src/pages/__tests__/Workspace.test.tsx | 73 +- .../screens.snapshot.test.tsx.snap | 32 +- docs/API.md | 67 +- docs/CLI.md | 32 +- docs/DEPLOYMENT.md | 2 +- docs/LANES.md | 157 ++-- docs/PLUGINS.md | 12 +- docs/plans/workspace-rename/README.md | 87 +++ openapi.yaml | 100 ++- package-lock.json | 4 +- package.json | 2 +- plugins/agenthub/commands/agenthub-doctor.md | 104 ++- plugins/agenthub/commands/agenthub-open.md | 24 +- plugins/agenthub/commands/agenthub-update.md | 40 +- scripts/plugin-doctor.js | 2 +- scripts/plugin-open.js | 21 +- server/__tests__/daily-cost.test.js | 7 + server/__tests__/dashboard-runs.test.js | 11 + .../__tests__/git-diff-ops-hardening.test.js | 69 +- server/__tests__/lanes-api.test.js | 73 +- server/__tests__/lanes-cli.test.js | 31 +- server/__tests__/native-dialog.test.js | 105 +++ server/__tests__/plugin-doctor-tmux.test.js | 2 +- server/__tests__/pty-attach.test.js | 4 +- server/__tests__/pty-run.test.js | 48 +- server/index.js | 10 +- server/lib/dashboard-runs.js | 14 +- server/lib/git-diff.js | 55 ++ server/lib/native-dialog.js | 180 +++++ server/lib/pets.js | 1 - server/lib/pty-attach.js | 4 +- server/lib/pty-run.js | 24 +- server/lib/worktree.js | 10 +- server/openapi-extra/lanes.js | 62 +- .../openapi-extra/override-pricing-alerts.js | 21 + server/openapi-extra/run.js | 9 + server/routes/hooks.js | 2 +- server/routes/lanes.js | 70 ++ server/routes/pricing.js | 10 +- server/routes/run.js | 4 +- server/websocket.js | 2 +- skills/debug-live-issue/SKILL.md | 23 - .../references/investigation-template.md | 32 - skills/repo-onboarding/SKILL.md | 24 - .../repo-onboarding/references/command-map.md | 13 - .../repo-onboarding/references/module-map.md | 15 - 104 files changed, 3478 insertions(+), 1396 deletions(-) delete mode 100644 .agents/skills/repo-onboarding/SKILL.md delete mode 100644 .agents/skills/repo-onboarding/agents/openai.yaml delete mode 100644 .agents/skills/repo-onboarding/references/module-map.md delete mode 100644 .agents/skills/repo-onboarding/references/verification-map.md delete mode 100644 .codex/skills/repo-onboarding/SKILL.md delete mode 100644 .codex/skills/repo-onboarding/agents/openai.yaml delete mode 100644 .codex/skills/repo-onboarding/references/module-map.md delete mode 100644 .codex/skills/repo-onboarding/references/verification-map.md create mode 100644 client/src/components/PathPickButton.tsx create mode 100644 client/src/components/__tests__/PathPickButton.test.tsx create mode 100644 docs/plans/workspace-rename/README.md create mode 100644 server/__tests__/native-dialog.test.js create mode 100644 server/lib/native-dialog.js delete mode 100644 skills/debug-live-issue/SKILL.md delete mode 100644 skills/debug-live-issue/references/investigation-template.md delete mode 100644 skills/repo-onboarding/SKILL.md delete mode 100644 skills/repo-onboarding/references/command-map.md delete mode 100644 skills/repo-onboarding/references/module-map.md diff --git a/.agents/skills/repo-onboarding/SKILL.md b/.agents/skills/repo-onboarding/SKILL.md deleted file mode 100644 index 4ea78747..00000000 --- a/.agents/skills/repo-onboarding/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: repo-onboarding -description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning. ---- - -# Repo Onboarding Skill - -## Workflow -- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`. -- Determine target layer: `server/`, `client/`, or docs. -- Identify the minimal file set needed for the task. -- Select verification commands before editing. - -## Verification defaults -- Backend: `npm run test:server` -- Frontend: `npm run test:client` - -## References -- `references/module-map.md` -- `references/verification-map.md` diff --git a/.agents/skills/repo-onboarding/agents/openai.yaml b/.agents/skills/repo-onboarding/agents/openai.yaml deleted file mode 100644 index 0938e09e..00000000 --- a/.agents/skills/repo-onboarding/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Repo Onboarding" - short_description: "Map architecture, ownership, and verification strategy before coding." - default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task." diff --git a/.agents/skills/repo-onboarding/references/module-map.md b/.agents/skills/repo-onboarding/references/module-map.md deleted file mode 100644 index d6570027..00000000 --- a/.agents/skills/repo-onboarding/references/module-map.md +++ /dev/null @@ -1,8 +0,0 @@ -# Module Map - -- `server/index.js`: app startup and route mounting. -- `server/routes/*.js`: API contracts and route behavior. -- `server/db.js`: schema and statement layer. -- `server/websocket.js`: live update broadcast path. -- `client/src/pages/`: route-level UI. -- `client/src/components/`: reusable UI primitives. diff --git a/.agents/skills/repo-onboarding/references/verification-map.md b/.agents/skills/repo-onboarding/references/verification-map.md deleted file mode 100644 index 45e362bd..00000000 --- a/.agents/skills/repo-onboarding/references/verification-map.md +++ /dev/null @@ -1,8 +0,0 @@ -# Verification Map - -- Backend changes: - - `npm run test:server` -- Frontend changes: - - `npm run test:client` -- Docs-only changes: - - validate command consistency against root `package.json` diff --git a/.codex/README.md b/.codex/README.md index c3fc8600..ae7557e1 100644 --- a/.codex/README.md +++ b/.codex/README.md @@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions: ## Included skills -- `repo-onboarding` — architecture discovery and verification selection - `release-guard` — release readiness checks diff --git a/.codex/skills/repo-onboarding/SKILL.md b/.codex/skills/repo-onboarding/SKILL.md deleted file mode 100644 index 4ea78747..00000000 --- a/.codex/skills/repo-onboarding/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: repo-onboarding -description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning. ---- - -# Repo Onboarding Skill - -## Workflow -- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`. -- Determine target layer: `server/`, `client/`, or docs. -- Identify the minimal file set needed for the task. -- Select verification commands before editing. - -## Verification defaults -- Backend: `npm run test:server` -- Frontend: `npm run test:client` - -## References -- `references/module-map.md` -- `references/verification-map.md` diff --git a/.codex/skills/repo-onboarding/agents/openai.yaml b/.codex/skills/repo-onboarding/agents/openai.yaml deleted file mode 100644 index 0938e09e..00000000 --- a/.codex/skills/repo-onboarding/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Repo Onboarding" - short_description: "Map architecture, ownership, and verification strategy before coding." - default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task." diff --git a/.codex/skills/repo-onboarding/references/module-map.md b/.codex/skills/repo-onboarding/references/module-map.md deleted file mode 100644 index d6570027..00000000 --- a/.codex/skills/repo-onboarding/references/module-map.md +++ /dev/null @@ -1,8 +0,0 @@ -# Module Map - -- `server/index.js`: app startup and route mounting. -- `server/routes/*.js`: API contracts and route behavior. -- `server/db.js`: schema and statement layer. -- `server/websocket.js`: live update broadcast path. -- `client/src/pages/`: route-level UI. -- `client/src/components/`: reusable UI primitives. diff --git a/.codex/skills/repo-onboarding/references/verification-map.md b/.codex/skills/repo-onboarding/references/verification-map.md deleted file mode 100644 index 45e362bd..00000000 --- a/.codex/skills/repo-onboarding/references/verification-map.md +++ /dev/null @@ -1,8 +0,0 @@ -# Verification Map - -- Backend changes: - - `npm run test:server` -- Frontend changes: - - `npm run test:client` -- Docs-only changes: - - validate command consistency against root `package.json` diff --git a/CLAUDE.md b/CLAUDE.md index abf1acad..468ae6b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.0.1 (2026-10-05) + AI CLI detection on WINDOWS (`which` in `server/lib/ai-tools.js` tries each `PATHEXT` extension — the bare name only when it already has one, since npm's extensionless shim beside `codex.cmd` is a sh script — so `GET /api/ai-tools` had answered `installed: false` for every tool; `spawnTarget` there gives a `.cmd`/`.bat` `shell: true` and a quoted path for `ai-tool-jobs.js` and `ai-tool-versions.js`, catalog argv only) + a dashboard pane's tmux SCROLLBAR that is actually there (`pane-scrollbars-style fg=brightwhite,bg=black,width=2` in `enableMouse`, `server/lib/tmux.js` — the default one-column track drew in the theme's border colour; and a claude pane started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` in `spawnRun`, because under `"tui": "fullscreen"` it ran on the alternate screen, where tmux keeps no history and draws no scrollbar) + a CODEX session's conversation in `GET /api/sessions/:id/transcript` (`codexMessage` in `server/routes/sessions.js`: its root thread's rollout from `codex_rollouts`, mapped to the Claude message shape; developer messages and injected context left out — the route had answered every Codex session with no messages) + `server/compat-sqlite.js` gaining `function`, `stmt.pluck()` and `readonly`/`fileMustExist` — a plugin install has no better-sqlite3 build, so HEAD's `db.function("trim_event_data")` crashed every such dashboard at boot — with `db.js` exporting its chosen `Database` for `antigravity-usage.js` (which required better-sqlite3 directly and skipped every agy file in production) and `pbFields` turning node:sqlite's Uint8Array blobs into Buffers; `compat-sqlite.test.js` boots db.js with better-sqlite3 blocked + the terminal ATTACH running the live tmux server's own binary (`attachBinary` in `server/lib/pty-attach.js` → `/proc//exe`, else PATH's `tmux`) — tmux 3.7c installed to `/usr/local/bin` beside a running 3.4 server failed every attach with `open terminal failed: not a terminal` while every other tmux command still worked + EVENT-LOOP FIXES (`GET /api/workflows` froze the server for minutes on a large session: its "next tool event" correlated subquery went quadratic once `ad6e801` dropped `idx_events_session` — now one `LEAD()` window pass, `server/lib/workflow-stats.js`, run in the read worker `server/lib/read-pool.js` with `?sections=` (Health asks for seven), shared in-flight runs, `AGENTHUB_READ_WORKER`/`AGENTHUB_READ_TIMEOUT_MS`, 503 `EREADTIMEOUT`; `idx_events_session(session_id, id)` restored for the per-hook duplicate guard and `idx_events_tool(tool_name)` added; `installHooks` adding nothing and removing its `hook-handler.js` entries while settings.json enables `agenthub@…`, which every checkout server start had re-added — ~40% of events doubled — and `getHookStatus` answering `source: "plugin"`) + a Codex/Antigravity session tied to its workspace without hooks (`claimToolSessions` in `server/lib/dashboard-runs.js`: tool + cwd + the run's live window; also fills an empty `dashboard_runs.session_id`) — it had vanished from the workspace once its tab closed + UI-AUDIT FIXES (shared primitives replacing per-page copies: `client/src/lib/toast.ts` + `components/Toaster.tsx` — `toast.success|error|info`, an error stays until dismissed —, `lib/confirm.ts` `confirmDialog`/`confirmDiscard` + `components/ConfirmHost.tsx` replacing every `window.confirm`, `lib/clipboard.ts` `copyText` (falls back to `execCommand` off a secure origin, toasts the outcome), `lib/keys.ts` `isSubmitEnter` ignoring an IME-confirming Enter, `hooks/useArmed.ts` two-click actions that DISARM after 3 s, `hooks/useDialog.ts` the one modal focus contract — focus in, Tab trapped, Escape, focus back, dialogs STACK so only the top one answers —, `hooks/useUnsavedGuard.ts` (`beforeunload` + `confirmDiscard`), `lib/throttle.ts` + `hooks/useLiveRefresh.ts` a leading+trailing throttled WebSocket refresh that skips hidden tabs and refetches on visibility and reconnect; Toaster and ConfirmHost mounted once in `App.tsx`; the `accent-solid` token (`--accent-solid`/`-hover` in every theme, `tailwind.config.js`) as the fill behind white text, `--accent` now a TEXT colour — dark Default `#70b8ff`/solid `#0076d1`, Orca Dark `#6aa0f8`/`#3472d8`, warm light `#924224` = its own solid —; `Workspace`/`SettingsHub`/`SessionDetail` lazy routes under `` inside `components/RouteErrorBoundary.tsx` (a stale chunk after a self-update reloads once, `vite:preloadError` too) — keep heavy page code out of anything the sidebar imports: the start helpers live in `components/lanes/startSession.ts`, not `WorkspaceHome.tsx`, or react-markdown lands in the main bundle; the Dashboard's `components/NeedsYouStrip.tsx`; Sessions filters (`awaiting=1`, `from`/`to` on `GET /api/sessions`, kept in the URL) and CSV/JSON export (`lib/exportData.ts`); Settings → General split into General / Data / About (`Settings.tsx` `section`), Pricing in its own **Cost** nav group, a browser-preferences backup (`lib/prefsBackup.ts`, allowlisted keys only), and the settings search deep-linking a row's `?tab=` and `#anchor`, which the hub scrolls to and highlights; the terminal (`TerminalView.tsx`): Ctrl+M toggles Tab-moves-focus, Cmd+F (macOS)/Ctrl+Shift+F a find bar (`@xterm/addon-search`), `@xterm/addon-web-links`, a WRITE-only `@xterm/addon-clipboard` (OSC 52 in, never a read of the user's clipboard), a **Reconnecting…** overlay, `agenthub.terminal.screenReader` and `agenthub.terminal.selectHintSeen`; `Alt+1–4` / `Alt+\` / `Alt+W` focus, split right and close a pane (session view only, never from a terminal or a field); a draggable Changes splitter storing a NUMERIC fraction in `agenthub.workspace.changesSize` beside the presets; `panePaste` pasting only into a VISIBLE pane; `POST /api/lanes/:id/start` `slot: "auto"` (the server picks the free tab under the lane lock — every start from the home, a replay and Resume/Fork sends it), `409 ESESSIONLIVE` + `run_id` for a resume of a session a live pane holds, `400 EUNSUPPORTED` for a catalog tool's `resumeSessionId`; `GET /api/agents?session_ids=`, `tool_call_count`/`distinct_tool_count` on session stats, label columns on `GET /api/events` rows, `POST /api/settings/cleanup` refusing a non-whole `purge_days` (`INVALID_PURGE_DAYS`) and answering `dry_run`; the client's `EBADRESPONSE` for a non-JSON 200) + the tab bar's **Tools** menu GONE (`LaneToolsMenu` deleted with its MCP sync / dev-sync check / Create-update readme items, the sidebar row menu's readme item, `api.lanes.syncBaseCheck` and the `actions.*` strings; the routes stay, reached by `agenthub lanes mcp sync` / `agenthub lanes sync-base`) + a pane's split right/down menus offering the AI tools beside **New terminal** (`newTabTools`/`newTabBody` exported from `ConsoleTabs.tsx`, shared with the `+` menu) + Settings → **Pet** (`/settings/pet`, `components/settings/PetSettings.tsx`: show, speech bubbles, size with preview, the pet grid with download/upload/import/delete; the Sagi block left General; the mascot menu's **Pet settings…** opens it; list + actions shared with `PetMenu` through `components/Sagi/usePets.ts`) + LONG-TERM DB SAFETY with no data dropped (`server/db.js`: `synchronous = NORMAL`, `journal_size_limit` 64 MB, hourly `maintainDatabase` — PASSIVE checkpoint + `PRAGMA optimize`, never VACUUM —, `idx_events_session_created` replacing `idx_events_session`; the watchdog reading `sessions.transcript_path`; the hook route warming `transcriptCache` BEFORE `processEvent`'s write transaction; `GET /api/sessions/:id?events=0` for the web page, which reloads only on its own session's messages, debounced; `GET /api/settings/export` streamed in keyset pages; clear data in one transaction, cleanup chunked; the subagent importer reading an agent's `tool_use_id`s once; runtime logs rotated past 5 MB by `scripts/log-rotate.js`. `events.data` TRIMMED at every insert through the SQL function `trim_event_data(?)` (`server/lib/event-payload.js`, registered in `server/db.js` — never insert an event without it: Edit/Write drop `originalFile`/`oldString`/`newString`/Write `content`, Read drops `file.content`/`base64`, strings in `tool_input`/`tool_response` over 8 KB keep head + tail, `structuredPatch` and every other field kept, `_trimmed` names the drops; the full text stays in the transcript and its snapshot) plus a one-time chunked sweep of stored rows (`sweepEventPayloads`, `user_version` 4) — the user's choice of 2026-10-05, replacing "keep every payload"; still nothing prunes rows automatically, and the session list now prices every token column like `/api/pricing/cost/:id` (`tokensForSessions`)) + the status bar's **Workspace $** and **Session $** on the Workspace page (`StatusBar.tsx`: `?lane=` on `GET /api/pricing/cost` — the sessions the workspace owns, same rule as `GET /api/sessions?lane=` — and `GET /api/pricing/cost/:id` for the URL's `session` or the focused pane's session from `LIVE_RUNS_EVENT`; `selectedFrom` exported from `WorkspaceTree.tsx`) + RIGHT-CLICK MENUS on every item with actions (`client/src/components/ContextMenu.tsx` — one portal shell + `useContextMenu`/`menuPoint`/`MenuItem`, which `WorkspaceRowMenu` now uses; `lanes/SessionMenu.tsx` for session rows in the sidebar, Recents and the archived fold; console tabs, pane headers, the Changes panel's file rows/review headers/tree entries/commits, the home's title and context rows; then `components/SessionRowMenu.tsx` for sessions outside a workspace — Sessions table, Dashboard agent cards/events, Activity, Workflow runs —, every Settings list and every Claude Code/Codex config list — `FileItemMenu` in `config/MdItemList.tsx`; the buttons stay, a menu repeats them, a delete there takes the button's own confirm, text areas keep the browser's menu, and the shell stops its clicks bubbling through the portal into the card that opened it) + the DAILY COST split by the day tokens were SPENT, not the day a session started (`token_usage_hourly` — one row per UTC hour per bucket, from each Claude transcript message's timestamp via `TranscriptCache.tokensByHour`, written by the hook writer and back-filled at start by `server/lib/hourly-usage.js`; `dailyTokenRows` in `server/routes/pricing.js` books the rest of a session's total to its start date, so the days still add up — the status bar's **Today** read ≈ $0 on a session started days earlier) + CUSTOM PETS for the mascot (`server/lib/pets.js` + `server/routes/pets.js` behind `/api/pets`, `/pets//`; the `.codex-pet.zip` format — `pet.json` + a 1536×1872 `spritesheet.webp`, 8×9 frames of 192×208, one row per state — played by mood in `client/src/components/Sagi/PetSprite.tsx`; the right-click `PetMenu.tsx`: hide, size, choose, upload an image, import a bundle; four featured pets DOWNLOADED from codex-pets.net on a pick, never bundled; worn pet and size in `agenthub.pet`/`agenthub.pet.size`) + a sidebar workspace row opening the workspace's live TABS on the pane it last focused (its home when it has none; **Home** at the tab bar's left end), a bare tab listed at once as **New session** from its run (`WorkspaceSessionList`), and the sidebar's live panes following the Workspace page's poll (`LIVE_RUNS_EVENT` in `WorkspaceTree.tsx`) + the session view's console SPLITTING like VS Code (`client/src/components/run/PaneGrid.tsx` over `client/src/lib/paneLayout.ts`: a binary split tree of slots, ≤ 4 panes, each an absolutely placed sibling keyed by slot; Ctrl/Cmd+click on a tab, a `+` item or a sidebar session row splits — router state `split`, never the URL; pane headers drag to move/swap, pick another live session, split right/down, close the pane only; dividers drag or take arrow keys; `?run=` = the focused pane, pushed with `focusOnly`; layout per workspace in `agenthub.workspace.paneLayout`) and the session view going EDGE TO EDGE (no page padding, no cards, the tab bar and the right panel's activity bar one `h-10` header strip, the terminal on the page colour) + the session view's console as TABS instead of a 1/2/4 split (`client/src/components/run/ConsoleTabs.tsx`: one tab per live tmux slot 0–3, the active one = `?run=`, inactive ones mounted and hidden with CSS; `+` opens **New terminal** — `shell: true` on `POST /api/lanes/:id/start`, `spawnShell` in `server/lib/pty-run.js`, tool `shell`, no `run_id`/`status` change — or a bare AI tool, and links Settings → Agents; `splitViewStorage.ts` and `&pane=` gone — the sidebar's Ctrl/Cmd+click came back as a split, see above; `LaneConsolePane`'s `homeOnExit` → `onRunExit`), the tab bar then becoming the session view's ONLY header (the status/name/back-arrow row gone — Tools at the tab bar's right end, since removed, the pipeline/feature pickers only with a template —, `LaneConsolePane` reduced to the terminal: no title, no **Active runs** — `RunHistory.tsx` deleted with the client's `api.run.history`/`DashboardRunHistoryItem` and the page's active-sessions poll —, no **New run**) + an ENDED session opening in its workspace (`&session=`, `SessionReplay.tsx`: conversation + prompt box, sending resumes) instead of `/sessions/`, which the right panel's **Analyst** button opens + a session's AI TOOL chosen per session (`tool` on `POST /api/lanes/:id/start`, the home's **AI** select; the sidebar's tool icon moved from the workspace row to each session row) and the pane's run form GONE (`RunSetup` keeps only `PromptEditor`/`CwdAutocomplete`/`ModelPicker`; New run/exit/an empty pane go to the workspace home) + the DESKTOP-PARITY plan (`docs/plans/desktop-parity/`, phases 1–7; the multi-user server plan lives in the AgentHub-Server repo): an AI Router chosen PER SESSION (the **AI Router** select beside the model picker in the workspace home and a pane's run form, `client/src/components/run/RouterPicker.tsx`, `router` on both start routes; Claude `--settings=/router-settings/.json`, Codex `-p `; `dashboard_runs.router` for a resume; `POST /api/lanes/:id/router/apply`, `LaneRouterRow`, the Add-workspace router radio and the claude-/codex-home `routers` answers gone; `clearLaneRouters` takes the old keys out of `settings.local.json` at start) + Settings and the collapse toggle pinned to the sidebar's foot + shipped SVG marks for claude/codex/gemini/openclaw/hermes (Gemini's from zonalogo.com) (`client/public/ai-tools/.svg`, `BUNDLED` in `AiToolIcon.tsx`; the server's favicon route stays raster-only for the rest) + a workspace OWNS its sessions and lists only those (`sessions.lane_id`, stamped by `bindRunSession`, backfilled only from `dashboard_runs`; `GET /api/sessions?lane=&archived=0|1`; `PATCH /api/sessions/:id {archived|name}`), `dashboard_runs` keeping a pane's previous run as `@` instead of overwriting it and flagging a run `abandoned` only once its tmux session is gone (`reconcileOrphans(hasSession)` at boot and on the run-heal beat), a session row's state dot (`sessionDot`), double-click rename and archive (`sessions.archived_at`, hides only; the home folds the archived with Unarchive), the activity bar's Sessions tab and `WorkspaceSessionsTab.tsx` gone with `LaneConsolePane`'s `resumeRequest`, **Fork** beside Resume (`forkSession` → `--fork-session`, Claude only, `EUNSUPPORTED` → 400 on start), Ctrl/Cmd+click a live session → `&pane=1`, and a diff line's number opening a comment pasted into the workspace's last-focused pane (`client/src/lib/panePaste.ts`) + the sidebar losing its footer, language and theme controls (the collapse toggle sits beside Settings at the sidebar's foot; the version and **Check for updates** moved into the connection details modal, the status bar showing a green dot while a release is out; the language is Settings → General, `SettingsSegmented`) + a session in its OWN WORKTREE, Claude's way (desktop-parity phase 6: the home's **Local / New worktree** select → `claude --worktree=`, `dashboard_runs.worktree`, `?session=` on every lane git route via `server/lib/session-worktree.js`, Resume/Fork into the worktree, `ENOWORKTREE` when it is gone, Archive = `POST /api/lanes/:id/session-worktree/remove` never forced; `listWorktrees` now reads `locked `) + ONE `client/src/components/Switch.tsx` for every on/off switch and the themed `Checkbox.tsx` (now with `tone`/`disabled`) for every checkbox — no native ``, no hand-rolled switch + the AI Tools catalog TRIMMED to 10 (`server/data/ai-tools/`: claude, codex, gemini, antigravity, copilot, opencode, cursor — labelled **Cursor CLI**, `--continue` resume, `cursor-agent update` — omp, hermes, openclaw; the other 28 deleted; the argv-separator / fixed-args / after-start+bypassEnv prompt modes no built-in uses any more stay supported for custom files and are tested through `server/__tests__/fixtures/ai-tools/`) + the workspace-sessions plan's leftovers (a sidebar TOOL FILTER, `agenthub.sidebar.projects` → `tool`; search also finding a workspace by a session's name; the home naming its repository from `GET /api/projects` and Recents showing cost; the transcript page's explicit **Resume** in `lanes/SessionResumeButton.tsx`) + a WORKSPACE HOME like a Claude Desktop project page (`client/src/components/lanes/WorkspaceHome.tsx`: `/workspace?lane=` opens it and hides — CSS — the session view, which `&run=` opens; its prompt box starts a new session in the first free pane slot; Recents; Instructions / Memory / Context / Folder over `server/lib/lane-sources.js` + `server/routes/lane-sources.js`: `lanes.instructions`, the new `lane_sources` table, uploaded files under `/workspace-context//`, read-only memory, and `spawnRun` adding them to Claude/Codex argv itself) + Settings losing its Projects group (`pages/ProjectSettings.tsx` and the per-project override gone; Worktrees now picks the home's folder suggestions) + notifications opening the lane's pane (`&run=`) + the sidebar becoming a Claude-Desktop-style WORKSPACE LIST (`client/src/components/WorkspaceTree.tsx` replaces `ProjectTree.tsx`: one flat row per workspace in the stored order, no repo grouping and no untracked-worktree rows; a row's chevron expands its sessions, `lanes/WorkspaceSessionList.tsx`; a session with a live pane opens `/workspace?lane=&run=`, which only ATTACHES pane 0 — `attachRequest` on `LaneConsolePane` — and any other one its read-only transcript; the sidebar never starts a resume; expanded rows in `agenthub.sidebar.projects` → `expanded`) + the Workflows page folding into Dashboard → Analytics → **Workflow** (`Dashboard.tsx` renders `pages/Workflows.tsx` under that sub-tab, `?tab=`/`?sub=` deep-link the Dashboard, `/workflows` redirects to `/?tab=analytics&sub=workflow`, no Workflows item in the sidebar) + the named themes gaining `orca-dark`/`orca-light` (Orca's neutral palette in `client/src/index.css`; Orca Dark's accent is two shades of `#3b82f6` — `--accent` `#6aa0f8` for text, `--accent-solid` `#3472d8` behind white text —, not Orca's near-white primary) + a sound per notification type (`sound` + `soundByType` in `client/src/lib/notifPrefs.ts`, default `"none"`; `playSound` in `lib/notify.ts`, played by `useNotifications` only when a notification was actually shown; the WAVs in `client/public/sounds/` are generated by `scripts/gen-notification-sounds.js` — edit the script, never the files) + Settings becoming `/settings/:section` with a grouped left nav (`client/src/pages/SettingsHub.tsx`, route `settings/*`: General / Appearance / Notifications — `Settings.tsx` now takes `section`, its TOC gone —, Agents with `agents/` per tool — a tool's own settings live there, the AI Tools modal is now the `agents` page and `AiToolsModal.tsx` is gone —, Routing (AI Router, an all-vendor Pricing page), Git (credentials, **Worktrees** = `WorktreeVisibilitySettings.tsx`), Projects = `pages/ProjectSettings.tsx` per repo with a per-project `projectHideSources` override and Forget all; `?tool=`/`#ai-tools` redirect to the path form; the whole-page search index is `SETTINGS_INDEX` in `client/src/lib/settingsSearch.ts`, ranked by `rankSettings`, Ctrl/Cmd+F focuses it; row primitives in `components/settings/SettingsRow.tsx`) + the Workspace page losing its lane STRIP to a sidebar PROJECT TREE (`client/src/components/ProjectTree.tsx` + `lanes/WorkspaceRow.tsx`/`WorkspaceRowMenu.tsx`: workspaces grouped by git common dir from the new `GET /api/projects` (`server/lib/projects.js`, `server/routes/projects.js`, `POST /api/projects/refresh` same-origin), untracked worktrees with **Adopt** (`ensure` `exact: true`), double-click rename, drag/Alt+↑/↓ order still in `agenthub.workspace.laneOrder`, a right-click menu with Start/Stop/Rename/router/New here/Copy path/Reset/Remove; prefs in `agenthub.sidebar.projects`, `client/src/lib/projectTreePrefs.ts`), the selected lane being the URL's `?lane=` (mirrored to `agenthub.workspace.selected`), ONE shared lane list (`client/src/hooks/useLanes.ts`), a fixed bottom `StatusBar.tsx` (connection, version, running/needs-you counts, today's cost) taking the sidebar's connection footer, the Changes panel gaining an activity bar (Files / Changes / **Sessions** = `lanes/WorkspaceSessionsTab.tsx`, a session resumed through `LaneConsolePane`'s `resumeRequest`; `agenthub.workspace.panelTab`), and a per-pane header in split view + the user-facing word "lane" becoming "workspace" in UI copy (en + vi), CLI prose, server error messages and docs, every identifier unchanged (`/api/lanes`, `agenthub lanes`, `lanes.json`, `Lane*` components; `docs/LANES.md` keeps its name) + `server/db.js` running every migration under a cross-process lock (`BEGIN IMMEDIATE` on `-migrate.lock`, held from before the legacy-DB copy until just before `stmts`) — two dashboards started at once on a fresh DB raced the probe-then-ALTER and the second died on `duplicate column name`; never wrap those migrations in a transaction on the main DB instead, since several rebuilds open their own `BEGIN` and turn `foreign_keys` off + a second dashboard on the SAME DB re-broadcasting the lanes the other one changed (`broadcastForeignLaneChanges` in `server/routes/lanes.js`, `PRAGMA data_version` every `DASHBOARD_LANE_SYNC_MS`=2 s from `server/index.js` — hooks reach only the lowest port, so a browser on the other port saw a lane's status only after F5) + the pricing sync never adding a RETIRED Claude model in advance (`RETIRED_PRICING_PATTERNS` moved to `server/lib/pricing-sync.js`, imported by `db.js`; one is priced only once used) + a just-started run no longer reading `dead` (`lastEventAge` also counts the run's `dashboard_runs.started_at`) + a Settings TAB PER CATALOG TOOL (`client/src/pages/AiToolConfig.tsx` for every installed, enabled tool with no `launcher`, built in `SettingsHub.tsx` from `useAiTools`, `?tool=`; `AntigravityConfig.tsx` deleted, its pricing + hooks cards living on there): path, homepage, an **Update…** link into the AI Tools modal, `GET /api/ai-tools/:id/usage` sessions/cost, the tool's lanes, a **Default model** saved through `PUT {defaultModel}` (`ModelPicker` now exported from `RunSetup.tsx`), `PricingSettings` for the catalog's `model.vendor` (antigravity now `google`), and the catalog's trusted `config` files edited through `server/lib/ai-tool-config.js` behind `GET/PUT /api/ai-tools/:id/config[/:index]`; the view gains `modelVendor`/`configFiles` + MODEL LISTS from each tool itself (`server/lib/ai-tool-models.js` behind `GET /api/ai-tools/:id/models`: Codex's `models_cache.json` listed slugs — its `identity` never read, Claude's aliases labelled with the newest priced version plus pinned priced versions, a catalog tool's vendor-priced ids plus the models its sessions used), a catalog `model: { flag, vendor? }` field (antigravity, gemini, opencode, cursor) putting ` ` before the prompt, `validModel` refusing a flag- or shell-shaped id (`EBADMODEL`) at the route AND in `spawnRun`, a per-tool `defaultModel` in `ai-tools.json` (`PUT /api/ai-tools/:id {defaultModel}`) that `LaneConsolePane` starts each run on, the run form's picker fetching that list (`RUN_MODEL_CHOICES` only as Claude's offline fallback, `RUN_MODEL_CHOICES_CODEX` deleted) + AUTOMATIC pricing that ADDS and FOLLOWS: `pricing-sync.js` fetches LiteLLM at most daily, inserts every current-generation Claude/OpenAI (gpt ≥ 5)/Gemini (≥ 3) chat model before it is used (`isCurrent`, `MIN_GEN`), keeps its own rows (`model_pricing.source = 'sync'`, the **Auto** badge in `PricingSettings`) at the published rate, adopts a pre-column row only while it equals the list, and never touches a `source = 'user'` row (any `PUT /api/pricing`) or a deleted pattern (`model_pricing_dismissed`, emptied by a reset for its scope) + a plugin build installed BESIDE the running one (a terminal `claude plugin update` starts nothing) being reported: `installed_not_running` + `update_available` in `pluginStatus` (`server/lib/update-check.js`, via `supersededBy`, no network), the sidebar's ** installed, not running**, the update modal keyed on it, `plugin-update-helper.js` handing over to the recorded install first; and a plugin install whose release lookup failed reading **Could not check for updates** instead of **Up to date** (`Sidebar.tsx`) + a markdown PREVIEW (`client/src/components/MarkdownPreview.tsx`: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) behind a **Preview**/**Raw** toggle in the config `FileViewer` and the Changes panel's whole-file view + AI Tools moving from a Settings → Dashboard section to a MODAL behind a button at the Settings tab bar's right edge (`client/src/components/settings/AiToolsModal.tsx`, `actions` on `PageTabs`, opened by `/settings#ai-tools` too); its rows an enable switch + Default badge/Set default + **Update** for EVERY tool whose catalog names an updater (no version lookup on open — `api.aiTools.versions` left the client, `GET /api/ai-tools/versions` stays), the not-installed tools folded behind a disclosure, a finished job's log folded to one line; `codex` updating through its own `codex update` (standalone or npm) and `openclaw`/`hermes` gaining `update --yes`; update-all running every installed updater, skipping an npm one only when npm does not manage the binary, with no `npm view`; the shared `client/src/components/AiToolIcon.tsx`; the Add-lane tool picker being ONE button showing the chosen tool that opens the enabled tools as buttons (no Default/Other… select); and the Claude Code / Codex **AgentHub** sub-tab folded into their **Overview** (home editor, hooks, pricing under the overview card; the overview's path list drops CLAUDE_HOME/CODEX_HOME; `HomeDirSetting` shows the saved value only while the field differs) + ANTIGRAVITY PARITY (#40 part D: `server/lib/antigravity-usage.js` reads agy's own per-conversation SQLite files `~/.gemini/antigravity-cli/conversations/.db` read-only every `DASHBOARD_ANTIGRAVITY_POLL_MS` — tokens from `gen_metadata`'s protobuf `ModelUsageStats`, cwd from `trajectory_metadata_blob`, name/start from `brain//…/transcript_full.jsonl` — into `tool = 'antigravity'` sessions, mtime per file in the new `antigravity_conversations` table, and sets idle the lane of a session gone quiet that the hooks left running; `server/lib/antigravity-hooks.js` installs on a click the named hook `agenthub` (PreInvocation, PostToolUse, Stop — NEVER PreToolUse) into the global `~/.gemini/config/hooks.json` through `~/.gemini/config/agenthub-hook.sh`, `GET/POST/DELETE /api/settings/antigravity-hooks`; `routes/hooks.js` `antigravityHookInput` maps a CLI hook — `conversationId` → session, `workspacePaths[0]` → cwd, first PreInvocation → UserPromptSubmit — and ignores the IDE's; `server/lib/antigravity-home.js` (`AGENTHUB_GEMINI_DIR`); Gemini families in `pricing-sync.js`; the new Settings → **Antigravity** tab `client/src/pages/AntigravityConfig.tsx` with `PricingSettings vendor="google"` and `AntigravityHooksSettings.tsx`. Its review fixed: an empty `-wal` (our own read-only open leaves one) is not activity; activity counts the session's newest hook event, with a 10-minute window mid-turn; an agy Stop carrying an error lands as `error`, one with `fullyIdle: false` is dropped, and the first PreInvocation after a Stop is a prompt; `agyCliDirs()` for a symlinked `~/.gemini`; both tools' hook installers refuse to overwrite or remove a forwarder that is not theirs (`EFOREIGN`) and keep hooks.json's mode. The C2 review also fixed: Codex hooks matched by exact command, one hook at a time, so a user's hook in our group or with a similar name survives; `sameOriginGuard` on the codex-hooks writes; `codexHomes()` in `codex-home.js` for a symlinked CODEX_HOME; a missing main agent created for a poll-imported Codex session; `refreshScript` leaving a foreign file alone; Refresh on the card. The whole-change review also fixed: settings load real env > repo `.env` > the settings file (located after `.env` loads, so `AGENTHUB_SETTINGS_FILE` may live there), a `.env` key `env_owned` too, `writeSetting` replacing every earlier line for its key and writing through a symlink, `validateHomeDir` comparing device+inode with the home and each ancestor, `sameOriginGuard` on both home PUTs, a claude-home PUT answering `routers.missing` (`lanesWithMissingRouter` in `router-apply.js`), CLAUDE_HOME-derived paths read per call in the hook status, `install-hooks.js` and `import-history.js`; `reset-pricing` `{scope: "claude"}` from the Claude tab; `GET /api/sessions?tool=` before the limit; `clear-data` emptying `codex_rollouts` and `antigravity_conversations`, plus `idx_codex_rollouts_thread`; Compaction tagging for claude only, a Codex sub-agent's folded tool event (`_codexChild`) moving no session, agent or lane status, and hooks owning a Codex session's status only while current (`hooksCurrent`: newest event ≥ newest journal write − 2 min, also in `settleActive`); `scriptIsOurs` treating other content, an unreadable file or a directory as foreign, a symlinked hooks.json emptied through the link; agy's quiet window 10 min for any session with hook events (2 min without), a settle completing its agents and clearing waiting flags, a PreInvocation a prompt also with no prior event, a non-active session or a last event >2 min old, a `terminationReason` matching /error|fail/ an error Stop, a file that is not agy's recorded with `session_id ''` and retried on change or at start (anything else next pass), and the session id taken from the file name) + CODEX HOOKS (#40 part C2: `server/lib/codex-hooks.js` installs — only on a click in Settings → Codex → AgentHub → **Codex hooks**, `GET/POST/DELETE /api/settings/codex-hooks` — one group per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd) into `$CODEX_HOME/hooks.json`, all through the stable forwarder `$CODEX_HOME/agenthub-hook.sh` (content rewritten each start by `refreshScript`, command never changes, because Codex trust is keyed on the definition); `scripts/hook-handler.js codex` posts `tool: "codex"`; `routes/hooks.js` creates `tool = 'codex'` sessions with no `transcript_path`, skips every Claude-transcript read for them, maps `PermissionRequest` → `Notification` and `Interrupt` → `Stop`, folds a sub-agent thread's hooks into the root session (`threadOf` in `codex-usage.js`), and `touchLaneFromHook` resolves the lane by (cwd, tool) with detection for claude only; a Codex session with events keeps its hook-given status in `codex-usage.js`, and `codex-ingest.js` skips a lane whose hooks are current. Also from the C1 review: one `codex_rollouts` row per thread, the resume picker lists Claude sessions only, a failed price fetch waits 15 min, a fork skipped until its first own total, 16 MB per read, usage rewritten whole, root-thread cwd/start/model, the startup cleanup and a lane purge scoped by tool, and the codex lane mirror deciding once per lane with a second of future-mtime slack) + CODEX SESSIONS + TOKEN COST (#40 part C1: `server/lib/codex-usage.js` reads every Codex rollout journal into `sessions` (new column `tool`, `'claude'` default, `'codex'` here) and `token_usage` — offset + parse state per file in the new `codex_rollouts` table, a fork's copied parent history skipped by total vector, sub-agent threads summed into the root session, input = input − cached, exact totals via `setTokenUsage`, no `transcript_path`; `startCodexUsagePoll` on the `DASHBOARD_CODEX_POLL_MS` beat; `allRolloutFiles()` exported from `codex-ingest.js`; `pricing-sync.js` also prices `gpt-…`/`o…`/`codex-…` from LiteLLM's `openai` entries and `reset-pricing` syncs right after reseeding; `AGENTHUB_PRICING_SYNC=0` in `scripts/test-server.js`; `client/src/components/settings/PricingSettings.tsx` = the pricing table with a `vendor` filter + **Show all models**, in both tools' AgentHub sub-tabs; `SessionToolBadge.tsx`; the Workspace attach/resume list offering `tool = 'claude'` rows only) + per-tool SETTINGS (#40 part B: Model Pricing, Hook Configuration and CLAUDE_HOME moved from Settings → Dashboard to the Claude Code tab's **AgentHub** sub-tab, `client/src/components/settings/ClaudeAgentHubSettings.tsx` — a tool's own settings go in that tool's tab; `setClaudeHome`/`setCodexHome` refuse any dir that is or contains the OS home (`EBADHOME`, `validateHomeDir` in `server/lib/settings-env.js`) and persist to AgentHub's settings file `~/.config/agenthub/settings.env` (`AGENTHUB_SETTINGS_FILE`; real env > repo `.env` > that file, loaded only when `server/index.js` is main — never the plugin cache's `.env`, which an update wipes), a first CLAUDE_HOME change pinning `DASHBOARD_DATA_DIR` so the DB stays put; CODEX_HOME editable in Codex → AgentHub (`/api/settings/codex-home`, `HomeDirSetting.tsx`, `reapplyCodexLayers` re-writing codex lanes' router layers into the new home); a codex pane is started with the dashboard's `CODEX_HOME` through tmux `-e`, in `server/lib/pty-run.js`) + 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 at the time, data from Orca, MIT, trimmed since to 10 — 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; **AI Tools** (`client/src/components/settings/AiToolsSettings.tsx`, now a modal opened from the Settings tab bar's right edge, `AiToolsModal.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 — since followed daily for the rows it owns, see the entry above — 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 `' + ); + const created = await fetch(`${base}/api/lanes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: LANE, title: "raw" }), + }).then((r) => r.json()); + const id = created.lane.id; + const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`); + assert.equal(png.status, 200); + assert.equal(png.headers.get("content-type"), "image/png"); + assert.deepEqual( + Buffer.from(await png.arrayBuffer()), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]) + ); + const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`); + assert.equal(svg.headers.get("content-type"), "image/svg+xml"); + assert.match(svg.headers.get("content-security-policy"), /sandbox/); + assert.equal(svg.headers.get("x-content-type-options"), "nosniff"); + const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`); + assert.equal(txt.status, 400); + assert.equal((await txt.json()).error.code, "ENOTIMAGE"); + await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" }); + } finally { + server.close(); + } + }); +}); + describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => { it("answers 403 for the router's settings file", async () => { process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db"); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index 4cfa5c18..d1327e49 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -129,6 +129,23 @@ describe("/api/lanes", () => { assert.equal(typeof r.body.counts.needs_you, "number"); }); + it("serves the same routes under /api/workspaces, the UI's word", async () => { + const lanes = await request("GET", "/api/lanes"); + const list = await request("GET", "/api/workspaces"); + assert.equal(list.status, 200); + assert.deepEqual( + list.body.lanes.map((l) => l.id), + lanes.body.lanes.map((l) => l.id) + ); + const one = await request("GET", `/api/workspaces/${laneId}`); + assert.equal(one.status, 200); + assert.equal(one.body.lane.id, laneId); + // Mounted through the sources router, which sits before the lanes router. + const sources = await request("GET", `/api/workspaces/${laneId}/sources`); + assert.equal(sources.status, 200); + assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body); + }); + it("exposes pipeline templates", async () => { const r = await request("GET", "/api/lanes/pipelines"); assert.equal(r.status, 200); @@ -220,6 +237,42 @@ describe("/api/lanes", () => { assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200); }); + it("opens the native picker only for a local, unproxied, same-origin browser", async () => { + const nd = require("../lib/native-dialog"); + const desktop = require("../lib/desktop-env"); + const calls = []; + nd.__setExecImpl(async (cmd) => { + calls.push(cmd); + return { code: 0, stdout: "/tmp/picked\n", stderr: "" }; + }); + desktop.__setExecImpl(() => "DISPLAY=:0\n"); + try { + for (const headers of [ + { Origin: "https://attacker.example" }, + { "X-Forwarded-For": "203.0.113.9" }, + { Forwarded: "for=203.0.113.9" }, + ]) { + const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers); + assert.equal(r.status, 403, JSON.stringify(headers)); + } + assert.deepEqual(calls, []); + if (process.platform === "linux") { + const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }); + assert.equal(r.status, 200); + assert.equal(r.body.path, "/tmp/picked"); + nd.__setExecImpl(async () => ({ missing: true })); + const none = await request("POST", "/api/lanes/pick-path", { kind: "file" }); + assert.equal(none.status, 501); + assert.equal(none.body.error.code, "ENODIALOG"); + } + } finally { + nd.__setExecImpl(null); + desktop.__setExecImpl(() => { + throw new Error("no systemd user manager in this test"); + }); + } + }); + it("broadcasts lane_update on create", async () => { const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`; const ws = new WebSocket(wsUrl); @@ -425,8 +478,13 @@ describe("lane start input", () => { assert.equal(r.body.run_id, `agenthub-lane-${id}-1`); assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`); assert.equal(created[created.indexOf("-c") + 1], cwd); - // Nothing after `--`: tmux runs the user's own default shell. - assert.equal(created[created.length - 1], "--"); + // After `--` only the fixed size-wait wrapper that execs the user's own + // default shell (issue #55) — nothing from the request. + const argv = created.slice(created.indexOf("--") + 1); + assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]); + assert.equal(argv.length, 3); + assert.match(argv[2], /exec "\$SHELL" -l$/); + assert.ok(!created.includes("ignored")); const lane = (await request("GET", `/api/lanes/${id}`)).body.lane; assert.equal(lane.run_id, null); assert.notEqual(lane.status, "running"); @@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => { assert.equal(r.body.run_id, `agenthub-lane-${id}-2`); }); - it("answers ERUNLIVE when every slot is busy", async () => { - live.add(`agenthub-lane-${id}-3`); + it("answers ERUNLIVE when all ten slots are busy", async () => { + for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`); + // Slot 9 is the tenth and last. + const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true }); + assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`); + live.add(`agenthub-lane-${id}-9`); const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" }); assert.equal(r.status, 409); assert.equal(r.body.error.code, "ERUNLIVE"); - live.delete(`agenthub-lane-${id}-3`); - live.delete(`agenthub-lane-${id}-2`); + for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`); }); it("refuses to resume a session a live pane already holds, naming that pane", async () => { diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index a94315fb..77446af2 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -193,6 +193,24 @@ after(() => { } }); +describe("agenthub workspaces", () => { + it("is the lanes command under the UI's word", async () => { + const [lanes, workspaces, workspace] = await Promise.all([ + cli(["lanes"], LANE_DIR), + cli(["workspaces"], LANE_DIR), + cli(["workspace"], LANE_DIR), + ]); + assert.equal(workspaces.status, 0, workspaces.stderr); + assert.match(workspaces.stdout, /CLI lane/); + assert.equal(workspaces.stdout, lanes.stdout); + assert.equal(workspace.stdout, lanes.stdout); + // The old name still works but says so — on stderr, never in stdout. + assert.match(lanes.stderr, /old name.*agenthub workspace/); + assert.doesNotMatch(workspaces.stderr, /old name/); + assert.doesNotMatch(lanes.stdout, /old name/); + }); +}); + describe("agenthub stage", () => { it("reports a stage for the lane owning the current directory", async () => { const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR); @@ -280,6 +298,17 @@ describe("agenthub stage in a directory shared by two lanes", () => { assert.notEqual(await stageOf(codexLane.id), "implement"); }); + it("--workspace names the lane like --lane", async () => { + const r = await cli( + ["stage", "review", "--workspace", String(codexLane.id)], + DUAL_DIR, + NO_HARNESS + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(codexLane.id), "review"); + assert.notEqual(await stageOf(claudeLane.id), "review"); + }); + it("fails loudly, naming both candidates, when nothing identifies the tool", async () => { const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)]; const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS); @@ -686,7 +715,7 @@ describe("agenthub lanes node", () => { assert.notEqual(r.status, 0); assert.match(r.stdout, /\bintake\b/); assert.match(r.stdout, /\bdone\b/); - assert.match(r.stderr, /usage: agenthub lanes node/); + assert.match(r.stderr, /usage: agenthub workspace node/); }); it("--print shows a slash-command node as a claude prompt, extra args appended", async () => { diff --git a/server/__tests__/native-dialog.test.js b/server/__tests__/native-dialog.test.js new file mode 100644 index 00000000..7bd43bb8 --- /dev/null +++ b/server/__tests__/native-dialog.test.js @@ -0,0 +1,105 @@ +/** + * @file native-dialog.test.js + * @description Unit tests for the server-side native folder/file picker: the + * argv each platform gets, the fallback from zenity to kdialog, Cancel versus a + * chosen path, and the "no dialog program" answer the client falls back on. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const os = require("node:os"); +const nd = require("../lib/native-dialog"); +const desktop = require("../lib/desktop-env"); + +const linux = process.platform === "linux"; + +describe("native-dialog", () => { + afterEach(() => nd.__setExecImpl(null)); + + it("starts in the given folder, a file's folder, or home", () => { + assert.equal(nd.startDir(os.tmpdir()), os.tmpdir()); + assert.equal(nd.startDir("/definitely/not/here/x"), os.homedir()); + assert.equal(nd.startDir("~"), os.homedir()); + assert.equal(nd.startDir(undefined), os.homedir()); + }); + + it("builds zenity then kdialog on Linux, the request's folder one argv element", () => { + const [[z, zargs], [k, kargs]] = nd.dialogCommands("dir", "/tmp/a b", "linux"); + assert.equal(z, "zenity"); + assert.deepEqual(zargs, [ + "--file-selection", + "--title=Select a folder", + "--filename=/tmp/a b/", + "--directory", + ]); + assert.equal(k, "kdialog"); + assert.deepEqual(kargs.slice(0, 2), ["--getexistingdirectory", "/tmp/a b"]); + assert.ok(!nd.dialogCommands("file", "/tmp", "linux")[0][1].includes("--directory")); + }); + + it("passes the folder to osascript as an argument, never inside the script", () => { + const [[cmd, args]] = nd.dialogCommands("file", '/x"; do shell script "id', "darwin"); + assert.equal(cmd, "osascript"); + assert.equal(args.at(-1), '/x"; do shell script "id'); + assert.ok(!args.slice(0, -2).join(" ").includes("shell script")); + }); + + it( + "answers the chosen path, null on Cancel, and falls through a missing program", + { + skip: !linux, + }, + async () => { + desktop.__setExecImpl(() => "DISPLAY=:0\n"); + const tried = []; + nd.__setExecImpl(async (cmd) => { + tried.push(cmd); + return cmd === "zenity" ? { missing: true } : { code: 0, stdout: "/home/u/proj\n" }; + }); + assert.equal(await nd.pickPath("dir"), "/home/u/proj"); + assert.deepEqual(tried, ["zenity", "kdialog"]); + + nd.__setExecImpl(async () => ({ code: 1, stdout: "" })); + assert.equal(await nd.pickPath("file"), null); + + nd.__setExecImpl(async () => ({ missing: true })); + await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" }); + } + ); + + it( + "answers ENODIALOG, not a Cancel, when the program could not reach the desktop", + { skip: !linux }, + async () => { + desktop.__setExecImpl(() => "DISPLAY=:0\n"); + nd.__setExecImpl(async () => ({ + code: 1, + stdout: "", + stderr: "(zenity:1): Gtk-WARNING **: cannot open display: :0\n", + })); + await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" }); + } + ); + + it("passes the abort signal through to the dialog program", { skip: !linux }, async () => { + desktop.__setExecImpl(() => "DISPLAY=:0\n"); + const ac = new AbortController(); + let seen; + nd.__setExecImpl(async (cmd, args, env, signal) => { + seen = signal; + return { code: 1, stdout: "", stderr: "" }; + }); + assert.equal(await nd.pickPath("dir", undefined, ac.signal), null); + assert.equal(seen, ac.signal); + }); + + it("tells a Cancel from a failure per program", () => { + assert.equal(nd.isCancel("zenity", ""), true); + assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true); + assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false); + assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true); + assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false); + assert.equal(nd.isCancel("powershell.exe", ""), true); + assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false); + }); +}); diff --git a/server/__tests__/plugin-doctor-tmux.test.js b/server/__tests__/plugin-doctor-tmux.test.js index 1b91998e..97f6ea82 100644 --- a/server/__tests__/plugin-doctor-tmux.test.js +++ b/server/__tests__/plugin-doctor-tmux.test.js @@ -47,7 +47,7 @@ describe("plugin-doctor: tmux row", () => { const row = rowWithTmux(null); assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run"); - assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks"); + assert.match(row.detail, /workspace shell/, "the reader needs to know WHAT breaks"); assert.match(row.detail, /install/i, "and how to fix it"); }); diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js index e4705205..cc508107 100644 --- a/server/__tests__/pty-attach.test.js +++ b/server/__tests__/pty-attach.test.js @@ -56,7 +56,9 @@ describe("pty-attach", () => { it("accepts a well-formed runId", () => { assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42")); assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3")); - assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/); + assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-9")); + assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-10"), /EBADRUNID/); + assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/); }); it("turns tmux mouse mode on for the session it attaches to", () => { diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index 4583640e..e6f6d554 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -58,7 +58,49 @@ describe("pty-run", () => { assert.ok(newSessionCall.includes("opus")); }); - it("spawnRun names a split pane's session agenthub-lane--, and refuses a slot past 3", () => { + it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" }); + const call = calls.find((c) => c[0] === "new-session"); + const argv = call.slice(call.indexOf("--") + 1); + assert.equal(argv[0], "sh"); + assert.equal(argv[1], "-c"); + // Run the wrapper with a stand-in shell and a fake `tmux` that reports no + // client until a flag file appears: the shell must wait for the attach, + // then start at once rather than at the 2 s cap. + const { spawn } = require("node:child_process"); + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-")); + const flag = path.join(bin, "attached"); + fs.writeFileSync( + path.join(bin, "tmux"), + `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, + { mode: 0o755 } + ); + try { + const t0 = Date.now(); + const child = spawn("sh", ["-c", argv[2]], { + env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` }, + }); + let out = ""; + let startedAt = 0; + child.stdout.on("data", (d) => { + out += d; + startedAt ||= Date.now() - t0; + }); + setTimeout(() => fs.writeFileSync(flag, ""), 400); + await new Promise((r) => child.on("exit", r)); + assert.equal(out.trim(), "-l"); + assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`); + } finally { + fs.rmSync(bin, { recursive: true, force: true }); + } + }); + + it("spawnRun names a split pane's session agenthub-lane--, and refuses a slot past 9 (ten per workspace)", () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("no such session"); @@ -72,8 +114,10 @@ describe("pty-run", () => { assert.equal(handle.laneId, 42); assert.equal(handle.slot, 2); assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42); + assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9"); + assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42); assert.throws( - () => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }), + () => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }), (err) => err.code === "EBADSLOT" ); }); diff --git a/server/index.js b/server/index.js index bdd6e013..b0877790 100644 --- a/server/index.js +++ b/server/index.js @@ -130,9 +130,13 @@ function createApp() { app.use("/api/git-credentials", gitCredentialsRouter); app.use("/api/ai-tools", aiToolsRouter); app.use("/api/run", runRouter); - // Before the lanes router, whose `/:id/:action` would otherwise see these. - app.use("/api/lanes", laneSourcesRouter); - app.use("/api/lanes", lanesRouter); + // `/api/workspaces` is the same router under the UI's word; `/api/lanes` + // stays for every client and script that already names it (whether it ever + // goes is step 4c of docs/plans/workspace-rename/README.md, default keep). + // Sources first: the lanes router's `/:id/:action` would otherwise see them. + const LANE_MOUNTS = ["/api/lanes", "/api/workspaces"]; + app.use(LANE_MOUNTS, laneSourcesRouter); + app.use(LANE_MOUNTS, lanesRouter); app.use("/api/projects", projectsRouter); app.use("/api/locks", locksRouter); app.use("/api/pets", petsRouter); diff --git a/server/lib/dashboard-runs.js b/server/lib/dashboard-runs.js index 9d4ed72c..cfccaf42 100644 --- a/server/lib/dashboard-runs.js +++ b/server/lib/dashboard-runs.js @@ -68,6 +68,14 @@ const listByLaneStmt = db.prepare(` LIMIT @limit `); +const listByLaneToolStmt = db.prepare(` + SELECT ${RUN_COLUMNS} + FROM dashboard_runs + WHERE lane_id = @laneId AND tool = @tool + ORDER BY started_at DESC + LIMIT @limit +`); + const getStmt = db.prepare(` SELECT ${RUN_COLUMNS} FROM dashboard_runs WHERE id = @id @@ -144,10 +152,12 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) { } } -/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */ -function listRuns({ limit = 50, laneId = null } = {}) { +/** @param {{limit?: number, laneId?: number|null, tool?: string|null}} [opts] + * laneId narrows to one lane's runs, and tool (with a laneId) to one tool's. */ +function listRuns({ limit = 50, laneId = null, tool = null } = {}) { try { const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50))); + if (laneId != null && tool) return listByLaneToolStmt.all({ limit: safeLimit, laneId, tool }); if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId }); return listStmt.all({ limit: safeLimit }); } catch { diff --git a/server/lib/git-diff.js b/server/lib/git-diff.js index e8c7bfbd..e91cef81 100644 --- a/server/lib/git-diff.js +++ b/server/lib/git-diff.js @@ -723,6 +723,60 @@ async function readFileAt(dir, requested, rev = "work") { return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) }; } +/** The image types the viewer shows, by extension. Raster and SVG only. */ +const IMAGE_TYPES = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + avif: "image/avif", + bmp: "image/bmp", + ico: "image/x-icon", + svg: "image/svg+xml", +}; +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; + +/** + * One image's bytes at a revision, for the viewer's ``: `readFileAt`'s + * guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus + * an extension allowlist that also picks the Content-Type and a size cap. + */ +async function readImageAt(dir, requested, rev = "work") { + const file = lanePath(requested); + const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()]; + if (!type) throw badRequest("ENOTIMAGE", "not an image file"); + if (rev !== "work" && !SAFE_REV.test(rev)) { + throw badRequest("EBADREV", "rev is not a valid revision"); + } + if (await isSecretOnDisk(dir, file)) { + throw badRequest("ESECRET", "that file holds secrets and is not readable here"); + } + const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB"); + if (rev === "work") { + const abs = await resolveInLane(dir, file); + let stat; + try { + stat = await fs.promises.stat(abs); + } catch { + throw badRequest("ENOFILE", "no such file in the working tree"); + } + if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree"); + if (stat.size > MAX_IMAGE_BYTES) throw tooBig(); + return { type, buf: await fs.promises.readFile(abs) }; + } + try { + const { stdout } = await git(dir, ["show", `${rev}:./${file}`], { + encoding: "buffer", + maxBuffer: MAX_IMAGE_BYTES, + }); + return { type, buf: stdout }; + } catch (err) { + if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig(); + throw badRequest("ENOFILE", "no such file at that revision"); + } +} + /** The version stamp of a file's bytes, for a stale-save check. */ function contentSha(buf) { return crypto.createHash("sha1").update(buf).digest("hex"); @@ -983,6 +1037,7 @@ module.exports = { parsePorcelainZ, parseUnifiedDiff, readFileAt, + readImageAt, resolveBaseRef, DEFAULT_CONTEXT, MAX_CONTEXT, diff --git a/server/lib/native-dialog.js b/server/lib/native-dialog.js new file mode 100644 index 00000000..0673f280 --- /dev/null +++ b/server/lib/native-dialog.js @@ -0,0 +1,180 @@ +/** + * @file native-dialog.js + * @description Opens the operating system's own folder/file picker ON THE + * SERVER'S DESKTOP and returns the absolute path the user chose. A browser's + * native picker never hands a page an absolute path, and the dashboard is + * local-first, so the server asks the desktop itself: zenity or kdialog on + * Linux (with the graphical session's variables from `desktop-env.js`, which a + * systemd user service lacks), `osascript` on macOS, PowerShell's WinForms + * dialogs on Windows. Runs with `execFile` (never a shell, never sync — a + * dialog waits on a human for minutes), one dialog at a time. Nothing from the + * request reaches argv except the starting folder, resolved to an absolute + * path first. macOS and Windows are code-only, untested on those systems. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFile } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { desktopEnv } = require("./desktop-env"); + +const TITLES = { dir: "Select a folder", file: "Select a file" }; +const TIMEOUT_MS = 10 * 60 * 1000; + +const defaultExec = (cmd, args, env, signal) => + new Promise((resolve) => { + execFile( + cmd, + args, + { env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal }, + (err, stdout, stderr) => + resolve({ + missing: err?.code === "ENOENT", + code: err ? (typeof err.code === "number" ? err.code : -1) : 0, + stdout: String(stdout || ""), + stderr: String(stderr || ""), + }) + ); + }); + +let execImpl = defaultExec; + +/** Test seam — same pattern as desktop-env.js; `null` restores the real one. */ +function __setExecImpl(fn) { + execImpl = fn || defaultExec; +} + +/** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */ +function startDir(start) { + if (typeof start === "string" && start.trim()) { + const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir()); + const abs = path.resolve(raw); + for (const p of [abs, path.dirname(abs)]) { + try { + if (fs.statSync(p).isDirectory()) return p; + } catch { + // missing — try the parent, then home + } + } + } + return os.homedir(); +} + +/** The candidate dialog commands for a platform, in order of preference. */ +function dialogCommands(kind, dir, platform = process.platform) { + const title = TITLES[kind]; + if (platform === "darwin") { + const what = kind === "dir" ? "folder" : "file"; + return [ + [ + "osascript", + [ + "-e", + "on run argv", + "-e", + `POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`, + "-e", + "end run", + title, + dir, + ], + ], + ]; + } + if (platform === "win32") { + const dialog = + kind === "dir" + ? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE" + : "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE"; + const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName"; + return [ + [ + "powershell.exe", + [ + "-NoProfile", + "-STA", + "-Command", + `[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`, + ], + ], + ]; + } + const sep = dir.endsWith("/") ? "" : "/"; + return [ + [ + "zenity", + [ + "--file-selection", + `--title=${title}`, + `--filename=${dir}${sep}`, + ...(kind === "dir" ? ["--directory"] : []), + ], + ], + [ + "kdialog", + [kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title], + ], + ]; +} + +// A dialog that could not reach the desktop exits 1 too — a stale DISPLAY +// handed over by `systemctl --user show-environment` after a logout, say. +const NO_DISPLAY_RE = + /cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i; + +/** + * Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog + * may print GTK/Qt warnings on a normal Cancel, so only a display failure + * counts against it; osascript says Cancel as error -128 and nothing else. + */ +function isCancel(cmd, stderr = "") { + if (cmd === "osascript") return /-128/.test(stderr); + if (cmd === "powershell.exe") return !stderr.trim(); + return !NO_DISPLAY_RE.test(stderr); +} + +function fail(code, message) { + return Object.assign(new Error(message), { code }); +} + +let busy = false; + +/** + * Show the native picker and wait for the user. + * @param {"dir"|"file"} kind + * @param {string} [start] Folder (or file) to open the dialog at. + * @param {AbortSignal} [signal] Closes the dialog — the browser went away. + * @returns {Promise} The chosen absolute path, or null when cancelled. + * @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`. + */ +async function pickPath(kind, start, signal) { + if (busy) throw fail("EBUSY", "a picker is already open on the desktop"); + const env = { ...process.env, ...desktopEnv() }; + if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) { + throw fail("ENODIALOG", "no graphical desktop session to show a picker on"); + } + const dir = startDir(start); + env.AGENTHUB_PICK_START = dir; + env.AGENTHUB_PICK_TITLE = TITLES[kind]; + busy = true; + try { + for (const [cmd, args] of dialogCommands(kind, dir)) { + const r = await execImpl(cmd, args, env, signal); + if (r.missing) continue; + if (r.code === 1) { + if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel + throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`); + } + const out = r.stdout.replace(/[\r\n]+$/, ""); + if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`); + // osascript ends a folder with "/"; "/" itself stays. + return out.replace(/\/+$/, "") || "/"; + } + throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)"); + } finally { + busy = false; + } +} + +module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl }; diff --git a/server/lib/pets.js b/server/lib/pets.js index 29036dd3..51b83e25 100644 --- a/server/lib/pets.js +++ b/server/lib/pets.js @@ -32,7 +32,6 @@ const FEATURED_HOST = "https://codex-pets.net"; /** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */ const FEATURED = [ { id: "goku", displayName: "Goku", by: "hackminit" }, - { id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" }, { id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" }, { id: "11", displayName: "11", by: "ct" }, ]; diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js index cfcf929f..0f35bcb7 100644 --- a/server/lib/pty-attach.js +++ b/server/lib/pty-attach.js @@ -14,10 +14,10 @@ const fs = require("fs"); const tmux = require("./tmux"); -const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/; +const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-9])?$/; /** - * Reject anything that isn't exactly `agenthub-lane-[-<1-3>]` before it can ever + * Reject anything that isn't exactly `agenthub-lane-[-<1-9>]` before it can ever * reach a tmux/PTY command — the trust boundary for this WS path, since a * validated runId is the only thing standing between an authenticated WS * client and naming an arbitrary session on the host. diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 66f97543..ae58f664 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -22,8 +22,8 @@ * Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on * the tool parameter. * - * A lane can hold up to four sessions, one per Workspace tab: slot 0 is - * `agenthub-lane-` (the lane's own `run_id`), slots 1–3 are + * A lane can hold up to ten sessions (`MAX_PANE_SLOT`): slot 0 is + * `agenthub-lane-` (the lane's own `run_id`), slots 1–9 are * `agenthub-lane--`. A slot holds an AI tool (`spawnRun`) or a * plain shell (`spawnShell`, a Terminal tab). * @@ -43,9 +43,10 @@ try { /* db-less environment, skip persistence */ } -const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/; -/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */ -const MAX_PANE_SLOT = 3; +const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/; +/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the + * Workspace groups them into tabs of up to four split panes each). */ +const MAX_PANE_SLOT = 9; const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); // `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`): // it asks only for what its classifier reads as risky. `default` is kept for @@ -529,7 +530,18 @@ function spawnShell({ laneId, slot = 0, cwd }) { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...desktopEnv(), }; - tmux.newSession({ name: id, cwd, argv: [], env }); + // The session is born 80×24 and only takes the browser's size when the pane + // attaches. A shell started at once printed its prompt at 80 columns (a long + // cwd wrapped), and readline's redraw after the resize left the cursor on top + // of the prompt (issue #55). So wait until a client is attached — 2 s at + // most — then exec the login shell tmux would have run (tmux sets SHELL in the + // pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on + // resizes the pane before any client is there. A tmux that cannot answer + // ends the wait at once, i.e. the old behaviour. + const waitForSize = + "i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" + + ' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l'; + tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env }); dashboardRuns?.recordRun({ id, sessionId: null, diff --git a/server/lib/worktree.js b/server/lib/worktree.js index 6db21a40..3efb870f 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -33,7 +33,14 @@ const STATUS_FILE_CAP = 20; async function git( cwd, args, - { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {} + { + timeout = 0, + maxBuffer = 8 * 1024 * 1024, + literal = false, + env: extraEnv = {}, + // "buffer" for a binary read (an image at a revision). + encoding = "utf8", + } = {} ) { // Build a clean environment: copy process.env but scrub git hook variables // that could point to the outer repo's git directory or index. @@ -79,6 +86,7 @@ async function git( // passes one so an unreachable remote fails instead of pinning a request // open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case. timeout, + encoding, }); return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index 3bb02bb6..c617991d 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -12,7 +12,8 @@ const tags = [ { name: "Lanes", - description: "Durable parallel-work lanes and dashboard-managed git worktrees", + description: + "Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.", }, ]; @@ -111,6 +112,45 @@ const schemas = { }; const paths = { + "/api/lanes/pick-path": { + post: { + tags: ["Lanes"], + summary: "Open the OS's own folder/file picker on the server's desktop", + description: + "Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER's desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.", + operationId: "pickLanePath", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + kind: { type: "string", enum: ["dir", "file"], default: "dir" }, + start: { type: "string", description: "Folder (or file) to open the dialog at." }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "`{path}` — the chosen absolute path — or `{cancelled: true}`.", + content: { + "application/json": { + schema: { + type: "object", + properties: { path: { type: "string" }, cancelled: { type: "boolean" } }, + }, + }, + }, + }, + 403: { description: "EBADORIGIN (not same-origin) or EREMOTE (not a local browser)." }, + 409: { description: "EBUSY — a picker is already open." }, + 500: { description: "EDIALOG — the dialog program failed." }, + 501: { description: "ENODIALOG — no desktop session or no dialog program." }, + }, + }, + }, "/api/lanes/ensure": { post: { tags: ["Lanes"], @@ -551,6 +591,26 @@ const paths = { }, }, }, + "/api/lanes/{id}/git/raw": { + get: { + tags: ["Lanes"], + summary: "One image file's bytes at a revision", + description: + "For the viewer's . Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.", + operationId: "readLaneImage", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "integer" } }, + { name: "path", in: "query", required: true, schema: { type: "string" } }, + { name: "rev", in: "query", schema: { type: "string", default: "work" } }, + ], + responses: { + 200: { description: "The image bytes, typed by extension." }, + 400: { description: "EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG." }, + 403: { description: "ESECRET." }, + 404: { description: "Lane not found." }, + }, + }, + }, "/api/lanes/{id}/git/ls": { get: { tags: ["Lanes"], diff --git a/server/openapi-extra/override-pricing-alerts.js b/server/openapi-extra/override-pricing-alerts.js index c6c163ed..dcb249aa 100644 --- a/server/openapi-extra/override-pricing-alerts.js +++ b/server/openapi-extra/override-pricing-alerts.js @@ -335,6 +335,27 @@ module.exports = { "for all sources. Narrows the aggregate cost to the given origins.", example: "local", }, + { + name: "lane", + in: "query", + required: false, + schema: { type: "integer" }, + description: + "Only the sessions this workspace owns — the same rule as " + + "GET /api/sessions?lane=. 400 INVALID_INPUT when not an id.", + example: 5, + }, + { + name: "date", + in: "query", + required: false, + schema: { type: "string", format: "date" }, + description: + "Only that local day (YYYY-MM-DD, in `tz_offset`'s timezone): the " + + "total and breakdown count the tokens spent that day. 400 " + + "INVALID_INPUT for any other shape.", + example: "2026-10-06", + }, ], responses: { 200: { diff --git a/server/openapi-extra/run.js b/server/openapi-extra/run.js index 66b47da8..972b69ee 100644 --- a/server/openapi-extra/run.js +++ b/server/openapi-extra/run.js @@ -812,6 +812,15 @@ const paths = { "Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.", example: 5, }, + { + name: "tool", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).", + example: "shell", + }, ], responses: { 200: { diff --git a/server/routes/hooks.js b/server/routes/hooks.js index b84fca2e..cd6e7624 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -1318,7 +1318,7 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput }; */ function bindRunSession(runId, sessionId, hookType, source, tool) { if (typeof sessionId !== "string" || !sessionId) return; - if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) { + if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-9])?$/.test(runId)) { // No pane named (a hook handler that predates AGENTHUB_RUN_ID): a run // that was started holding this very session id still owns it. const laneId = dashboardRuns.laneForSession(sessionId); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index b1eb1858..12bb06ea 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -30,6 +30,7 @@ const runs = require("../lib/pty-run"); const { resumeRouter } = require("../lib/router-apply"); const { sameOriginGuard } = require("./run"); const { writeLaneFile } = require("../lib/lane-file-write"); +const { pickPath } = require("../lib/native-dialog"); const { preflight } = require("../lib/lane-preflight"); const { LANES_ROOT, @@ -51,6 +52,7 @@ const { listTree, listTreeAll, readFileAt, + readImageAt, } = require("../lib/git-diff"); const { runGitAction } = require("../lib/git-ops"); const { withLaneLock } = require("../lib/lane-lock"); @@ -501,6 +503,40 @@ router.get("/browse", (req, res) => { res.json({ path: resolved, parent, entries }); }); +/** + * The OS's own folder/file picker, shown on the SERVER's desktop + * (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`. + * Only a client on this machine may ask — anywhere else the dialog would pop + * up on a screen nobody is looking at (`EREMOTE`); that client, and a server + * with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request + * a reverse proxy forwarded arrives from loopback too, so a forwarding header + * counts as remote. An SSH tunnel carries no such header and cannot be told + * apart — the dialog is closed when the browser's request goes away. + */ +router.post("/pick-path", sameOriginGuard, async (req, res) => { + const addr = req.socket.remoteAddress || ""; + const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded; + if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) { + return res.status(403).json({ + error: { code: "EREMOTE", message: "the native picker opens only for a local browser" }, + }); + } + const kind = req.body?.kind === "file" ? "file" : "dir"; + const ac = new AbortController(); + res.on("close", () => { + if (!res.writableFinished) ac.abort(); + }); + try { + const picked = await pickPath(kind, req.body?.start, ac.signal); + if (ac.signal.aborted) return; + res.json(picked ? { path: picked } : { cancelled: true }); + } catch (err) { + if (ac.signal.aborted) return; + const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500; + res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } }); + } +}); + router.get("/:id", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) @@ -901,6 +937,40 @@ router.get("/:id/git/file", async (req, res) => { } }); +/** + * One image's bytes (`?path=&rev=work|HEAD|`) for the viewer's ``: + * `readImageAt`'s guards (the file read's, plus an image-extension allowlist + * that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`, + * so an SVG opened straight from this URL runs no script on the dashboard's + * origin. + */ +router.get("/:id/git/raw", async (req, res) => { + const t = await gitTargetOr404(req, res); + if (!t) return; + try { + const { type, buf } = await readImageAt( + t.cwd, + typeof req.query.path === "string" ? req.query.path : "", + typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work" + ); + res.set({ + "Content-Type": type, + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }); + res.send(buf); + } catch (err) { + if (err.code === "ESECRET") { + return res.status(403).json({ error: { code: err.code, message: err.message } }); + } + if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } }); + } +}); + /** * Save one text file in the lane from the viewer's editor. Guards live in * lane-file-write.js (the read path's own, plus existing-textual-file-only and diff --git a/server/routes/pricing.js b/server/routes/pricing.js index 2d8c7172..a050e0a4 100644 --- a/server/routes/pricing.js +++ b/server/routes/pricing.js @@ -477,6 +477,12 @@ router.get("/cost", (req, res) => { .status(400) .json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } }); } + const date = req.query.date; + if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) { + return res + .status(400) + .json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } }); + } const scope = sourceColumnClause(parseSources(req), "s.source"); const params = [...scope.params]; const where = scope.clause ? [scope.clause] : []; @@ -489,7 +495,9 @@ router.get("/cost", (req, res) => { } const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : ""; - const dailyTokens = dailyTokenRows(tzModifier, whereClause, params); + let dailyTokens = dailyTokenRows(tzModifier, whereClause, params); + // `date=`: only that local day (the status bar's Today and its per-model list). + if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date); const rules = stmts.listPricing.all(); // Price the date-split rows so each day's usage bills at the rate effective on // that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after). diff --git a/server/routes/run.js b/server/routes/run.js index 872acbc9..68f40b82 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -84,7 +84,8 @@ router.get("/", (_req, res) => { * * Optional ?limit= caps results (default 50, max 500). The most recent * runs are first. Optional ?laneId= narrows to the runs started through one - * lane, which is what the Workspace page's per-lane history shows. + * lane, and ?tool= (with a laneId) to one tool's — the sidebar reads a + * workspace's closed Terminal tabs with `tool=shell`. */ // ── Drag & drop into the terminal ───────────────────────────────────────── // A local terminal answers a dropped image by typing its PATH, which is how @@ -149,6 +150,7 @@ router.get("/history", (req, res) => { const items = dr.listRuns({ limit: Number.isFinite(limit) ? limit : 50, laneId: Number.isFinite(laneId) ? laneId : null, + tool: typeof req.query.tool === "string" ? req.query.tool : null, }); const liveIds = new Set(runs.listRuns().map((h) => h.id)); res.json({ diff --git a/server/websocket.js b/server/websocket.js index 27d2f754..279e6aad 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -80,7 +80,7 @@ function initWebSocket(server) { } let ptyWss = null; -const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/; +const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/; /** * The attaching terminal's own size, off the attach URL's query. tmux resizes diff --git a/skills/debug-live-issue/SKILL.md b/skills/debug-live-issue/SKILL.md deleted file mode 100644 index c35743b9..00000000 --- a/skills/debug-live-issue/SKILL.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: debug-live-issue -description: Debug production-like issues in this repository with disciplined evidence gathering. Use when fixing failing workflows, regressions, flaky behavior, or data inconsistencies across hooks, API, DB, websocket, and UI. ---- - -# Debug Live Issue - -Use this workflow for incident-style debugging. - -## Steps -- Capture symptom, expected behavior, and reproducible path. -- Isolate subsystem first: hook ingestion, API route, DB state, websocket, or UI rendering. -- Reproduce with minimal surface area. -- Prove root cause before changing code. -- Apply minimal fix and re-verify. - -## Evidence standards -- Prefer direct logs, API responses, DB state checks, and deterministic repro steps. -- Avoid speculative fixes without root-cause evidence. -- If not fully reproducible, state uncertainty and strongest hypothesis. - -## References -- Investigation template: `references/investigation-template.md` diff --git a/skills/debug-live-issue/references/investigation-template.md b/skills/debug-live-issue/references/investigation-template.md deleted file mode 100644 index 89ee2c2f..00000000 --- a/skills/debug-live-issue/references/investigation-template.md +++ /dev/null @@ -1,32 +0,0 @@ -# Investigation Template - -## Problem statement -- Symptom: -- Expected behavior: -- First observed: -- Scope: - -## Reproduction -- Preconditions: -- Exact steps: -- Actual result: - -## Evidence -- Logs: -- API responses: -- Database observations: -- Websocket behavior: -- UI behavior: - -## Root cause -- Confirmed cause: -- Why it happens: - -## Fix -- Change summary: -- Why this fix is minimal and safe: - -## Verification -- Commands/tests run: -- Manual verification: -- Residual risk: diff --git a/skills/repo-onboarding/SKILL.md b/skills/repo-onboarding/SKILL.md deleted file mode 100644 index d4dc3f9b..00000000 --- a/skills/repo-onboarding/SKILL.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: repo-onboarding -description: Onboard quickly to this repository. Use when asked to understand architecture, locate ownership, choose the right module, or identify the correct commands and verification strategy before coding. ---- - -# Repo Onboarding - -Use this workflow when a task begins with discovery. - -## Steps -- Read `README.md` and `ARCHITECTURE.md` for system-level context. -- Identify target layer: - - `server/` for API, hooks, DB, websocket - - `client/` for UI and routing -- Select the smallest set of files required to answer the task. -- Confirm verification commands before implementation. - -## Verification defaults -- Backend: `npm run test:server` -- Frontend: `npm run test:client` - -## References -- Module map: `references/module-map.md` -- Command map: `references/command-map.md` diff --git a/skills/repo-onboarding/references/command-map.md b/skills/repo-onboarding/references/command-map.md deleted file mode 100644 index 1dc86b4d..00000000 --- a/skills/repo-onboarding/references/command-map.md +++ /dev/null @@ -1,13 +0,0 @@ -# Command Map - -## Root commands -- `npm run setup` -- `npm run dev` -- `npm run build` -- `npm start` -- `npm run test:server` -- `npm run test:client` -- `npm run install-hooks` -- `npm run import-history` -- `npm run clear-data` - diff --git a/skills/repo-onboarding/references/module-map.md b/skills/repo-onboarding/references/module-map.md deleted file mode 100644 index d5461293..00000000 --- a/skills/repo-onboarding/references/module-map.md +++ /dev/null @@ -1,15 +0,0 @@ -# Module Map - -## Backend -- `server/index.js`: app composition, startup behavior, periodic maintenance. -- `server/db.js`: schema and prepared statement ownership. -- `server/routes/*.js`: endpoint contracts by domain. -- `server/websocket.js`: WS lifecycle and broadcast behavior. - -## Frontend -- `client/src/pages/`: route-level screens. -- `client/src/components/`: reusable UI building blocks. -- `client/src/lib/api.ts`: client API access patterns. -- `client/src/hooks/useWebSocket.ts`: live update pipeline. - -## MCP