diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 862381aa..08a2d45a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -601,7 +601,7 @@ graph LR | `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` | | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) | -| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | +| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | | `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | | `/*` | NotFound | None (static 404 page) | diff --git a/CLAUDE.md b/CLAUDE.md index f3b55ff4..a070490f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.17 (2026-09-18) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.17 (2026-09-18) + the run's permission mode defaulting to `auto` and the model picker sending ALIASES (`ALLOWED_PERMISSION_MODES` in `server/lib/pty-run.js` + `server/routes/run.js` gained `auto`, which is also the fallback `buildClaudeArgv` passes to `--permission-mode`, the `RunSetup` select's first option and `LaneConsolePane`'s initial state; and `RUN_MODEL_CHOICES` in `client/src/lib/api.ts` now holds `fable`/`opus`/`sonnet`/`haiku` instead of pinned ids — `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + `convertEol` being GONE from the xterm terminal (`TerminalView.tsx`) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (`server/lib/desktop-env.js`, merged into `spawnRun`'s tmux `-e` env) — a systemd user service has none of them, so `claude` in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (`sizeFromQuery` in `server/websocket.js`, `?cols=&rows=` on the `/ws-pty/:runId` URL built in `TerminalView.tsx`) — `attach()` was spawning `tmux attach-session` at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (`lane-empty` in `client/src/pages/Workspace.tsx`, `empty.title`/`empty.body` in `lanes.json` replacing `emptyState`) — the lane strip, add tile included, now renders only when a lane exists + the **Create/update readme file** action living in the lane's **Tools** menu (`LaneToolsMenu` in `client/src/components/lanes/LaneCard.tsx`, `actions.writeDocs*` in `lanes.json`) instead of being a checkbox in the Add-lane modal — same `api.run.start({initialPrompt: "/sg-all:sg-md-writing"})`, claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (`TerminalView.tsx`: `term.focus()` after the first fit, so typing reaches tmux without clicking the pane, and a new `onExit` prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's `exit` frame; `LaneConsolePane` runs `newRun()` there, so a double Ctrl+C out of `claude` lands back on the run setup form instead of a dead pane behind a **New run** button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 7dd09107..2063da90 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -14,8 +14,6 @@ * the route answers before the actual `git worktree add` runs, so this * component polls `GET /api/lanes/:id` until that finishes before running the * auto-setup calls against a cwd that must actually exist on disk first. - * An optional "Create/update readme file" checkbox starts the lane's terminal - * run right after creation with `/sg-all:sg-md-writing` as its first message. * @author Nguyễn Ngọc Trí Vĩ */ @@ -96,7 +94,6 @@ export function AddLaneModal({ const [branches, setBranches] = useState(null); const [base, setBase] = useState(""); const [pipeline, setPipeline] = useState("default"); - const [writeDocs, setWriteDocs] = useState(false); // "native" is the built-in profile, not a null state — see server/lib/ai-routers.js. const [router, setRouter] = useState("native"); const [routers, setRouters] = useState([]); @@ -122,7 +119,6 @@ export function AddLaneModal({ setBranches(null); setBase(""); setPipeline("default"); - setWriteDocs(false); setRouter("native"); setRoutersLoaded(false); setTool("claude"); @@ -208,14 +204,6 @@ export function AddLaneModal({ return () => window.clearTimeout(timer); }, [mode, sourceRepo, t]); - // The checkbox below runs the docs skill by opening the lane's own terminal - // run with the slash command as its first message — a skill only exists - // inside a CLI session, so there is nothing server-side to call instead. - // Claude-only: codex has no slash commands (same reason LaneConsolePane - // prefills prose for a codex lane). - const startDocsRun = (laneId: number, cwd: string) => - api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" }); - // Native is the built-in profile, so this is one call either way — there is // no "no router" branch to keep in step with the real one. const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router); @@ -242,19 +230,14 @@ export function AddLaneModal({ // Worktree mode below already works this way. onAdded(result.lane); - // Router first: a session that booted on the vendor's API would keep - // talking to it for its whole life. - const [routerOutcome, docsOutcome] = await Promise.allSettled([ - applyRouter(result.lane.id), - writeDocs && tool === "claude" - ? startDocsRun(result.lane.id, result.lane.cwd) - : Promise.resolve(null), - ]); - const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected"); - if (failed && failed.status === "rejected") { + // A session that booted on the vendor's API would keep talking to it + // for its whole life, so the router is applied before anything runs. + try { + await applyRouter(result.lane.id); + } catch (routerErr) { // The lane exists and is already listed — name what did not finish // instead of closing on a half-set-up lane. - setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason)); + setError(routerErr instanceof Error ? routerErr.message : String(routerErr)); setBusy(false); return; } @@ -312,10 +295,6 @@ export function AddLaneModal({ const anyFailed = Object.values(outcome).includes("failed"); setSetupResult(outcome); - if (writeDocs && tool === "claude") { - await startDocsRun(result.lane.id, provisionedLane.cwd); - } - if (import.meta.env.DEV) { console.info("[add-lane] auto-setup result:", { profile: @@ -555,39 +534,6 @@ export function AddLaneModal({

- {tool === "claude" && ( - - )} - {mode === "worktree" && branches && (
), })); @@ -130,6 +134,35 @@ describe("LaneConsolePane", () => { ); }); + it("returns to the setup form when the pane exits, without a New run click", async () => { + (api.lanes.action as ReturnType).mockResolvedValue({ + lane: { ...LANE, run_id: "run-1" }, + }); + (api.run.get as ReturnType).mockResolvedValue({ + id: "run-1", + laneId: 1, + status: "running", + cwd: "/workspace/a", + model: null, + permissionMode: null, + effort: null, + resumeSessionId: null, + sessionId: null, + startedAt: null, + promptPreview: null, + }); + + render(); + fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } }); + fireEvent.click(screen.getByRole("button", { name: /^run$/i })); + await screen.findByTestId("terminal-view"); + + fireEvent.click(screen.getByTestId("fake-pane-exit")); + + await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); + expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument(); + }); + it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => { const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" }; const run = (id: string, laneId: number) => ({ diff --git a/client/src/components/run/__tests__/RunSetup.test.tsx b/client/src/components/run/__tests__/RunSetup.test.tsx index c4260aba..ab680f73 100644 --- a/client/src/components/run/__tests__/RunSetup.test.tsx +++ b/client/src/components/run/__tests__/RunSetup.test.tsx @@ -121,8 +121,10 @@ describe("RunSetup — selections report through callbacks", () => { it("reports the model from the picker, including a custom id", () => { const { spies } = renderSetup(); - pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5"); - expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5"); + // The picker offers the CLI's "latest" aliases, not pinned ids — that is + // what keeps the list from going stale on every model release. + pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)"); + expect(spies.onModelChange).toHaveBeenCalledWith("sonnet"); // "Custom…" is a sentinel, not a model id — it must not be reported as one; // the free-text box it reveals is what reports. fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 3d59f7e3..c009a28d 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -16,6 +16,7 @@ const openMock = vi.fn(); const disposeMock = vi.fn(); const wheelHandlerMock = vi.fn(); const keyHandlerMock = vi.fn(); +const focusMock = vi.fn(); const loadAddonMock = vi.fn(); // Mutable stand-ins for the two live terminal facts the wheel handler reads. const termState = { @@ -54,6 +55,7 @@ vi.mock("@xterm/xterm", () => ({ loadAddon: loadAddonMock, attachCustomWheelEventHandler: wheelHandlerMock, attachCustomKeyEventHandler: keyHandlerMock, + focus: focusMock, })), })); vi.mock("@xterm/addon-fit", () => ({ @@ -100,13 +102,27 @@ describe("TerminalView", () => { keyHandlerMock.mockClear(); loadAddonMock.mockClear(); openMock.mockClear(); + focusMock.mockClear(); }); afterEach(cleanup); - it("opens a WS connection to the run's ws-pty path", () => { + it("opens a WS connection to the run's ws-pty path, carrying its own size", () => { + // The size rides on the attach URL, not on a `resize` message sent after + // it: tmux resizes the window to the newest client, so attaching at the + // server's default 80x24 makes claude redraw its UI at 80 columns and + // leaves that width in the scrollback (issue #33). render(); expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0]!.url).toBe( + "ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" + ); + }); + + it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => { + termState.cols = 0; + termState.rows = 0; + render(); expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1"); }); @@ -184,6 +200,24 @@ describe("TerminalView", () => { expect(preventPlainTab).not.toHaveBeenCalled(); }); + it("takes the keyboard as soon as it mounts", () => { + // The terminal IS the page here — without this the first thing typed after + // opening a lane went nowhere until the user clicked the pane. + render(); + expect(focusMock).toHaveBeenCalled(); + }); + + it("tells its owner when the pane exited, so the setup form can come back", () => { + // A double Ctrl+C out of `claude` ends the tmux session; the dead pane used + // to sit there until the user found the "New run" button. + const onExit = vi.fn(); + render( + + ); + MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' }); + expect(onExit).toHaveBeenCalledTimes(1); + }); + it("renders through the canvas addon, not xterm's DOM renderer", () => { // The DOM renderer aligns a row with one `letter-spacing`, which holds only // while every glyph shares a font. Vietnamese diacritics and CJK fall to a diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 2b051d9f..7b8b364b 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -16,6 +16,9 @@ "actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}", "actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}", "actions.tools": "Tools", + "actions.writeDocs": "Create/update readme file", + "actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.", + "actions.writeDocsResult": "Started — see this lane's terminal.", "add": "Add lane", "addLane": "Create a lane from a working directory", "addLaneBaseLabel": "Branch to fork from", @@ -49,8 +52,6 @@ "addLaneToolClaude": "Claude Code", "addLaneToolCodex": "Codex", "addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.", - "addLaneWriteDocsHint": "On creation, opens the lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.", - "addLaneWriteDocsLabel": "Create/update readme file", "autoStage": "auto: {{stage}}", "browse.button": "Browse", "browse.empty": "This folder is empty.", @@ -93,7 +94,8 @@ "destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.", "destructive.reset.title": "Reset managed worktree?", "destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.", - "emptyState": "No lanes yet. Create one from a working directory:", + "empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:", + "empty.title": "No lanes yet", "features.archived": "archived", "features.live": "Live", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.", diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index 88acc986..e78ed30b 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -71,7 +71,8 @@ "modelCustom": "Custom…", "modelCustomPlaceholder": "model id (e.g. claude-opus-5)", "modelInheritLabel": "Inherit from settings", - "permissionAcceptEdits": "acceptEdits (recommended)", + "permissionAcceptEdits": "acceptEdits (file edits without asking)", + "permissionAuto": "auto (recommended - asks only for risky actions)", "permissionBypass": "bypassPermissions (DANGER - allows everything without asking)", "permissionDefault": "default (interactive prompts - sessions may hang headlessly)", "permissionMode": "Permission mode", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index afb2764d..2b9964b1 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -16,6 +16,9 @@ "actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}", "actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}", "actions.tools": "Công cụ", + "actions.writeDocs": "Tạo/cập nhật file readme", + "actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.", + "actions.writeDocsResult": "Đã chạy — xem terminal của lane này.", "add": "Thêm lane", "addLane": "Tạo lane từ một thư mục làm việc", "addLaneBaseLabel": "Nhánh để tạo nhánh mới", @@ -49,8 +52,6 @@ "addLaneToolClaude": "Claude Code", "addLaneToolCodex": "Codex", "addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.", - "addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.", - "addLaneWriteDocsLabel": "Tạo/cập nhật file readme", "autoStage": "tự động: {{stage}}", "browse.button": "Duyệt", "browse.empty": "Thư mục này trống.", @@ -93,7 +94,8 @@ "destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.", "destructive.reset.title": "Đặt lại worktree được quản lý?", "destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.", - "emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:", + "empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Thêm ở đây, hoặc từ terminal:", + "empty.title": "Chưa có lane nào", "features.archived": "đã lưu trữ", "features.live": "Phiên bản trực tiếp", "features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.", diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 25f2677a..7f913816 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -70,7 +70,8 @@ "modelCustom": "Tuỳ chỉnh…", "modelCustomPlaceholder": "model id (vd: claude-opus-5)", "modelInheritLabel": "Kế thừa từ settings", - "permissionAcceptEdits": "acceptEdits (khuyên dùng)", + "permissionAcceptEdits": "acceptEdits (sửa file không hỏi)", + "permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)", "permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)", "permissionDefault": "default (prompt tương tác - headless sẽ treo)", "permissionMode": "Permission mode", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ba0df84c..c894bd00 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3390,7 +3390,7 @@ export interface CodexMarketplaceActionArgs { // ───────────────────────────────────────────────────────────────────────────── /** Maps 1:1 to the `claude --permission-mode` CLI flag. */ -export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions"; +export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions"; /** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */ export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max"; /** "running" — a live tmux session exists; "gone" — it doesn't (killed, @@ -3515,10 +3515,17 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [ // configured default and the rest map to concrete `--model` values. export const RUN_MODEL_CHOICES: ModelChoice[] = [ { id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" }, - { id: "claude-opus-5", label: "Opus 5", hint: "Highest capability" }, - { id: "claude-sonnet-5", label: "Sonnet 5", hint: "Balanced capability and speed" }, - { id: "claude-fable-5", label: "Fable 5", hint: "Fast output, same capability tier as Opus" }, - { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", hint: "Fastest, lightest" }, + // Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/ + // `sonnet`/`haiku` as "an alias for the latest model", and the CLI resolves + // each one itself (2.1.278 maps fable -> claude-fable-5-1). A pinned id here + // is a list that silently goes stale every release and has to be edited by + // hand; an alias follows whatever `claude` was last updated to. Cost math is + // unaffected — pricing keys off the model the transcript reports, which is + // the resolved full id. + { id: "fable", label: "Fable (latest)", hint: "Fast output, same capability tier as Opus" }, + { id: "opus", label: "Opus (latest)", hint: "Highest capability" }, + { id: "sonnet", label: "Sonnet (latest)", hint: "Balanced capability and speed" }, + { id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" }, ]; // Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index d402fae9..a3c38d39 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -43,7 +43,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react"; +import { Plus, Square, Columns2, Grid2x2, Pencil, Layers } from "lucide-react"; import { api } from "../lib/api"; import type { CwdSuggestion, RunListResponse } from "../lib/api"; import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types"; @@ -574,37 +574,68 @@ export function Workspace() { scrolling row (holds any number of lanes), the detail/console area below it gets the full width and never competes for it. */}
-
- {/* "Add lane" is a tile in the strip rather than a header button: - creating a lane is the same kind of act as picking one, so it - reads as the empty slot at the head of the row. Dashed border and - no liveness dot keep it from being mistaken for a lane. */} - - {lanes.map((l, i) => ( - setSelectedLaneId(l.id)} - onReorder={handleLaneReorder} - /> - ))} - {!lanes.length && ( -

- {tLanes("emptyState")} agenthub lanes add --cwd $(pwd) -

- )} -
+ + + +
+

{tLanes("empty.title")}

+

+ {tLanes("empty.body")}{" "} + + agenthub lanes add --cwd $(pwd) + +

+
+ +
+ ) : ( +
+ {/* "Add lane" is a tile in the strip rather than a header button: + creating a lane is the same kind of act as picking one, so it + reads as the empty slot at the head of the row. Dashed border and + no liveness dot keep it from being mistaken for a lane. */} + + {lanes.map((l, i) => ( + setSelectedLaneId(l.id)} + onReorder={handleLaneReorder} + /> + ))} +
+ )} {/* The selected lane's pipeline — the thing you actually come to this page to read, beside the list rather than stacked under it. diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index c7f57b3d..0a66164d 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -5100,13 +5100,58 @@ exports[`screen snapshots > Run 1`] = ` class="flex min-h-0 flex-1 flex-col gap-4" >
+ + + + + + + +
+

+ No lanes yet +

+

+ A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal: + + + agenthub lanes add --cwd $(pwd) + +

+
-

- No lanes yet. Create one from a working directory: - - - agenthub lanes add --cwd $(pwd) - -

Run 1`] = ` - acceptEdits (recommended) + auto (recommended - asks only for risky actions) ` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally. -`permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it. +`permissionMode` (`auto` default, or `acceptEdits` / `default` / `plan` / `bypassPermissions`) is the mode the pane starts in — `auto` is the CLI's own classifier mode: it asks only for what it reads as risky. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it. **PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view. @@ -1990,7 +1990,7 @@ Sent when a notification is created. #### /ws-pty/:runId — PTY frames -A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. +A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. #### cc_config_changed diff --git a/docs/LANES.md b/docs/LANES.md index e2d4ad4d..c4179988 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -45,8 +45,6 @@ The modal has a segmented toggle mirroring the CLI's two modes: Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/.config.toml` layer plus a `-p ` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles. -Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run. - Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing. ## Lane tool: Claude or Codex @@ -372,11 +370,13 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce - **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers. - **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane. +- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and a primary **Add lane** button. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height. - **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped. - **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation. -- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which. +- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment. -- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. +- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click. +- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads. - **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row. @@ -1301,7 +1301,9 @@ curl -X POST http://localhost:4820/api/run \ The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `agenthub lanes shell` seamlessly. -**Permission mode in a lane run.** `permissionMode` (one of `acceptEdits` — the default — `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default. +**The model picker offers aliases, not pinned ids.** The Run setup's Model select sends `fable` / `opus` / `sonnet` / `haiku` (plus `Custom…` for a literal id, and the empty value that inherits `settings.json`). `claude --model` documents those as *"an alias for the latest model"* and resolves each itself — 2.1.278 maps `fable` to `claude-fable-5-1` — so a new model release reaches the dashboard the moment the CLI is updated, with no list to edit by hand. Cost maths is unaffected: pricing keys off the model id the transcript reports, which is always the resolved full name. Codex has no such aliases, so `RUN_MODEL_CHOICES_CODEX` stays a pinned list. + +**Permission mode in a lane run.** `permissionMode` (one of `auto` — the default — `acceptEdits`, `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. `auto` hands the decision to the CLI's own classifier, which asks only for what it reads as risky; it is what a terminal `claude` starts in too. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default. **A killed run releases the lane.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the lane's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the lane's id and are listable via `GET /api/run/history?laneId=`. diff --git a/openapi.yaml b/openapi.yaml index a8a9a400..f07dded9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3428,12 +3428,13 @@ components: permissionMode: type: string enum: + - auto - acceptEdits - default - plan - bypassPermissions - description: Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid. - example: acceptEdits + description: Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid. + example: auto effort: type: string enum: @@ -3611,12 +3612,13 @@ components: type: string nullable: true enum: + - auto - acceptEdits - default - plan - bypassPermissions description: Permission mode recorded at spawn time. - example: acceptEdits + example: auto effort: type: string nullable: true @@ -3792,13 +3794,14 @@ components: permissionMode: type: string enum: + - auto - acceptEdits - default - plan - bypassPermissions - default: acceptEdits - description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`. - example: acceptEdits + default: auto + description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`. + example: auto RunSpawnConcurrencyResponse: type: object description: HTTP 429 body when the concurrency cap is reached (ECONCURRENCY). In addition to the standard `error` envelope, this response carries a top-level `running` array listing the runs currently occupying the cap. diff --git a/server/__tests__/desktop-env.test.js b/server/__tests__/desktop-env.test.js new file mode 100644 index 00000000..68ce2cb6 --- /dev/null +++ b/server/__tests__/desktop-env.test.js @@ -0,0 +1,63 @@ +/** + * @file desktop-env.test.js + * @description Unit tests for the graphical-session variables forwarded into a + * lane's tmux pane. Without them a `claude` started by the dashboard has no + * DISPLAY/WAYLAND_DISPLAY at all and answers "no image found in clipboard" on + * a paste, while the same CLI works in a normal terminal. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env"); + +const saved = {}; + +describe("desktopEnv", () => { + beforeEach(() => { + for (const name of FORWARDED) { + saved[name] = process.env[name]; + delete process.env[name]; + } + }); + afterEach(() => { + for (const name of FORWARDED) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } + }); + + it("reads the systemd user manager's environment", () => { + __setExecImpl( + () => "XDG_RUNTIME_DIR=/run/user/1001\nDISPLAY=:0\nXAUTHORITY=/run/user/1001/gdm/Xauthority\n" + ); + assert.deepEqual(desktopEnv(), { + DISPLAY: ":0", + XAUTHORITY: "/run/user/1001/gdm/Xauthority", + XDG_RUNTIME_DIR: "/run/user/1001", + }); + }); + + it("prefers this process's own value — a checkout run from a terminal has the real one", () => { + process.env.DISPLAY = ":1"; + __setExecImpl(() => "DISPLAY=:0\n"); + assert.equal(desktopEnv().DISPLAY, ":1"); + }); + + it("forwards nothing when there is no systemd user manager (macOS, a container)", () => { + __setExecImpl(() => { + throw new Error("systemctl: not found"); + }); + assert.deepEqual(desktopEnv(), {}); + }); + + it("forwards only the clipboard-related names, never the whole session", () => { + __setExecImpl(() => "DISPLAY=:0\nSSH_AUTH_SOCK=/tmp/agent\nPATH=/usr/bin\n"); + assert.deepEqual(Object.keys(desktopEnv()), ["DISPLAY"]); + }); + + it("drops a value carrying a newline — it becomes a tmux -e argv", () => { + __setExecImpl(() => ""); + process.env.WAYLAND_DISPLAY = "wayland-0\ninjected"; + assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined); + }); +}); diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index ea398b32..2ece4293 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -193,7 +193,10 @@ describe("pty-run", () => { // `--dangerously-skip-permissions` (which would enable it) is never passed. assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions")); const modeAt = newSessionArgv.indexOf("--permission-mode"); - assert.equal(newSessionArgv[modeAt + 1], "acceptEdits"); + // `auto` is the default when the caller named no mode: the CLI's own + // classifier asks for what it reads as risky and stays out of the way + // otherwise. + assert.equal(newSessionArgv[modeAt + 1], "auto"); }); it("spawnRun appends a positional initial prompt after argv flags", () => { diff --git a/server/__tests__/websocket-pty-size.test.js b/server/__tests__/websocket-pty-size.test.js new file mode 100644 index 00000000..453a564a --- /dev/null +++ b/server/__tests__/websocket-pty-size.test.js @@ -0,0 +1,31 @@ +/** + * @file websocket-pty-size.test.js + * @description Regression test for issue #33: the `/ws-pty/:runId` attach + * spawns `tmux attach-session` at the size the CLIENT reports in the attach + * URL, not a hardcoded 80×24. tmux sizes the window to the newest client, so + * attaching at 80 columns and fixing it from the first `resize` message made + * `claude` redraw its whole UI at 80 columns — wreckage that stays in the + * scrollback and shows up as torn left-margin fragments when scrolling back. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { sizeFromQuery } = require("../websocket"); + +const query = (search) => new URL(`http://x/ws-pty/agenthub-lane-1${search}`).searchParams; + +describe("sizeFromQuery — the attach carries the client's real size", () => { + it("reads the terminal's own cols/rows", () => { + assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 }); + }); + + it("falls back to 80x24 when the client named no size", () => { + assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 }); + }); + + it("refuses anything out of bounds — this number reaches an ioctl", () => { + assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 }); + assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 }); + assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 }); + }); +}); diff --git a/server/lib/desktop-env.js b/server/lib/desktop-env.js new file mode 100644 index 00000000..5608491d --- /dev/null +++ b/server/lib/desktop-env.js @@ -0,0 +1,67 @@ +/** + * @file desktop-env.js + * @description The graphical session's own environment variables, for panes + * the dashboard spawns. The server usually runs as a systemd *user* service, + * which has no `DISPLAY`/`WAYLAND_DISPLAY` of its own — so neither does any + * tmux pane it starts, and a `claude` running in one cannot reach the X or + * Wayland clipboard: pasting an image answers "no image found in clipboard" + * while the same CLI in a normal terminal pastes fine. `systemctl --user + * show-environment` is the canonical local source for those values (the + * desktop session imports them there at login), with this process's own + * environment preferred when it already has them — a checkout started from a + * terminal inherits the real ones. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFileSync } = require("node:child_process"); + +/** The only names forwarded. A pane gets a clipboard, not the whole session. */ +const FORWARDED = ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"]; + +let execImpl = (args) => + execFileSync("systemctl", args, { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }); + +/** Test seam — same pattern as tmux.js. */ +function __setExecImpl(fn) { + execImpl = fn; +} + +/** `KEY=value` lines from `systemctl --user show-environment`, as a map. */ +function systemdUserEnv() { + let out; + try { + out = execImpl(["--user", "show-environment"]); + } catch { + // No systemd user manager (macOS, a container, a plain login shell) — the + // process environment below is the only source then. + return {}; + } + const env = {}; + for (const line of String(out).split("\n")) { + const eq = line.indexOf("="); + if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1); + } + return env; +} + +/** + * The forwarded variables that actually have a usable value right now. + * A value carrying a newline is dropped rather than passed on: these end up in + * a `tmux -e NAME=value` argv, and a line break there is not a value tmux can + * represent. + */ +function desktopEnv() { + const fromSystemd = systemdUserEnv(); + const out = {}; + for (const name of FORWARDED) { + const value = process.env[name] || fromSystemd[name]; + if (typeof value === "string" && value && !/[\r\n\0]/.test(value)) out[name] = value; + } + return out; +} + +module.exports = { desktopEnv, FORWARDED, __setExecImpl }; diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 44f7d526..3f162e5d 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -26,6 +26,7 @@ const tmux = require("./tmux"); const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply"); +const { desktopEnv } = require("./desktop-env"); let dashboardRuns = null; try { @@ -36,7 +37,16 @@ try { const RUN_ID_RE = /^agenthub-lane-(\d+)$/; const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); -const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); +// `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 +// callers that stored it, though this CLI now spells that one `manual`. +const ALLOWED_PERMISSION_MODES = new Set([ + "auto", + "acceptEdits", + "default", + "plan", + "bypassPermissions", +]); // Pane commands that mean "idle shell prompt, safe to type a command into". const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); const VALID_TOOLS = new Set(["claude", "codex"]); @@ -70,7 +80,7 @@ function makeErr(code, message) { */ function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { const argv = ["claude"]; - argv.push("--permission-mode", permissionMode || "acceptEdits"); + argv.push("--permission-mode", permissionMode || "auto"); // Puts "bypass permissions" INTO the pane's shift+tab cycle without turning // it on: `claude --help` — "Enable bypassing all permission checks as an // option, without it being enabled by default." Without it the cycle is only @@ -235,7 +245,7 @@ function spawnRun(args) { cwd, tool: tool === "codex" ? "codex" : "claude", model: model || null, - permissionMode: permissionMode || "acceptEdits", + permissionMode: permissionMode || "auto", effort: effort || null, resumeSessionId: resumeSessionId || null, prompt: initialPrompt || "", @@ -254,8 +264,14 @@ function spawnRun(args) { // A codex lane on an AI Router also needs the key its layer's `env_key` // names. A claude lane needs nothing here — its settings file already carries // the router — and a lane on the vendor's own API needs nothing either. + // The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the + // server normally runs as a systemd user service with neither, so a pane it + // starts has no clipboard at all — pasting an image into `claude` there + // answers "no image found in clipboard" while the same CLI pastes fine in a + // terminal. Never overrides what this spawn already needs. const env = { AGENTHUB_LANE_ID: String(laneId), + ...desktopEnv(), ...routerEnvForSpawn({ tool, router_profile: routerProfile || null }), }; diff --git a/server/openapi-extra/run.js b/server/openapi-extra/run.js index 14a168a6..66b47da8 100644 --- a/server/openapi-extra/run.js +++ b/server/openapi-extra/run.js @@ -102,10 +102,10 @@ const schemas = { }, permissionMode: { type: "string", - enum: ["acceptEdits", "default", "plan", "bypassPermissions"], + enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], description: - "Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.", - example: "acceptEdits", + "Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.", + example: "auto", }, effort: { type: "string", @@ -316,9 +316,9 @@ const schemas = { permission_mode: { type: "string", nullable: true, - enum: ["acceptEdits", "default", "plan", "bypassPermissions"], + enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], description: "Permission mode recorded at spawn time.", - example: "acceptEdits", + example: "auto", }, effort: { type: "string", @@ -516,11 +516,11 @@ const schemas = { }, permissionMode: { type: "string", - enum: ["acceptEdits", "default", "plan", "bypassPermissions"], - default: "acceptEdits", + enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], + default: "auto", description: - "Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.", - example: "acceptEdits", + "Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.", + example: "auto", }, }, }, diff --git a/server/routes/run.js b/server/routes/run.js index 5b851adb..db65f411 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -60,7 +60,13 @@ function sanitiseCwd(input) { return resolved; } -const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); +const ALLOWED_PERMISSION_MODES = new Set([ + "auto", + "acceptEdits", + "default", + "plan", + "bypassPermissions", +]); router.get("/", (_req, res) => { res.json({ items: runs.listRuns() }); @@ -248,7 +254,7 @@ router.post("/", (req, res) => { permissionMode: typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) ? body.permissionMode - : "acceptEdits", + : "auto", effort: typeof body.effort === "string" && body.effort ? body.effort : null, resumeSessionId: typeof body.resumeSessionId === "string" && body.resumeSessionId diff --git a/server/websocket.js b/server/websocket.js index 808e081e..6ec60f37 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -82,6 +82,23 @@ function initWebSocket(server) { let ptyWss = null; const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/; +/** + * The attaching terminal's own size, off the attach URL's query. tmux resizes + * the window to whichever client attached most recently, so a hardcoded 80×24 + * here is not a harmless default: the pane's program redraws its whole UI at + * 80 columns and only gets the real width back when the client's first + * `resize` message lands, leaving 80-column wreckage in the scrollback that + * shows up as torn left-margin fragments when the user scrolls back + * (issue #33). Bounded, because this number reaches a `TIOCSWINSZ` ioctl. + */ +function sizeFromQuery(params) { + const read = (name, fallback) => { + const parsed = Number.parseInt(params.get(name) ?? "", 10); + return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : fallback; + }; + return { cols: read("cols", 80), rows: read("rows", 24) }; +} + /** * Second WebSocket server, dedicated to the terminal-run PTY transport * (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so @@ -100,9 +117,9 @@ function initPtyWebSocket(server) { const { attach } = require("./lib/pty-attach"); ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); - ptyWss.on("connection", (ws, runId) => { + ptyWss.on("connection", (ws, runId, size) => { try { - attach(ws, runId, { cols: 80, rows: 24 }); + attach(ws, runId, size || { cols: 80, rows: 24 }); } catch (err) { try { ws.close(1008, err.message); @@ -133,7 +150,7 @@ function initPtyWebSocket(server) { return; } ptyWss.handleUpgrade(req, socket, head, (ws) => { - ptyWss.emit("connection", ws, match[1]); + ptyWss.emit("connection", ws, match[1], sizeFromQuery(url.searchParams)); }); }); @@ -203,4 +220,11 @@ function closeWebSocket() { } } -module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket }; +module.exports = { + initWebSocket, + initPtyWebSocket, + broadcast, + getConnectionCount, + closeWebSocket, + sizeFromQuery, +};