diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0523b16b..a75a7b5c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -386,7 +386,7 @@ graph TD | `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything | | `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | | `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` | -| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- ` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout | +| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- ` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout | | `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `/.playwright-mcp/proof///`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` | | `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | @@ -2224,7 +2224,7 @@ sequenceDiagram | --- | --- | | **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch --tags && git merge --ff-only ` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks; `git fetch ` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. | | **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. | -| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. | +| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. | | **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. | | **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. | | **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `" available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. | diff --git a/CLAUDE.md b/CLAUDE.md index ec98744b..3a2a959d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.5.10 (2026-10-07) + server mode (login, per-member rows, Claude account pool) and performance for 10+ concurrent users. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, Open terminal in the AI menu, Repo as the Add-workspace default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -44,9 +44,9 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these seventeen, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort` — nothing else. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is the one history-joining action, and only because its recovery is a button too** (`merge-abort`): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and `reset` already lives behind `worktree.js`'s three-check destroy guard. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these twenty-one, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend` — nothing else. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. -- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. +- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. - **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`). AgentHub never reads the IDE's data. - **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'/agenthub-hook.sh' ` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`. - **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; an npm update is refused (`ENOTNPM`) for a tool whose binary is not under `npm prefix -g` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects. Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name. diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx index c79d8e19..90371588 100644 --- a/client/src/components/Select.tsx +++ b/client/src/components/Select.tsx @@ -84,6 +84,8 @@ export interface SelectOption { hint?: string; /** Optional leading icon, shown in the trigger and the list. */ icon?: ReactNode; + /** Draw a rule above this option: it starts a group of its own. */ + divider?: boolean; } /** Props for {@link Select}. */ @@ -272,6 +274,8 @@ export function Select({ onClick={() => choose(opt)} onMouseEnter={() => setActive(idx)} className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${ + opt.divider ? "mt-1 border-t border-border pt-2" : "" + } ${ isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3" }`} > diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index be94903f..2c534efa 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -24,7 +24,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom"; -import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react"; +import { RefreshCw, Wifi, WifiOff } from "lucide-react"; import { api, type RunHandle } from "../lib/api"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types"; import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format"; @@ -145,7 +145,7 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) { {t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")} ) : ( -
+
{q.windows.map((w) => { const p = Math.round(w.usedPercent ?? 0); const resets = untilText(w.resetsAt); @@ -161,16 +161,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) { /> {p}% - - {resets && ( - <> - - {resets} - - {t("statusBar.quotaResets", { time: resets })} - - - )} + {/* In words: a ↻ glyph here read as a Refresh button. */} + + {resets && t("statusBar.quotaResets", { time: resets })} ); diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index f0d734a4..304ea339 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -217,7 +217,7 @@ describe("StatusBar", () => { const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account"); expect(work).toHaveTextContent("Workin use"); expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m"); - expect(work).toHaveTextContent("5h95%41m"); + expect(work).toHaveTextContent("5h95%Resets in 41m"); expect(work).toHaveTextContent("Week32%"); expect(personal).toHaveTextContent("home@example.com · Sign-in expired"); expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles"); diff --git a/client/src/components/__tests__/WorkspaceTree.test.tsx b/client/src/components/__tests__/WorkspaceTree.test.tsx index 44824118..7a18211a 100644 --- a/client/src/components/__tests__/WorkspaceTree.test.tsx +++ b/client/src/components/__tests__/WorkspaceTree.test.tsx @@ -81,8 +81,6 @@ vi.mock("../../lib/api", async (importOriginal) => { })), cwds: vi.fn(async () => ({ items: [] })), kill: vi.fn(async () => ({ ok: true })), - history: vi.fn(async () => ({ items: [] })), - forgetHistory: vi.fn(async () => ({ removed: "" })), }, aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), @@ -351,97 +349,6 @@ describe("WorkspaceTree", () => { expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1"); }); - it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => { - vi.mocked(api.run.history).mockImplementation( - async () => - ({ - items: [ - { - id: "agenthub-lane-1-2", - tool: "shell", - status: "running", - started_at: "2026-10-06T10:00:00Z", - ended_at: null, - isLive: true, - }, - { - id: "agenthub-lane-1-3@a", - tool: "shell", - status: "killed", - started_at: "2026-10-06T09:00:00Z", - ended_at: "2026-10-06T09:05:00Z", - isLive: false, - }, - { - id: "agenthub-lane-1-1@b", - tool: "shell", - status: "exited", - started_at: "2026-10-06T08:00:00Z", - ended_at: "2026-10-06T08:05:00Z", - isLive: false, - }, - { - id: "agenthub-lane-1-1@c", - tool: "shell", - status: "exited", - started_at: "2026-10-06T07:00:00Z", - ended_at: "2026-10-06T07:05:00Z", - isLive: false, - }, - { - id: "agenthub-lane-1-1@d", - tool: "shell", - status: "exited", - started_at: "2026-10-06T06:00:00Z", - ended_at: "2026-10-06T06:05:00Z", - isLive: false, - }, - ], - }) as never - ); - vi.mocked(api.lanes.action).mockImplementation( - async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never - ); - const user = userEvent.setup(); - renderTree(); - await user.click(await screen.findByTestId("workspace-toggle-1")); - const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a"); - expect(closed).toHaveTextContent("Terminal"); - expect(api.run.history).toHaveBeenCalledWith( - expect.objectContaining({ laneId: 1, tool: "shell" }) - ); - // The live one is not history, and only the newest three are listed. - expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull(); - expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3); - await user.click(closed); - expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" }); - expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3"); - }); - - it("removes a closed terminal from its hover X and its right-click menu", async () => { - const shell = (id: string) => ({ - id, - tool: "shell", - status: "exited", - started_at: "2026-10-06T08:00:00Z", - ended_at: "2026-10-06T08:05:00Z", - isLive: false, - }); - vi.mocked(api.run.history).mockImplementation( - async () => ({ items: [shell("t-a"), shell("t-b")] }) as never - ); - const user = userEvent.setup(); - renderTree(); - await user.click(await screen.findByTestId("workspace-toggle-1")); - await user.click(await screen.findByTestId("workspace-terminal-forget-t-a")); - expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a"); - await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull()); - fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b")); - await user.click(screen.getByText("Remove from list")); - expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b"); - await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull()); - }); - it("selects by URL, and a click selects and expands", async () => { const user = userEvent.setup(); renderTree("/workspace?lane=2"); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 4c8c79fd..c79fc687 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -1,7 +1,8 @@ /** * @file AddLaneModal.tsx * @description The "+ Add lane" flow, in one of two modes chosen with a - * segmented toggle: "Repo" adopts an existing directory as-is via + * segmented toggle — "Repo" by default; a branch prefill (the git bar's "new + * workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via * `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a * main repo you want stage detection on); "Worktree" provisions a * dashboard-managed git worktree via `POST /api/lanes/worktree` with a @@ -94,7 +95,7 @@ export function AddLaneModal({ cwdSuggestions: CwdSuggestion[]; }) { const { t } = useTranslation(["lanes"]); - const [mode, setMode] = useState<"repo" | "worktree">("worktree"); + const [mode, setMode] = useState<"repo" | "worktree">("repo"); const [sourceRepo, setSourceRepo] = useState(""); const [title, setTitle] = useState(""); const [branch, setBranch] = useState(""); @@ -130,7 +131,7 @@ export function AddLaneModal({ } | null>(null); const reset = () => { - setMode("worktree"); + setMode("repo"); setSourceRepo(""); setTitle(""); setBranch(""); @@ -151,6 +152,8 @@ export function AddLaneModal({ if (!open || !prefill) return; if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo); if (prefill.branch) { + // A branch only means something to a worktree. + setMode("worktree"); setBranch(prefill.branch); setBranchMode("existing"); } diff --git a/client/src/components/lanes/DiffEditor.tsx b/client/src/components/lanes/DiffEditor.tsx index a15ed9c5..e4b8d6ce 100644 --- a/client/src/components/lanes/DiffEditor.tsx +++ b/client/src/components/lanes/DiffEditor.tsx @@ -11,16 +11,22 @@ * text), its edit in a stronger blue — the same scheme as the read-only diff in * LaneDiffView. Syntax colours come from the dashboard's own per-line * tokenizer (`lib/highlight.ts`), like every other code view here. + * Given `onBlock`, each changed block also gets the read-only diff's Undo / + * Keep bar under its last line (Undo asks twice) in place of its Revert; the + * panel turns it off while the editor holds unsaved text, because git acts on + * the file on disk. * Lazy-loaded by LaneChangesPanel, which keys it by file and shape: * CodeMirror stays out of the main bundle, and one mount is one view. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef } from "react"; -import { EditorState, RangeSetBuilder } from "@codemirror/state"; +import { useTranslation } from "react-i18next"; +import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, + WidgetType, drawSelection, highlightActiveLine, highlightActiveLineGutter, @@ -139,6 +145,83 @@ const diffColours = (lang: string) => { decorations: (p) => p.decorations } ); +/** A changed block by line (1-based; a count of 0 is a pure insertion or + * removal): what the panel matches against git's own change blocks. */ +export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number }; +export type BlockAction = "keep" | "undo"; + +/** A chunk side's first line and line count. `to` is the start of the line + * after the chunk (or the end + 1), so its last line holds `to - 1`. */ +const lineSpan = (doc: Text, from: number, to: number): [number, number] => { + const first = doc.lineAt(Math.min(from, doc.length)).number; + return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1]; +}; + +class BlockBar extends WidgetType { + constructor( + readonly block: EditorBlock, + readonly build: (b: EditorBlock) => HTMLElement + ) { + super(); + } + eq(o: BlockBar) { + const a = this.block; + const b = o.block; + return ( + a.oldFrom === b.oldFrom && + a.oldCount === b.oldCount && + a.newFrom === b.newFrom && + a.newCount === b.newCount + ); + } + toDOM() { + return this.build(this.block); + } +} + +const barsOn = StateEffect.define(); + +/** One bar per chunk, under its last line (above a pure removal), rebuilt + * whenever the chunks are — and dropped while `barsOn` is false. */ +function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) { + const decos = (doc: Text, chunks: readonly Chunk[]) => { + const out = new RangeSetBuilder(); + for (const c of chunks) { + const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA); + const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB); + const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length); + out.add( + pos, + pos, + Decoration.widget({ + block: true, + side: newCount ? 1 : -1, + widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build), + }) + ); + } + return out.finish(); + }; + return StateField.define<{ on: boolean; deco: DecorationSet }>({ + create: () => ({ on: false, deco: Decoration.none }), + update(v, tr) { + let on = v.on; + for (const e of tr.effects) if (e.is(barsOn)) on = e.value; + const chunks = getChunks(tr.state)?.chunks; + if (on === v.on && chunks === getChunks(tr.startState)?.chunks) { + return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v; + } + return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none }; + }, + provide: (f) => EditorView.decorations.from(f, (v) => v.deco), + }); +} + +const CHIP = + "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer"; +const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"; +const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger"; + const GREEN = "rgb(var(--status-success) / 0.18)"; const RED = "rgb(var(--status-danger) / 0.18)"; const BLUE = "rgb(var(--diff-mod) / 0.18)"; @@ -217,6 +300,7 @@ export default function DiffEditor({ revertLabel, onChange, onSave, + onBlock, }: { value: string; /** The text the diff is taken against; read once, at mount. */ @@ -231,11 +315,65 @@ export default function DiffEditor({ revertLabel: string; onChange: (text: string) => void; onSave: () => void; + /** Keep / Undo one changed block in git; absent = no bars. */ + onBlock?: (action: BlockAction, block: EditorBlock) => void; }) { + const { t } = useTranslation("lanes"); const host = useRef(null); const view = useRef(null); - const cb = useRef({ onChange, onSave }); - cb.current = { onChange, onSave }; + const cb = useRef({ onChange, onSave, onBlock, t }); + cb.current = { onChange, onSave, onBlock, t }; + + /** The diff view's review bar, as DOM: pinned to the visible right edge, + * zero height so it never pushes lines (or a split view's alignment). */ + const buildBar = (block: EditorBlock) => { + const tr = cb.current.t; + const wrap = document.createElement("div"); + wrap.className = "flex h-0"; + const pin = wrap.appendChild(document.createElement("span")); + pin.className = "sticky right-2 ml-auto h-0 w-0 self-start"; + const bar = pin.appendChild(document.createElement("span")); + bar.dataset.testid = "lane-editor-block-bar"; + bar.className = + "absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"; + const chip = (text: string, title: string, cls: string) => { + const b = bar.appendChild(document.createElement("button")); + b.type = "button"; + b.textContent = text; + b.title = title; + b.setAttribute("aria-label", title); + b.className = `${CHIP} ${cls}`; + return b; + }; + const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO); + const keep = chip( + tr("changes.stageHunkShort"), + tr("changes.stageHunk"), + "border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35" + ); + // Two clicks, like every undo git cannot give back; disarms after 3 s. + let timer = 0; + const arm = (on: boolean) => { + undo.dataset.armed = on ? "1" : ""; + undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort"); + const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk"); + undo.title = title; + undo.setAttribute("aria-label", title); + undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`; + }; + undo.onclick = () => { + window.clearTimeout(timer); + if (undo.dataset.armed) { + arm(false); + cb.current.onBlock?.("undo", block); + return; + } + arm(true); + timer = window.setTimeout(() => arm(false), 3000); + }; + keep.onclick = () => cb.current.onBlock?.("keep", block); + return wrap; + }; useEffect(() => { const folded = collapse ? { margin: 3, minSize: 4 } : undefined; @@ -263,6 +401,7 @@ export default function DiffEditor({ EditorView.updateListener.of((u) => { if (u.docChanged) cb.current.onChange(u.state.doc.toString()); }), + blockBars(Text.of(original.split("\n")), buildBar), ]; let v: EditorView; let destroy: () => void; @@ -311,6 +450,11 @@ export default function DiffEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const bars = !!onBlock; + useEffect(() => { + view.current?.dispatch({ effects: barsOn.of(bars) }); + }, [bars]); + // A value set from outside (Cancel's revert, a save's fresh text). useEffect(() => { const v = view.current; @@ -324,7 +468,9 @@ export default function DiffEditor({ ref={host} data-testid="lane-file-editor" data-layout={layout} - className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto" + data-bars={bars} + // With the git bars up, a block's local Revert would be a second Undo. + className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto" /> ); } diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index 8a5970d0..b7b6d63b 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -9,7 +9,8 @@ * file — one file, as a diff or as its whole content (issue #35); a * working file is always the editor (`DiffEditor`, diffed * against HEAD / the branch's merge base, unified or side by - * side): Changes folds the unchanged lines away, Whole file + * side, each block with the diff's Undo / Keep while nothing + * is unsaved): Changes folds the unchanged lines away, Whole file * shows them; any other file opens on its read-only diff; a * whole markdown file can also be read rendered (Preview), and an * image file is shown as the picture (`GET …/git/raw`) @@ -30,10 +31,20 @@ * before the server is told `confirm: true` — it is the one action in this * panel git cannot undo. * - * During a merge (issue #45) a banner offers **Complete merge** (only once no - * file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and - * conflicted files get their own group with Keep mine / Take theirs / Mark - * resolved instead of stage — staging one blind would mark it resolved. + * During a merge, cherry-pick or revert (issue #45) a banner offers + * **Complete** (only once no file is conflicted) and **Abort** (two clicks, + * `confirm: true`), and conflicted files get their own group with Keep mine / + * Take theirs / Mark resolved instead of stage — staging one blind would mark + * it resolved. + * + * History rewrites through its commit menu: Revert (any commit), Undo commit + * (the top one; the server refuses a pushed one) and, while another branch's + * history is shown, Cherry-pick onto this branch. The commit row's Amend box + * folds the staged changes into the last commit. All four ask first + * (`confirmDialog`). + * + * "vs base" compares against the lane's base, else the repo's default branch + * (`git.base`), and is hidden when there is none or the lane IS on it. * * A diff line's number opens a comment box (LaneDiffView `onComment`); the * comment is pasted, as `: — `, into the workspace's pane @@ -45,7 +56,7 @@ * reloading the browser tab asks the browser's own question, and * `onDirtyChange` tells the Workspace, which asks before closing the panel, * switching its tab, or moving it to another workspace or session. Two-click - * actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`). + * actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`). * @author Nguyễn Ngọc Trí Vĩ */ @@ -76,16 +87,18 @@ import { } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { pasteToLane } from "../../lib/panePaste"; -import { confirmDiscard as askDiscard } from "../../lib/confirm"; +import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { toast } from "../../lib/toast"; import { useArmed } from "../../hooks/useArmed"; import { useSpin } from "../../hooks/useSpin"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; +import type { BlockAction, EditorBlock } from "./DiffEditor"; import { LaneDiffView, LaneFileView, + changeBlocks, langFor, type DiffLayout, type LaneDiffChange, @@ -350,6 +363,22 @@ function IconButton({ const HUNK_CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40"; +/** The git change block an editor block is, by its line span on both sides; + * none when the editor's diff and git's split the change differently. */ +export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined { + return (f.hunks ?? []).flatMap(changeBlocks).find((c) => { + const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1); + const del = lines.filter((l) => l.type === "del"); + const add = lines.filter((l) => l.type === "add"); + return ( + del.length === b.oldCount && + add.length === b.newCount && + (!del.length || del[0]!.oldLine === b.oldFrom) && + (!add.length || add[0]!.newLine === b.newFrom) + ); + }); +} + type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void; /** A work-range file's stage / unstage / discard buttons — the overview row's @@ -401,20 +430,32 @@ function FileActions({ {kept ? : } ) : null} - { - // Two clicks, always: the first arms the row, the second is the - // only one that reaches the server (which itself refuses the - // call without `confirm: true`). - if (armed) onAct("discard", f.path, true); - else onArm(f.path); - }} - > - - + {/* Two clicks, always: the first arms the row, the second is the only + one that reaches the server (which itself refuses the call without + `confirm: true`). Armed, the icon becomes a red word, so the second + click is asked for where the eye already is. */} + {armed ? ( + + ) : ( + onArm(f.path)} + > + + + )} ); } @@ -694,6 +735,8 @@ export function LaneChangesPanel({ // One read of the working copy, shared by the header and every write below: // a stage, a discard or a commit changes the counts the header shows. const [git, refreshGit] = useLaneGitFacts(laneId, session); + // A merge, cherry-pick or revert is open: no new one starts from history. + const opOpen = !!(git?.available && git.merging); // Every git read and write below goes to the same tree as those facts. const target = gitTarget(laneId, session); // A diff line's comment goes into this workspace's pane, as a paste. @@ -823,6 +866,12 @@ export function LaneChangesPanel({ const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null); const pendingScroll = useRef(null); const [message, setMessage] = useState(""); + // The commit row amends the last commit instead of making a new one. + const [amend, setAmend] = useState(false); + // History of another branch (where Cherry-pick finds its commits); null = + // this branch's own. + const [historyRef, setHistoryRef] = useState(null); + const [historyBranches, setHistoryBranches] = useState([]); const [suggesting, setSuggesting] = useState(false); const [error, setError] = useState(null); @@ -898,11 +947,18 @@ export function LaneChangesPanel({ if (mode !== "history") return; setHistoryLoading(true); void api.lanes - .gitLog(target, { limit: historyLimit }) + .gitLog(target, { limit: historyLimit, ...(historyRef ? { ref: historyRef } : {}) }) .then(forLane(laneId, (r) => setCommits(r.commits))) .catch((err) => setError(err instanceof Error ? err.message : String(err))) .finally(() => laneNow.current === laneId && setHistoryLoading(false)); - }, [mode, laneId, reread, historyLimit]); + }, [mode, laneId, reread, historyLimit, historyRef]); + useEffect(() => { + if (mode !== "history") return; + void api.lanes + .gitBranches(target) + .then(forLane(laneId, (r) => setHistoryBranches(r.branches))) + .catch(() => setHistoryBranches([])); + }, [mode, laneId, reread]); // Re-run when a lane switch empties the cache too: the full-width sidebar // stays across the switch, so the mode alone never changes. @@ -1038,6 +1094,8 @@ export function LaneChangesPanel({ setConfirmDiscard(null); // Same for a half-typed commit message: it describes the OTHER lane's work. setMessage(""); + setAmend(false); + setHistoryRef(null); returnTo.current = null; pendingScroll.current = null; }, [laneId]); @@ -1177,6 +1235,69 @@ export function LaneChangesPanel({ }; const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side }); + // Revert / cherry-pick / undo from the history's commit menu. A conflict is + // an operation that STARTED: the overview is where it gets resolved. + const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => { + // Every one of the three rewrites this branch's history, so each asks. + const branch = git?.available ? git.branch : "HEAD"; + const ask = + action === "undo-commit" + ? { + title: t("changes.undoCommitTitle"), + message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }), + confirmLabel: t("changes.undoCommitConfirm"), + } + : action === "revert" + ? { + title: t("changes.revertCommit"), + message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }), + confirmLabel: t("changes.revertCommit"), + destructive: false, + } + : { + title: t("changes.cherryPick", { branch }), + message: t("changes.cherryPickConfirm", { + short: c.short, + subject: c.subject, + from: historyRef, + branch, + }), + confirmLabel: t("changes.cherryPick", { branch }), + destructive: false, + }; + if (!(await confirmDialog(ask))) return; + const lane = laneId; + setBusy(true); + setError(null); + try { + const r = await api.lanes.gitAction( + target, + action, + // The commit the dialog named: the server refuses an undo once HEAD + // is another one (ESTALE) — an agent may have committed meanwhile. + action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha } + ); + if (laneNow.current !== lane) return; + if (r.conflicts?.length) { + toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length })); + } else if (action !== "undo-commit") { + toast.success(t("changes.opDone", { op: action })); + } + // Conflicts and an undone commit's staged changes are both work-range + // facts; a clean pick or revert is best seen in this branch's history. + setHistoryRef(null); + if (action === "undo-commit" || r.conflicts?.length) { + setRange("work"); + setMode("overview"); + } + void rereadAll(); + } catch (err) { + if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (laneNow.current === lane) setBusy(false); + } + }; + const fileCtx = useContextMenu(); const treeCtx = useContextMenu(); const commitCtx = useContextMenu(); @@ -1305,6 +1426,45 @@ export function LaneChangesPanel({ copy(commitCtx.menu!.item.subject))}> {t("changes.copySubject")} + + {historyRef ? ( + void historyAct("cherry-pick", commitCtx.menu!.item) + )} + > + {t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })} + + ) : ( + <> + void historyAct("revert", commitCtx.menu!.item) + )} + > + {t("changes.revertCommit")} + + {commits?.[0]?.sha === commitCtx.menu.item.sha && ( + void historyAct("undo-commit", commitCtx.menu!.item) + )} + > + {t("changes.undoCommit")} + + )} + + )} )} @@ -1333,6 +1493,14 @@ export function LaneChangesPanel({ setArmedHunk(null); refreshGit(); await load(); + // Undone under the open editor: it shows the file as it is now (the + // bars only exist while nothing is unsaved, so no draft is lost). + if (action === "discard-hunk" && text?.path === path) { + const r = await api.lanes.gitFile(target, path, "work"); + if (laneNow.current !== lane || !r.available) return; + setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha }); + setDraft(r.lines.join("\n")); + } } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { @@ -1343,8 +1511,10 @@ export function LaneChangesPanel({ // Stage / Undo on each change block of a file with unstaged changes. The diff is // HEAD → working tree, so a staged-only file gets none: undoing its tree // would leave the index holding the change. + const hunkActable = (f: LaneChangeFile) => + range === "work" && f.unstaged && !f.untracked && !f.binary; const hunkActions = (f: LaneChangeFile) => - range === "work" && f.unstaged && !f.untracked && !f.binary + hunkActable(f) ? (change: LaneDiffChange) => { const key = `${f.path}\n${change.hunk.header}\n${change.block}`; const armed = armedHunk === key; @@ -1382,6 +1552,19 @@ export function LaneChangesPanel({ : undefined; const commitStaged = async () => { + // Amend rewrites the last commit: asked, like every history rewrite. + if ( + amend && + !(await confirmDialog({ + title: t("changes.amendTitle"), + message: t("changes.amendConfirm", { + subject: git?.available ? git.subject : "", + }), + confirmLabel: t("changes.amend"), + })) + ) { + return; + } const lane = laneId; setBusy(true); setError(null); @@ -1389,9 +1572,20 @@ export function LaneChangesPanel({ // `only: "staged"` — the whole point of staging file by file is that the // commit takes the index as it stands, with no `add -A` sweeping the // rest of the tree in behind it. - await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" }); + if (amend) { + // No message keeps the old one (`--no-edit`). + // `sha`: the commit the dialog named (ESTALE if HEAD moved since). + const head = git?.available ? git.head : null; + await api.lanes.gitAction(target, "amend", { + ...(message.trim() ? { message: message.trim() } : {}), + ...(head ? { sha: head } : {}), + }); + } else { + await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" }); + } if (laneNow.current !== lane) return; setMessage(""); + setAmend(false); refreshGit(); await load(); } catch (err) { @@ -1453,9 +1647,17 @@ export function LaneChangesPanel({ ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path)) : []; const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; + // "vs base" needs a base, and means nothing on the base branch itself. An + // older server reports no `base` at all: keep the toggle as it was. + const base = git?.available ? git.base : undefined; + const showBranchRange = + base === undefined || (base !== null && !(git?.available && git.branch === base)); // The armed path left the list (committed, reverted, lane switched): disarm, // so a later reappearance starts from the first click again. + useEffect(() => { + if (!showBranchRange && range === "branch") setRange("work"); + }, [showBranchRange, range]); useEffect(() => { if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) { setConfirmDiscard(null); @@ -1622,35 +1824,44 @@ export function LaneChangesPanel({ {mode !== "history" && mode !== "browse" && (
-
- {(["work", "branch"] as const).map((r) => ( - - ))} - {range === "commit" && sha && ( - - {sha.slice(0, 7)} - - )} -
+ {/* With nothing to compare against, "Uncommitted" alone would be a + toggle with one side: none is drawn, unless a commit is open — + then Uncommitted is the way back. */} + {(showBranchRange || (range === "commit" && sha)) && ( +
+ {(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map( + (r) => ( + + ) + )} + {range === "commit" && sha && ( + + {sha.slice(0, 7)} + + )} +
+ )} {(mode === "file" || mode === "review") && (
@@ -1758,8 +1969,8 @@ export function LaneChangesPanel({ > {git.conflicts - ? t("changes.mergeBanner", { count: git.conflicts }) - : t("changes.mergeReady")} + ? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" }) + : t("changes.mergeReady", { op: git.operation ?? "merge" })}
)} @@ -1796,6 +2009,29 @@ export function LaneChangesPanel({ > {mode === "history" ? (
    + {historyBranches.length > 1 && ( +
  • + {t("changes.historyOf")} + +
  • + )} {(() => { const list = commits ?? []; const graph = commitGraph(list); @@ -1875,10 +2111,16 @@ export function LaneChangesPanel({ value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => { - if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged(); + if (isSubmitEnter(e) && (message.trim() || amend) && !busy) { + void commitStaged(); + } }} - placeholder={t("changes.commitPlaceholder")} - aria-label={t("changes.commitPlaceholder")} + placeholder={t( + amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder" + )} + aria-label={t( + amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder" + )} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> +
)} @@ -2184,6 +2440,21 @@ export function LaneChangesPanel({ onSave={() => { if (!busy) void saveDraft(); }} + // The same Undo / Keep as the diff, on the file on + // disk — so only while the editor holds no unsaved text. + onBlock={ + current && hunkActable(current) && !dirty && !busy + ? (action: BlockAction, b: EditorBlock) => { + const change = gitChangeFor(current, b); + if (!change) return setError(t("changes.blockNotInDiff")); + void actHunk( + action === "keep" ? "stage-hunk" : "discard-hunk", + current.path, + change + ); + } + : undefined + } /> ) diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index 84020e9d..91e4496c 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -19,8 +19,8 @@ * is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt * in, so a remote wanting a login opens a form here and the SAME action is * resent with the typed credential — the token is never rendered back. - * Fetch, pull, push and publish say so in a toast when they succeed; a merge - * asks through the themed confirm; the rows wrap in a narrow panel. + * Fetch, pull, push and publish say so in a toast when they succeed; a merge, + * a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel. * @author Nguyễn Ngọc Trí Vĩ */ @@ -234,6 +234,45 @@ export function LaneGitBar({ await refreshBranches(); }; + // Writes that leave this machine (push, publish) or delete a ref ask + // first. Asked HERE, at the click — never inside `run` — so the retry a git + // login sends is not asked twice. + const confirmRun = async ( + opts: { title: string; message: string; destructive?: boolean }, + action: Action, + body?: RunBody + ) => { + if (await confirmDialog({ destructive: false, confirmLabel: opts.title, ...opts })) { + void run(action, body); + } + }; + const push = () => + confirmRun( + git.upstream + ? { + title: t("gitBar.push"), + message: t("gitBar.pushConfirm", { + count: git.ahead ?? 0, + branch: git.branch, + upstream: git.upstream, + }), + } + : { + title: t("gitBar.publishBranch"), + message: t("gitBar.publishBranchConfirm", { branch: git.branch }), + }, + "push" + ); + const publish = () => + confirmRun( + { + title: t("gitBar.publishApply"), + message: t("gitBar.publishConfirm", { branch: git.branch, url: url.trim() }), + }, + "publish", + { url: url.trim() } + ); + const merge = async (branch: string) => { const ok = await confirmDialog({ title: t("gitBar.merge"), @@ -322,7 +361,11 @@ export function LaneGitBar({ icon={} busy={busy === "pull"} disabled={!git.hasRemote || !!busy || merging} - title={merging ? t("gitBar.merging") : t("gitBar.pullHint")} + title={ + merging + ? t("gitBar.merging", { op: git.operation ?? "merge" }) + : t("gitBar.pullHint") + } onClick={() => run("pull")} /> run("push")} + onClick={() => void push()} /> {!git.hasRemote && ( 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint") @@ -434,7 +477,17 @@ export function LaneGitBar({ busy={busy === "branch-delete"} disabled={!picked || picked === git.branch || !!heldBy.get(picked)} title={t("gitBar.branchDeleteHint")} - onClick={() => void run("branch-delete", { branch: picked })} + onClick={() => + void confirmRun( + { + title: t("gitBar.branchDelete"), + message: t("gitBar.branchDeleteConfirm", { branch: picked }), + destructive: true, + }, + "branch-delete", + { branch: picked } + ) + } /> {dirty > 0 && ( {t("gitBar.switchDirty")} @@ -474,7 +527,7 @@ export function LaneGitBar({ value={url} onChange={(e) => setUrl(e.target.value)} onKeyDown={(e) => { - if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() }); + if (isSubmitEnter(e) && url.trim()) void publish(); }} // The server decides which URLs are acceptable (https without a // token, ssh, user@host:path); this only names the two usual ones. @@ -488,7 +541,7 @@ export function LaneGitBar({ busy={busy === "publish"} disabled={!url.trim()} title={t("gitBar.publishHint")} - onClick={() => void run("publish", { url: url.trim() })} + onClick={() => void publish()} />
)} diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index acb88cd5..dd3293c6 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -6,7 +6,8 @@ * prompt (the session view's tab bar `+` only starts a bare CLI or a shell, * `ConsoleTabs.tsx`). Inside * the box, like Claude Desktop's: AI (the session's own tool, the workspace's - * by default) and one Model chip (`ModelMenu`: the tool's own list or the + * by default; its last row, **Open terminal**, is no AI — it starts a plain + * shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the * proxy's models, plus Effort); under it the quieter choices — AI Proxy * (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, * a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it @@ -38,6 +39,7 @@ import { Pencil, ArrowUp, Plus, + SquareTerminal, X, } from "lucide-react"; import { @@ -101,6 +103,9 @@ type HomeProps = Parameters[0]; /** Keyed by workspace: every piece of state below (a half-typed prompt, the * model, an open folder form, a late upload's answer) belongs to ONE * workspace, and the page swaps `lane` without unmounting. */ +/** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */ +const OPEN_TERMINAL = "__terminal__"; + export function WorkspaceHome(props: HomeProps) { return ; } @@ -311,6 +316,24 @@ function HomeBody({ } }; + // The AI menu's Open terminal: a plain shell in the first free slot, opened + // at once — the same start the session view's tab bar `+` makes. + const openTerminal = async () => { + if (startingRef.current) return; + startingRef.current = true; + setStarting(true); + setError(null); + try { + const runId = await startInFreeTab(lane.id, { shell: true }); + navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); + } catch (err) { + setError(message(err)); + } finally { + startingRef.current = false; + setStarting(false); + } + }; + const text = promptWithImages(prompt, images); const start = async () => { // Enter reaches here past the disabled button: the same guards again. @@ -518,19 +541,29 @@ function HomeBody({ label={t("home.ai")} value={tool} onChange={(v) => { + // Not a tool: an action. The AI chosen for the prompt stays. + if (v === OPEN_TERMINAL) return void openTerminal(); setTool(v); setBypass(false); }} - options={toolOptions.map((id) => ({ - value: id, - label: aiTools.label(id), - icon: ( - - ), - }))} + options={[ + ...toolOptions.map((id) => ({ + value: id, + label: aiTools.label(id), + icon: ( + + ), + })), + { + value: OPEN_TERMINAL, + label: t("home.openTerminal"), + icon: , + divider: true, + }, + ]} />
diff --git a/client/src/components/lanes/WorkspaceSessionList.tsx b/client/src/components/lanes/WorkspaceSessionList.tsx index 9338d561..4777c89f 100644 --- a/client/src/components/lanes/WorkspaceSessionList.tsx +++ b/client/src/components/lanes/WorkspaceSessionList.tsx @@ -12,10 +12,8 @@ * (`?lane=&run=`, its tab in the session view; a live tab whose * session has no row yet — a bare tab before its first prompt — is listed as * **New session** from the run itself, and a live Terminal tab (`tool: "shell"`, - * which never has a session) as **Terminal** — and under the sessions the last - * `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose - * click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or - * right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens + * which never has a session) as **Terminal** while it is open — a closed one + * leaves no row; Ctrl/Cmd+click opens * it in a split beside the focused pane) — attaching, never starting or * resuming anything; * any other session opens in its workspace too (`?lane=&session=`, @@ -32,24 +30,20 @@ import { useEffect, useState, type ReactNode } from "react"; import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; -import { Archive, GitBranch, SquareTerminal, X } from "lucide-react"; +import { Archive, GitBranch, SquareTerminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { api, type RunHandle, type RunHistoryItem } from "../../lib/api"; -import { toast } from "../../lib/toast"; -import { startInFreeTab } from "./startSession"; +import { api, type RunHandle } from "../../lib/api"; import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types"; import { timeAgo } from "../../lib/format"; import { ConfirmModal } from "../ConfirmModal"; import { AiToolIcon } from "../AiToolIcon"; import { useAiTools } from "../../hooks/useAiTools"; import { useThrottledValue } from "../../hooks/useThrottledValue"; -import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; +import { useContextMenu } from "../ContextMenu"; import { SessionMenu } from "./SessionMenu"; export const SESSION_LIMIT = 6; -/** Closed Terminal tabs listed under a workspace's sessions. */ -export const CLOSED_TERMINALS = 3; /** * The live pane holding a session: the run the hooks bound it to @@ -278,43 +272,6 @@ export function WorkspaceSessionList({ 3000 ); const refreshKey = `${hookKey}:${version}`; - // A Terminal tab closing is when one joins the history. - const shellKey = liveShells.map((r) => r.id).join(","); - const [closedShells, setClosedShells] = useState([]); - useEffect(() => { - if (hidden) return; - let live = true; - api.run - // Live ones come back too (isLive): ask for enough to fill the list anyway. - .history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 }) - .then( - (r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS)) - ) - .catch(() => undefined); - return () => { - live = false; - }; - }, [lane.id, shellKey, hidden]); - const reopenShell = async (split: boolean) => { - try { - const runId = await startInFreeTab(lane.id, { shell: true }); - navigate( - `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`, - split ? { state: { split: true } } : undefined - ); - } catch (err) { - toast.error(err); - } - }; - const closedCtx = useContextMenu(); - const forgetShell = async (r: RunHistoryItem) => { - try { - await api.run.forgetHistory(r.id); - setClosedShells((all) => all.filter((x) => x.id !== r.id)); - } catch (err) { - toast.error(err); - } - }; const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey); const sessions = own?.sessions ?? (own === null ? null : []); const more = own ? Math.max(0, own.total - own.sessions.length) : 0; @@ -440,9 +397,7 @@ export function WorkspaceSessionList({ {error}

); - // Not during a search, like the live ones: they have no name to match. - const closed = q ? [] : closedShells; - if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) { + if (sessions !== null && shown.length === 0 && fresh.length === 0) { return ( <>

{t("tree.noSessions")}

@@ -599,42 +554,6 @@ export function WorkspaceSessionList({ ); })} - {closed.map((r) => ( -
  • - - -
  • - ))} {more > 0 && !q && (
    -` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `/.claude/worktrees/` on branch `worktree-`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`. - **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane--`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`. -- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`. +- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab, or **Open terminal** in the workspace home's AI menu): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`. Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected. @@ -572,13 +572,14 @@ that file. file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on the response and on the file it cut. - Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd - git cannot read; `400 ENOBASE` when `range=branch` on a workspace with no base - branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range. + git cannot read; `range=branch` compares against the workspace's base branch, + else the repo's default branch (what `origin/HEAD` names, else `main`/`master`) + — `400 ENOBASE` when neither exists — and `400 ENOFILECHANGE` for a `path` with no changes in that range. #### Read a workspace's commits, a file, or the file tree ```http -GET /api/lanes/:id/git/log?limit=30[&file=] +GET /api/lanes/:id/git/log?limit=30[&file=][&ref=] GET /api/lanes/:id/git/file?path=[&rev=work|HEAD|] PUT /api/lanes/:id/git/file {path, content, baseSha} GET /api/lanes/:id/git/raw?path=[&rev=work|HEAD|] @@ -595,7 +596,9 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read. refs, parents}]}`, newest first in `--date-order` (no parent before its children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the commits touching that path — relative, no `..` segment, else - `400 EBADPATH`. Reading one commit's diff is + `400 EBADPATH`. `ref=` reads another branch's history instead of HEAD's + (where the panel's Cherry-pick finds its commits): a local or + remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is `/git/changes?range=commit&sha=`. - **`PUT /git/file`** saves the whole-file editor (same-origin guarded; `server/lib/lane-file-write.js`). Same path guards as the read, plus: the @@ -723,7 +726,11 @@ cannot collide on `index.lock`. | `merge` | `git merge --no-edit ` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` | | `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` | | `merge-commit` | `git commit --no-edit` — concludes the merge | — | -| `merge-abort` | `git merge --abort` | `{"confirm": true}` | +| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` | +| `cherry-pick` | `git cherry-pick ` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` | +| `revert` | `git revert --no-edit ` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` | +| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": ""}` | +| `amend` | `git commit --amend -m `, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands | `{"message": "...", "sha": ""}` | ```json { "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" } @@ -731,8 +738,29 @@ cannot collide on `index.lock`. - `pull` is **always** `--ff-only` and `switch` refuses a dirty tree (`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a - running agent. There is deliberately no rebase, reset or stash here — - `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`. + running agent. There is deliberately no rebase, hard reset or stash here — + a hard `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`. +- **Cherry-pick and revert** follow merge: a commit named by hex sha that + exists (`400 EBADSHA`) and is not a merge commit (`400 EMERGECOMMIT`), a + clean tree, a repo-root workspace. A conflict is a **200** with + `conflicts[]`, resolved with `resolve` and finished by `merge-commit` (which + keeps a pick's original author) or dropped by `merge-abort`; `GET /git` then + reports `merging: true` and `operation: "cherry-pick"|"revert"`. A pick or + revert whose changes are already there is aborted again and answers + `409 EEMPTY`, never left open; one git could not commit (no identity, a + failing signer) is undone the same way and answers git's own error. A + rebase stopped in a terminal makes every history action, `merge-commit` and + `merge-abort` `409 EMERGING` — it is finished where it started. +- **Undo commit and amend** rewrite the last commit, so both are + `409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE` + when HEAD is no longer the `sha` the request names (`400 EBADSHA` without + one — an agent may have committed since the UI drew the commit), and + `400 ESUBDIR` outside a repo-root workspace. `undo-commit` needs + `confirm: true` and refuses + the first commit (`400 ENOPARENT`) and a merge commit (`400 EMERGECOMMIT`); + it never touches the working tree. `amend` with neither a message nor a + staged change is `400 ENOCHANGES`. All four are `409 EMERGING` while a + merge, cherry-pick or revert is open. - **Merge** (issue #45) happens only when asked for by name: on a clean tree (`400 EDIRTY`), from a workspace at the repo root (`400 ESUBDIR` — a merge is repo-wide), for a branch listed under `refs/heads` or `refs/remotes` diff --git a/docs/LANES.md b/docs/LANES.md index 9be9c4b4..7589152e 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -40,8 +40,8 @@ Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs The modal has a segmented toggle mirroring the CLI's two modes: -- **Repo** — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on). -- **Worktree** (default) — provisions a managed worktree (maps to `agenthub workspace add --repo`). +- **Repo** (default) — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on). +- **Worktree** — provisions a managed worktree (maps to `agenthub workspace add --repo`). The git bar's "new workspace from this branch" opens the modal here, with the branch filled in. In Worktree mode the branch question has three answers, because git's own rule decides which is available (issue #34): @@ -93,7 +93,7 @@ Both modes' path field has a **Browse** button next to it that opens the operati ## Workspace tool: Claude or Codex -A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row. +A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's (its last row, **Open terminal**, is no tool: it opens a plain shell tab right away, `shell: true` in the first free slot, and leaves the picked AI alone) — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row. - **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session. - **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.) @@ -636,17 +636,22 @@ reads the workspace's git surface in five modes: `/git/changes`): **Changes** folds the unchanged stretches away (a click on a fold opens it), **Whole file** shows them, and the toggle keeps the draft. Unified puts the removed lines read-only above their replacement; side by - side puts the original, read-only, left of the file. Every changed block - carries a **Revert** (`→` between split columns) that puts the original back - in the editor — nothing is written until Save. The panel opens side by side + side puts the original, read-only, left of the file. While the editor holds + no unsaved text, every changed block of a file with unstaged changes + carries the review bar's **Undo** (asks twice) / **Keep** under its last + line — the same `discard-hunk` / `stage-hunk` as the diff, on the file on + disk, matched to git's own change block by its line span (a block git + splits differently says so instead of acting); an Undo reloads the editor. + Once you type, the bar steps aside and each block carries a **Revert** + (`→` between split columns) that puts the original back in the editor — + nothing is written until Save. The panel opens side by side at 50/50 and full width (Workspace's `wide`) and unified when narrower; the layout button overrides it until the next resize. The colours follow every keystroke: **green** is a line added whole, **red** a line removed whole, **blue** a line edited in place (it keeps a letter or digit of its old text), its edit in a stronger blue; the read-only diffs (Review, a commit, a file the editor cannot take) use the same scheme, and only they carry the - per-block Stage/Undo review bars and the line-comment buttons — Review - keeps both for every file. In a read-only side-by-side diff, every row is at + line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at least one line tall (`MIN_ROW`), so an empty filler row keeps the two columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown as the picture (`/git/raw`), and a conflicted file opens in the @@ -695,10 +700,13 @@ reads the workspace's git surface in five modes: opening one shows that commit's diff (`?range=commit&sha=`). A range toggle switches the comparison between **Uncommitted** (HEAD vs the -working tree, untracked files included as an all-added patch) and **vs base** -(`...HEAD` — what this workspace's branch adds on top of `base_branch`; a workspace -with no base says so rather than guessing one). Opening a commit from History -adds a third range, shown as a short-sha chip. +working tree, untracked files included as an all-added patch) and **Compared to +** (`...HEAD` — what this branch adds on top of the workspace's +`base_branch`, else the repo's default branch: what `origin/HEAD` names, else +`main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or +on the base branch itself, there is nothing to compare and no toggle is drawn. +Opening a commit from History adds a third range, shown as a short-sha chip +beside **Uncommitted**, the way back. The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side** toggle, syntax highlighting from the tokenizer the conversation view already diff --git a/openapi.yaml b/openapi.yaml index b6a40227..f186eb8a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ openapi: 3.0.3 info: title: Agent Dashboard for Claude Code API - version: 2.5.10 + version: 2.5.11 description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. contact: name: Nguyễn Ngọc Trí Vĩ @@ -10623,7 +10623,7 @@ paths: 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). + description: Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat). example: shell responses: '200': @@ -10664,7 +10664,7 @@ paths: tags: - Run summary: Forget a closed Terminal tab - description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies. + description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies. operationId: runHistoryForget parameters: - name: id @@ -11487,7 +11487,7 @@ paths: '200': description: '{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.' '400': - description: ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV. + description: ENOBASE (range=branch with neither a lane base branch nor a repo default branch to compare against), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV. '404': description: Lane not found. /api/lanes/{id}/git/log: @@ -11495,7 +11495,7 @@ paths: tags: - Lanes summary: The lane's recent commits - description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard. + description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard. operationId: listLaneCommits parameters: - name: id @@ -11513,11 +11513,15 @@ paths: in: query schema: type: string + - name: ref + in: query + schema: + type: string responses: '200': description: '{available, commits:[{sha, short, subject, author, date, refs}]}.' '400': - description: EBADPATH (file= is absolute or has a `..` segment). + description: EBADPATH (file= is absolute or has a `..` segment), ENOBRANCH (ref= is not a branch). '404': description: Lane not found. /api/lanes/{id}/git/file: @@ -11737,6 +11741,10 @@ paths: - branch-create - branch-delete - publish + - cherry-pick + - revert + - undo-commit + - amend requestBody: required: false content: @@ -11749,7 +11757,10 @@ paths: description: publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars. message: type: string - description: commit only; required, max 4000 chars. + description: commit (required) and amend (optional — none keeps the old message); max 4000 chars. + sha: + type: string + description: cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR). only: type: string enum: diff --git a/package-lock.json b/package-lock.json index 6993be6a..97cdb9f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenthub", - "version": "2.5.10", + "version": "2.5.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenthub", - "version": "2.5.10", + "version": "2.5.11", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/package.json b/package.json index 14ecbbfb..373ab0a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.5.10", + "version": "2.5.11", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/README.md b/server/README.md index dc960315..fe2ee380 100644 --- a/server/README.md +++ b/server/README.md @@ -538,7 +538,7 @@ Request body shape: | `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the workspace's log directory. | | `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. | | `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The workspace's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. | -| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path. | +| `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). | | `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. | | `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. | | `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. | @@ -561,7 +561,7 @@ Request body shape: | `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. | | `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. | | `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. | -| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). | +| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. No rebase/hard reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). | The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be an existing absolute git repository. The response workspace starts as diff --git a/server/__tests__/dashboard-runs.test.js b/server/__tests__/dashboard-runs.test.js index b5c34638..b0a42100 100644 --- a/server/__tests__/dashboard-runs.test.js +++ b/server/__tests__/dashboard-runs.test.js @@ -61,7 +61,7 @@ describe("dashboard-runs", () => { assert.equal(runs.getRun("agenthub-lane-9-1"), null); }); - it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => { + it("drops every closed Terminal tab of a lane, live ones untouched", () => { for (let i = 1; i <= 5; i++) { start(`agenthub-lane-11-${i}`, { laneId: 11, @@ -73,12 +73,7 @@ describe("dashboard-runs", () => { // Pane 5 still runs; every other one has closed. runs.reconcileOrphans((id) => id === "agenthub-lane-11-5"); const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id); - assert.deepEqual(shells, [ - "agenthub-lane-11-5", - "agenthub-lane-11-4", - "agenthub-lane-11-3", - "agenthub-lane-11-2", - ]); + assert.deepEqual(shells, ["agenthub-lane-11-5"]); assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays"); }); diff --git a/server/__tests__/git-diff-ops.test.js b/server/__tests__/git-diff-ops.test.js index 57ca5348..b28b1664 100644 --- a/server/__tests__/git-diff-ops.test.js +++ b/server/__tests__/git-diff-ops.test.js @@ -203,10 +203,12 @@ describe("collectChanges — branch range", () => { assert.equal(out.files[0].added, 1); }); - it("refuses when the lane has no base to measure against", async () => { - await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), { - code: "ENOBASE", - }); + it("falls back to the default branch without a base, and refuses a base that is gone", async () => { + // No recorded base (an adopted folder): what origin/HEAD names. + assert.equal( + (await collectChanges(LANE, { range: "branch", base: null })).base, + "development" + ); await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), { code: "ENOBASE", }); diff --git a/server/__tests__/git-ops-history.test.js b/server/__tests__/git-ops-history.test.js new file mode 100644 index 00000000..bed0d15e --- /dev/null +++ b/server/__tests__/git-ops-history.test.js @@ -0,0 +1,290 @@ +/** + * @file The history actions against a REAL repo: cherry-pick and revert + * (clean tree, repo-root lane, a commit that exists and is not a merge, a + * conflict reported and finished/aborted through merge-commit/merge-abort, + * an empty pick undone rather than left open), undo-commit (confirm, never a + * pushed commit, never the root or a merge commit, changes kept staged) and + * amend (never a pushed commit), plus a log read of another branch and the + * "vs base" range falling back to the repo's default branch. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const { runGitAction } = require("../lib/git-ops"); +const { gitFacts } = require("../lib/worktree"); +const { listCommits, collectChanges } = require("../lib/git-diff"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-history-")); +const REPO = path.join(ROOT, "repo"); +const REMOTE = path.join(ROOT, "remote.git"); + +const g = (...args) => { + const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + delete env.GIT_INDEX_FILE; + return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env }); +}; +const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body); +const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8"); +const sha = (ref) => g("rev-parse", ref).trim(); +const subject = (ref = "HEAD") => g("log", "-1", "--format=%s", ref).trim(); + +// main: init -> main ; feat (from init): feat-a (edits a.txt), feat-b (adds b.txt) +beforeEach(() => { + fs.rmSync(REPO, { recursive: true, force: true }); + fs.rmSync(REMOTE, { recursive: true, force: true }); + fs.mkdirSync(path.join(REPO, "sub"), { recursive: true }); + g("init", "-q", "-b", "main"); + g("config", "user.email", "t@h"); + g("config", "user.name", "t"); + write("a.txt", "base\n"); + write("sub/s.txt", "s\n"); + g("add", "-A"); + g("commit", "-qm", "init"); + g("switch", "-qc", "feat"); + write("a.txt", "theirs\n"); + g("commit", "-qam", "feat-a"); + write("b.txt", "only on feat\n"); + g("add", "-A"); + g("commit", "-qm", "feat-b"); + g("switch", "-q", "main"); + write("a.txt", "ours\n"); + g("commit", "-qam", "main"); +}); + +describe("cherry-pick", () => { + it("applies one commit from another branch", async () => { + const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat") }); + assert.deepEqual(r.conflicts, []); + assert.equal(read("b.txt"), "only on feat\n"); + assert.equal(subject(), "feat-b"); + }); + + it("reports a conflict, then finishes it with merge-commit keeping the author", async () => { + g("switch", "-qc", "other", "main~1"); + write("a.txt", "other\n"); + g("-c", "user.name=Other", "-c", "user.email=o@h", "commit", "-qam", "by-other"); + g("switch", "-q", "main"); + const r = await runGitAction(REPO, "cherry-pick", { sha: sha("other") }); + assert.deepEqual(r.conflicts, ["a.txt"]); + const facts = await gitFacts(REPO); + assert.equal(facts.merging, true); + assert.equal(facts.operation, "cherry-pick"); + await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { code: "EMERGING" }); + await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" }); + await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" }); + await runGitAction(REPO, "merge-commit"); + assert.equal((await gitFacts(REPO)).merging, false); + assert.equal(subject(), "by-other"); + assert.equal(read("a.txt"), "other\n"); + assert.equal(g("log", "-1", "--format=%an", "HEAD").trim(), "Other"); + }); + + it("aborts a conflicted pick with confirm, back to where it started", async () => { + const before = sha("HEAD"); + await runGitAction(REPO, "cherry-pick", { sha: sha("feat~1") }); + await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" }); + await runGitAction(REPO, "merge-abort", { confirm: true }); + assert.equal((await gitFacts(REPO)).merging, false); + assert.equal(sha("HEAD"), before); + assert.equal(read("a.txt"), "ours\n"); + }); + + it("undoes an empty pick instead of leaving it open", async () => { + await runGitAction(REPO, "cherry-pick", { sha: sha("feat") }); + await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), { + code: "EEMPTY", + }); + assert.equal((await gitFacts(REPO)).merging, false); + }); + + it("refuses a dirty tree, a bad or unknown sha, a merge commit and a subdirectory lane", async () => { + write("a.txt", "wip\n"); + await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), { + code: "EDIRTY", + }); + g("checkout", "--", "a.txt"); + for (const bad of [undefined, "", "--help", "HEAD", "zzzz", "feat"]) { + await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: bad }), { code: "EBADSHA" }); + } + await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: "deadbeefdeadbeef" }), { + code: "EBADSHA", + }); + g("merge", "-q", "-X", "ours", "--no-edit", "feat"); + await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { + code: "EMERGECOMMIT", + }); + await assert.rejects( + runGitAction(path.join(REPO, "sub"), "cherry-pick", { sha: sha("feat") }), + { code: "ESUBDIR" } + ); + }); +}); + +describe("revert", () => { + it("adds a commit that undoes an older one", async () => { + g("switch", "-q", "feat"); + const r = await runGitAction(REPO, "revert", { sha: sha("feat~1") }); + assert.deepEqual(r.conflicts, []); + assert.equal(read("a.txt"), "base\n"); + assert.match(subject(), /^Revert "feat-a"/); + }); + + it("reports a conflict and aborts it", async () => { + write("a.txt", "later\n"); + g("commit", "-qam", "later"); + await runGitAction(REPO, "revert", { sha: sha("HEAD~1") }); + assert.equal((await gitFacts(REPO)).operation, "revert"); + await runGitAction(REPO, "merge-abort", { confirm: true }); + assert.equal((await gitFacts(REPO)).merging, false); + assert.equal(read("a.txt"), "later\n"); + }); +}); + +describe("undo-commit", () => { + it("drops the last commit and keeps its changes staged", async () => { + await assert.rejects(runGitAction(REPO, "undo-commit"), { code: "ECONFIRM" }); + const parent = sha("HEAD~1"); + await runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }); + assert.equal(sha("HEAD"), parent); + assert.equal(read("a.txt"), "ours\n"); + assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt"); + }); + + it("never touches a pushed commit, the root commit or a merge commit", async () => { + execFileSync("git", ["init", "-q", "--bare", REMOTE]); + g("remote", "add", "origin", REMOTE); + g("push", "-q", "origin", "main"); + await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { + code: "EPUSHED", + }); + await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: sha("HEAD") }), { + code: "EPUSHED", + }); + + g("switch", "-q", "--orphan", "solo"); + g("commit", "-qm", "root", "--allow-empty"); + await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { + code: "ENOPARENT", + }); + + g("switch", "-q", "-f", "feat"); + g("merge", "-q", "--no-ff", "--no-edit", "-X", "ours", "main"); + await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { + code: "EMERGECOMMIT", + }); + }); +}); + +describe("amend", () => { + it("rewrites the message, or folds staged changes in keeping it", async () => { + const parent = sha("HEAD~1"); + await runGitAction(REPO, "amend", { message: "main, reworded", sha: sha("HEAD") }); + assert.equal(subject(), "main, reworded"); + assert.equal(sha("HEAD~1"), parent); + + await assert.rejects(runGitAction(REPO, "amend", { sha: sha("HEAD") }), { code: "ENOCHANGES" }); + write("c.txt", "c\n"); + g("add", "c.txt"); + await runGitAction(REPO, "amend", { sha: sha("HEAD") }); + assert.equal(subject(), "main, reworded"); + assert.equal(sha("HEAD~1"), parent); + assert.match(g("show", "--name-only", "--format=", "HEAD"), /c\.txt/); + }); +}); + +describe("log of another branch", () => { + it("lists a named branch's commits and refuses a ref that is not a branch", async () => { + const commits = await listCommits(REPO, { ref: "feat" }); + assert.deepEqual( + commits.map((c) => c.subject), + ["feat-b", "feat-a", "init"] + ); + await assert.rejects(listCommits(REPO, { ref: "--all" }), { code: "ENOBRANCH" }); + await assert.rejects(listCommits(REPO, { ref: "nope" }), { code: "ENOBRANCH" }); + }); +}); + +describe("vs base without a recorded base", () => { + it("compares against the repo's default branch", async () => { + g("switch", "-q", "feat"); + const r = await collectChanges(REPO, { range: "branch", base: null }); + assert.equal(r.base, "main"); + assert.deepEqual( + r.files.map((f) => f.path), + ["a.txt", "b.txt"] + ); + }); +}); + +describe("review fixes", () => { + it("undo and amend act only on the HEAD the user saw", async () => { + const seen = sha("HEAD"); + write("a.txt", "agent\n"); + g("commit", "-qam", "agent committed meanwhile"); + await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: seen }), { + code: "ESTALE", + }); + await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: seen }), { + code: "ESTALE", + }); + await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true }), { code: "EBADSHA" }); + assert.equal(subject(), "agent committed meanwhile"); + }); + + it("a pick or revert that fails at commit time reports git's error and leaves the tree clean", async () => { + g("config", "commit.gpgsign", "true"); + g("config", "gpg.program", "false"); + for (const [action, ref] of [["cherry-pick", "feat"], ["revert", "HEAD"]]) { + const before = sha("HEAD"); + await assert.rejects(runGitAction(REPO, action, { sha: sha(ref) }), (e) => { + assert.notEqual(e.code, "EEMPTY"); + assert.match(e.git?.stderr || "", /gpg/i); + return true; + }); + assert.equal(sha("HEAD"), before); + assert.equal(g("status", "--porcelain"), ""); + assert.equal((await gitFacts(REPO)).merging, false); + } + }); + + it("a revert of something already reverted is EEMPTY", async () => { + const target = sha("HEAD"); + await runGitAction(REPO, "revert", { sha: target }); + g("revert", "--no-edit", "HEAD"); + write("a.txt", "base\n"); + g("commit", "-qam", "undo by hand"); + await assert.rejects(runGitAction(REPO, "revert", { sha: target }), { code: "EEMPTY" }); + assert.equal(g("status", "--porcelain"), ""); + }); + + it("amend refuses a subdirectory lane", async () => { + await assert.rejects( + runGitAction(path.join(REPO, "sub"), "amend", { message: "x", sha: sha("HEAD") }), + { code: "ESUBDIR" } + ); + }); + + it("a rebase started in a terminal blocks every history action", async () => { + g("switch", "-q", "feat"); + const env = { ...process.env, GIT_SEQUENCE_EDITOR: "sed -i 1s/^pick/edit/" }; + delete env.GIT_DIR; + execFileSync("git", ["rebase", "-q", "-i", "HEAD~2"], { cwd: REPO, env, stdio: "ignore" }); + for (const [action, body] of [ + ["undo-commit", { confirm: true, sha: sha("HEAD") }], + ["amend", { message: "x", sha: sha("HEAD") }], + ["cherry-pick", { sha: sha("main") }], + ["merge-commit", {}], + ]) { + await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }); + } + g("rebase", "--abort"); + }); +}); diff --git a/server/__tests__/update-check.test.js b/server/__tests__/update-check.test.js index 7efd394b..7b8cd63d 100644 --- a/server/__tests__/update-check.test.js +++ b/server/__tests__/update-check.test.js @@ -12,7 +12,7 @@ const os = require("os"); const path = require("path"); const { execFileSync } = require("child_process"); -const { getUpdatesStatus } = require("../lib/update-check"); +const { getUpdatesStatus, npmInvocation } = require("../lib/update-check"); // Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this // very suite) sets in its own environment — those leak to every child @@ -182,3 +182,20 @@ describe("getUpdatesStatus — no remotes configured", () => { assert.match(result.message, /No git remotes configured/); }); }); + +describe("npmInvocation (the checkout's Update now)", () => { + it("drops NODE_ENV so `npm run setup` keeps the client's build toolchain", () => { + // The server defaults NODE_ENV=production; npm then omits devDependencies, + // and vite/typescript are dev deps — the `npm run build` after it failed. + const npm = npmInvocation(["run", "setup"], { NODE_ENV: "production", PATH: "/usr/bin" }); + assert.equal(npm.env.NODE_ENV, undefined); + assert.equal(npm.env.PATH.split(path.delimiter).at(-1), "/usr/bin"); + }); + + it("runs the node's own npm, never a bare npm.cmd through execFile", () => { + // execFile("npm.cmd") with no shell is EINVAL on Node >= 20.12.2 (Windows). + const npm = npmInvocation(["run", "build"], {}); + assert.ok(npm.file === process.execPath || npm.shell === (process.platform === "win32")); + assert.deepEqual(npm.args.slice(-2), ["run", "build"]); + }); +}); diff --git a/server/lib/dashboard-runs.js b/server/lib/dashboard-runs.js index 9c1f9138..6476de21 100644 --- a/server/lib/dashboard-runs.js +++ b/server/lib/dashboard-runs.js @@ -265,19 +265,10 @@ const abandonStmt = db.prepare(` WHERE id = ? AND status IN ('running', 'spawning') `); -/** Closed Terminal tabs kept per lane — what the sidebar lists - * (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */ -const CLOSED_SHELLS_KEPT = 3; -// A shell has no session, so its row is only that list's entry: one the list -// cannot show is dropped, or removing the shown ones uncovers older ones. +// A shell has no session, so its row only tracks the open tab: once closed it +// is dropped — the sidebar keeps no list of closed Terminal tabs. const pruneShellsStmt = db.prepare(` - DELETE FROM dashboard_runs WHERE id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n - FROM dashboard_runs - WHERE tool = 'shell' AND status NOT IN ('running', 'spawning') - ) WHERE n > ${CLOSED_SHELLS_KEPT} - ) + DELETE FROM dashboard_runs WHERE tool = 'shell' AND status NOT IN ('running', 'spawning') `); /** @@ -285,8 +276,8 @@ const pruneShellsStmt = db.prepare(` * gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted. * A pane is a tmux session, which outlives a server restart, so liveness is * asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot - * and on the server's run-heal beat, which also drops the closed Terminal - * tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane. + * and on the server's run-heal beat, which also drops every closed Terminal + * tab's row. * * Returns the number of rows updated. */ diff --git a/server/lib/git-diff.js b/server/lib/git-diff.js index 06393362..9dced2fb 100644 --- a/server/lib/git-diff.js +++ b/server/lib/git-diff.js @@ -258,6 +258,23 @@ async function resolveBaseRef(dir, base) { return null; } +/** + * The repo's default branch, for a lane that recorded no base of its own (an + * adopted folder, a session's `claude --worktree` tree): what `origin/HEAD` + * points at, else `main` or `master` when one exists, else null. + */ +async function defaultBranch(dir) { + const head = await git(dir, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]).then( + (r) => r.stdout.trim(), + () => "" + ); + if (head.startsWith("origin/")) return head.slice("origin/".length); + for (const name of ["main", "master"]) { + if (await resolveBaseRef(dir, name)) return name; + } + return null; +} + /** * `git diff --numstat -z` output → Map. `-z` * because without it git C-quotes any path with a space, a quote or a @@ -501,7 +518,8 @@ async function synthesizeAddedPatch(dir, file) { * @param {string} dir Lane working directory. * @param {object} opts * @param {"work"|"branch"|"commit"} [opts.range="work"] - * @param {string|null} [opts.base] The lane's base branch, for `range="branch"`. + * @param {string|null} [opts.base] The lane's base branch, for `range="branch"`; + * none falls back to the repo's default branch (`defaultBranch`). * @param {string|null} [opts.sha] The commit to read, for `range="commit"`. * @param {string|null} [opts.file] Only this path, with its hunks (single-file mode). * @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when @@ -524,6 +542,7 @@ async function collectChanges( // reads a file's original text there. let mergeBase = null; if (range === "branch") { + base = base || (await defaultBranch(dir)); baseRef = await resolveBaseRef(dir, base); if (!baseRef) { throw badRequest("ENOBASE", "no base branch to compare against"); @@ -618,13 +637,21 @@ async function collectChanges( * @param {object} [opts] * @param {number} [opts.limit=30] Commits to read, clamped to 200. * @param {string|null} [opts.file] Only commits touching this path. + * @param {string|null} [opts.ref] Another branch's history instead of HEAD's + * (where cherry-pick finds its commits): a short branch name, looked up + * with `branchRef` and ENOBRANCH when it is not one. */ -async function listCommits(dir, { limit = 30, file = null } = {}) { +async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) { const n = Math.min(Math.max(Number(limit) || 30, 1), 200); const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f"); // --date-order: no parent before all its children, which the history's // commit graph needs to draw a lane down to each parent. const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`]; + if (ref) { + const full = await worktree.branchRef(dir, ref); + if (!full) throw badRequest("ENOBRANCH", `no branch ${ref}`); + args.push(full); + } // Through the same lexical guard as every other path: in a subdirectory // lane `../x` would list commit metadata for a path outside the lane. if (file) args.push("--", lanePath(file)); @@ -1090,6 +1117,7 @@ module.exports = { readFileAt, readImageAt, resolveBaseRef, + defaultBranch, DEFAULT_CONTEXT, MAX_CONTEXT, MAX_FILES, diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js index ca2a0ab6..95bd951d 100644 --- a/server/lib/git-ops.js +++ b/server/lib/git-ops.js @@ -3,12 +3,20 @@ * stage/unstage a file, commit, discard a file, switch branch, create or * delete a branch, publish a repo that has no remote yet, and merge a branch * with its conflict resolution (issue #45: merge, resolve ours/theirs/ - * hand-edited, merge-commit, merge-abort). There is no rebase, no reset and - * no stash, because a dashboard button is the wrong place to start an - * operation whose recovery needs a terminal (`reset` stays where it was: - * behind the three-check destroy guard in worktree.js). A merge is the + * hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset + * and no stash, because a dashboard button is the wrong place to start an + * operation whose recovery needs a terminal (a hard `reset` stays where it + * was: behind the three-check destroy guard in worktree.js). A merge is the * exception because its recovery is ONE button here too — `merge-abort` puts - * the branch back where it was. + * the branch back where it was — and `cherry-pick` and `revert` follow it: + * same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/ + * `merge-abort` finish or abort whichever of the three git has open. + * + * `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit, + * so both refuse once any remote-tracking branch holds it (EPUSHED): an + * unpushed commit is the only one whose rewrite costs nobody else anything. + * `undo-commit` keeps every change staged, never touches the working tree, + * and still asks for `confirm: true` because the commit itself is dropped. * * Four rules hold for every action: * - argv only, via worktree.js's `git()` — never a shell string. @@ -48,6 +56,8 @@ const { listBranches, branchCheckedOutAt, statusCounts, + operationInProgress, + branchRef, } = require("./worktree"); const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff"); const gitCredentials = require("./git-credentials"); @@ -77,10 +87,41 @@ const ACTIONS = new Set([ "resolve", "merge-commit", "merge-abort", + "cherry-pick", + "revert", + "undo-commit", + "amend", ]); -/** Actions that would tangle with a merge in progress. */ -const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]); +/** Actions that would tangle with a merge, cherry-pick or revert in progress. */ +const BLOCKED_WHILE_MERGING = new Set([ + "pull", + "switch", + "merge", + "stage-hunk", + "discard-hunk", + "cherry-pick", + "revert", + "undo-commit", + "amend", +]); + +/** A commit as the request may name it: an abbreviated or full hex sha + * (64 = a SHA-256 repository). */ +const SHA_RE = /^[0-9a-f]{4,64}$/i; + +/** + * Whether a rebase (started in a terminal) is stopped in this repo. It leaves + * no *_HEAD the dashboard finishes, so it gets no banner and no button — but + * nothing here may move HEAD or commit underneath it either. + */ +async function rebaseInProgress(dir) { + for (const name of ["rebase-merge", "rebase-apply"]) { + const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim(); + if (fs.existsSync(path.resolve(dir, rel))) return true; + } + return false; +} /** A line git writes to open or close a conflict block. */ const MARKER_RE = /^(<{7} |>{7} |={7}$)/m; @@ -280,10 +321,19 @@ async function networkGit(dir, args, body = {}) { */ async function runGitAction(dir, action, body = {}) { if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`); - if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) { - throw bad("EMERGING", "a merge is in progress: finish or abort it first"); + if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") { + // A rebase blocks even Complete/Abort: `commit` mid-rebase would make a + // commit the rebase never planned. It is finished where it was started. + if (await rebaseInProgress(dir)) { + throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first"); + } + const open = BLOCKED_WHILE_MERGING.has(action) && (await operationInProgress(dir)); + if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`); } if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body); + if (["cherry-pick", "revert", "undo-commit", "amend"].includes(action)) { + return historyAction(dir, action, body); + } if (action === "fetch") { return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body)); @@ -475,16 +525,6 @@ async function runGitAction(dir, action, body = {}) { return done(action, await git(dir, ["switch", branch]), branch); } -/** Whether a merge is in progress (MERGE_HEAD exists). */ -async function isMerging(dir) { - try { - await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]); - return true; - } catch { - return false; - } -} - /** Paths git still holds as unmerged, repo-wide. */ async function unmergedPaths(dir) { return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout @@ -499,42 +539,12 @@ async function mergeAction(dir, action, body) { if (!(await isValidBranchName(dir, branch))) { throw bad("EBADBRANCH", "branch is not a valid git branch name"); } - // A merge moves the whole repo, so only a lane that IS the repo may start - // one — a subdirectory lane could not see, let alone resolve, a conflict - // outside its own folder. - if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { - throw bad( - "ESUBDIR", - "a merge changes the whole repository; run it from a workspace at the repo root" - ); - } - if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes"); + await assertCleanRepoRoot(dir, "a merge"); // The ref is looked up, never passed through: git sees the full refname // of a local or remote-tracking branch that exists, nothing else. - const refs = ( - await git(dir, [ - "for-each-ref", - "--format=%(refname:short)%00%(refname)", - "refs/heads", - "refs/remotes", - ]) - ).stdout - .split("\n") - .filter(Boolean) - .map((l) => l.split("\0")); - const ref = refs.find(([short]) => short === branch)?.[1]; + const ref = await branchRef(dir, branch); if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`); - try { - const out = await git(dir, ["merge", "--no-edit", ref]); - return { ...done(action, out, branch), conflicts: [] }; - } catch (e) { - // A conflict is a merge that STARTED: report the files, not a failure. - if (!(await isMerging(dir))) throw e; - return { - ...done(action, e.git || { stdout: "", stderr: "" }, branch), - conflicts: await unmergedPaths(dir), - }; - } + return startOperation(dir, action, ["merge", "--no-edit", ref], branch); } if (action === "resolve") { @@ -564,14 +574,156 @@ async function mergeAction(dir, action, body) { if (action === "merge-abort" && body.confirm !== true) { throw bad("ECONFIRM", "merge-abort requires confirm: true"); } - if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress"); - if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"])); + const open = await operationInProgress(dir); + if (!open) throw bad("ENOMERGE", "no merge, cherry-pick or revert in progress"); + if (action === "merge-abort") return done(action, await git(dir, [open, "--abort"])); // merge-commit const left = await unmergedPaths(dir); if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`); + // `commit` finishes all three: it takes MERGE_MSG, a pick keeps its + // original author, and git clears the *_HEAD it was holding. return done(action, await git(dir, ["commit", "--no-edit"])); } +/** + * A merge, cherry-pick or revert moves the whole repo, so only a lane that + * IS the repo may start one — a subdirectory lane could not see, let alone + * resolve, a conflict outside its own folder — and only on a clean tree, so + * an abort can never take an agent's uncommitted work with it. + */ +async function assertCleanRepoRoot(dir, what) { + if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { + throw bad( + "ESUBDIR", + `${what} changes the whole repository; run it from a workspace at the repo root` + ); + } + if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes"); +} + +/** Run a merge/cherry-pick/revert; a conflict is one that STARTED, not a failure. */ +async function startOperation(dir, action, args, branch) { + try { + return { ...done(action, await git(dir, args), branch), conflicts: [] }; + } catch (e) { + if (!(await operationInProgress(dir))) throw e; + return { + ...done(action, e.git || { stdout: "", stderr: "" }, branch), + conflicts: await unmergedPaths(dir), + }; + } +} + +/** `body.sha` as the full sha of a commit that exists, or EBADSHA. */ +async function commitSha(dir, sha) { + if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a commit id"); + const full = await git(dir, ["rev-parse", "-q", "--verify", `${sha}^{commit}`]).then( + (r) => r.stdout.trim(), + () => "" + ); + if (!full) throw bad("EBADSHA", `no commit ${sha}`); + return full; +} + +/** How many parents a commit has (0 = root, 2+ = a merge commit). */ +async function parentCount(dir, sha) { + return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1; +} + +/** + * Refuse to rewrite HEAD unless it is still the commit the user confirmed + * (`sha` — an agent may have committed since the dialog was drawn), it is not + * on any remote-tracking branch, and the lane is the repo root (the rewrite + * moves the whole branch, outside a subdirectory lane too). + */ +async function assertUnpushedHead(dir, sha) { + if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "there is no commit yet"); + const seen = await commitSha(dir, sha); + if (seen !== (await git(dir, ["rev-parse", "HEAD"])).stdout.trim()) { + throw bad("ESTALE", "the last commit changed since you looked: refresh and retry"); + } + if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { + throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace"); + } + const on = ( + await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"]) + ).stdout.trim(); + if (on) { + throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`); + } +} + +/** cherry-pick / revert / undo-commit / amend. */ +async function historyAction(dir, action, body) { + if (action === "cherry-pick" || action === "revert") { + const sha = await commitSha(dir, body.sha); + // A merge commit needs `-m ` to mean anything; picking which side + // is a terminal decision, not a menu item. + if ((await parentCount(dir, sha)) > 1) { + throw bad("EMERGECOMMIT", `${action} of a merge commit is not supported here`); + } + await assertCleanRepoRoot(dir, `a ${action}`); + const args = action === "revert" ? ["revert", "--no-edit", sha] : ["cherry-pick", sha]; + try { + return { ...done(action, await git(dir, args)), conflicts: [] }; + } catch (e) { + const open = await operationInProgress(dir); + const conflicts = open ? await unmergedPaths(dir) : []; + // Conflicts: an operation that STARTED, finished from the banner. + if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts }; + // Nothing to resolve, so nothing may be left behind either: a pick + // whose changes are already here (EEMPTY), or a commit git could not + // make (no identity, a failing signer) — which can leave the changes + // staged with no *_HEAD and so no Abort. The tree was clean before, so + // the commit's own paths go back to HEAD; nothing else is touched. + const empty = await git(dir, ["diff", "--cached", "--quiet"]).then( + () => true, + () => false + ); + if (open) await git(dir, [open, "--abort"]).catch(() => {}); + const touched = ( + await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha]) + ).stdout + .split("\0") + .filter(Boolean); + if (touched.length) { + await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched]) + .catch(() => {}); + } + if (empty) throw bad("EEMPTY", "those changes are already on this branch"); + throw e; + } + } + + if (action === "undo-commit") { + if (body.confirm !== true) throw bad("ECONFIRM", "undo-commit requires confirm: true"); + await assertUnpushedHead(dir, body.sha); + const parents = await parentCount(dir, "HEAD"); + if (parents === 0) throw bad("ENOPARENT", "the first commit has nothing to go back to"); + if (parents > 1) throw bad("EMERGECOMMIT", "undoing a merge commit is not supported here"); + // --soft: only the branch moves. The commit's changes stay staged and the + // working tree (an agent's live edits included) is never touched. + return done(action, await git(dir, ["reset", "--soft", "HEAD~1"])); + } + + // amend: the index as it stands folds into the last commit, like `commit` + // with `only: "staged"`; no message keeps the old one. + await assertUnpushedHead(dir, body.sha); + await assertNothingStagedOutside(dir); + const message = typeof body.message === "string" ? body.message.trim() : ""; + if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long"); + if (!message) { + const staged = [...(await changedPaths(dir)).values()].some( + ({ code }) => code[0] !== " " && code[0] !== "?" + ); + if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with"); + } + return done( + action, + await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])]) + ); +} + /** Create or delete a branch WITHOUT touching the working tree. */ async function branchAction(dir, action, body) { const branch = typeof body.branch === "string" ? body.branch.trim() : ""; diff --git a/server/lib/update-check.js b/server/lib/update-check.js index f0b69094..2bb13026 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -82,16 +82,28 @@ function execGit(cwd, args, opts = {}) { }); } +/** + * The npm {@link applyUpdate} runs: the bootstrap's `npmCommand` (the running + * node's own npm — a bare `execFile("npm.cmd")` is EINVAL on Windows since + * Node 20.12.2) with NODE_ENV dropped. The server defaults NODE_ENV to + * `production`, under which npm omits devDependencies — and the client's + * vite/typescript are dev deps, so the `npm run build` after it failed. + */ +function npmInvocation(args, env = process.env) { + const { NODE_ENV, ...rest } = env; + return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest }); +} + /** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps * {@link applyUpdate} runs after fast-forwarding. */ function execNpm(cwd, args, opts = {}) { const timeout = opts.timeout ?? 300_000; - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const npm = npmInvocation(args); return new Promise((resolve, reject) => { execFile( - npmCmd, - args, - { cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" }, + npm.file, + npm.args, + { cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell }, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message || String(err))); else resolve(String(stdout).trim()); @@ -857,4 +869,5 @@ module.exports = { fetchLatestRelease, repoUrlFor, isShallowClone, + npmInvocation, }; diff --git a/server/lib/worktree.js b/server/lib/worktree.js index 3efb870f..1b12f058 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -738,17 +738,58 @@ async function gitFacts(dir) { }; } -/** A merge in progress and how many files it still holds unmerged (repo-wide). */ -async function mergeFacts(dir) { - try { - await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]); - } catch { - return { merging: false, conflicts: 0 }; +/** The head file each history-joining operation leaves while it is open. */ +const OPERATION_HEADS = [ + ["MERGE_HEAD", "merge"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], +]; + +/** The merge, cherry-pick or revert git has open in `dir`, or null. */ +async function operationInProgress(dir) { + for (const [ref, operation] of OPERATION_HEADS) { + const open = await git(dir, ["rev-parse", "-q", "--verify", ref]).then( + () => true, + () => false + ); + if (open) return operation; } + return null; +} + +/** + * A merge, cherry-pick or revert in progress (`merging` stays the one flag + * the UI gates on; `operation` names which) and how many files it still holds + * unmerged (repo-wide). + */ +async function mergeFacts(dir) { + const operation = await operationInProgress(dir); + if (!operation) return { merging: false, operation: null, conflicts: 0 }; const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({ stdout: "", })); - return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length }; + return { merging: true, operation, conflicts: out.stdout.split("\0").filter(Boolean).length }; +} + +/** + * The full refname of a local or remote-tracking branch whose short name is + * `short`, or null. Looked up, never passed through: git only ever sees a + * ref that exists, so a request can never smuggle an option or a revision + * expression in as a "branch". + */ +async function branchRef(dir, short) { + const refs = ( + await git(dir, [ + "for-each-ref", + "--format=%(refname:short)%00%(refname)", + "refs/heads", + "refs/remotes", + ]) + ).stdout + .split("\n") + .filter(Boolean) + .map((l) => l.split("\0")); + return refs.find(([s]) => s === short)?.[1] ?? null; } /** @@ -870,6 +911,8 @@ module.exports = { removeWorktree, statusCounts, gitFacts, + operationInProgress, + branchRef, unpushedCount, hasNoRemotes, }; diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index e9eb6571..964a5ecb 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -541,7 +541,7 @@ const paths = { }, 400: { description: - "ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.", + "ENOBASE (range=branch with neither a lane base branch nor a repo default branch to compare against), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.", }, 404: { description: "Lane not found." }, }, @@ -552,16 +552,20 @@ const paths = { tags: ["Lanes"], summary: "The lane's recent commits", description: - "Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.", + "Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.", operationId: "listLaneCommits", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "limit", in: "query", schema: { type: "integer", default: 30, maximum: 200 } }, { name: "file", in: "query", schema: { type: "string" } }, + { name: "ref", in: "query", schema: { type: "string" } }, ], responses: { 200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." }, - 400: { description: "EBADPATH (file= is absolute or has a `..` segment)." }, + 400: { + description: + "EBADPATH (file= is absolute or has a `..` segment), ENOBRANCH (ref= is not a branch).", + }, 404: { description: "Lane not found." }, }, }, @@ -734,6 +738,10 @@ const paths = { "branch-create", "branch-delete", "publish", + "cherry-pick", + "revert", + "undo-commit", + "amend", ], }, }, @@ -750,7 +758,16 @@ const paths = { description: "publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.", }, - message: { type: "string", description: "commit only; required, max 4000 chars." }, + message: { + type: "string", + description: + "commit (required) and amend (optional — none keeps the old message); max 4000 chars.", + }, + sha: { + type: "string", + description: + "cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).", + }, only: { type: "string", enum: ["staged"], diff --git a/server/openapi-extra/run.js b/server/openapi-extra/run.js index 8dbddc79..c8b35534 100644 --- a/server/openapi-extra/run.js +++ b/server/openapi-extra/run.js @@ -818,7 +818,7 @@ const paths = { 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).", + "Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).", example: "shell", }, ], @@ -862,7 +862,7 @@ const paths = { tags: ["Run"], summary: "Forget a closed Terminal tab", description: - "Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.", + "Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.", operationId: "runHistoryForget", parameters: [ { diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 62cee690..aacfb455 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -52,6 +52,7 @@ const { const { collectChanges, listCommits, + defaultBranch, listPaths, listTree, listTreeAll, @@ -903,6 +904,9 @@ router.get("/:id/git", async (req, res) => { res.json({ available: true, ...(await gitFacts(t.cwd)), + // What the Changes panel's "vs base" compares against: the lane's own + // base, else the repo's default branch. + base: t.lane.base_branch || (await defaultBranch(t.cwd)), ...(t.worktree ? { worktree: t.worktree } : {}), }); } catch { @@ -920,8 +924,8 @@ router.get("/:id/git", async (req, res) => { * &context= — lines of context per hunk (expand) * * `work` (default) is HEAD vs the working tree, untracked files included; - * `branch` is `...HEAD`, and answers 404-free with ENOBASE when the lane - * has no base branch to measure against; `commit` needs `&sha=` and shows that + * `branch` is `...HEAD` — the lane's base, else the repo's default + * branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that * one commit. Read-only, so no same-origin guard — same reasoning as * GET /:id/git. See server/lib/git-diff.js for the caps. */ @@ -965,10 +969,11 @@ router.get("/:id/git/log", async (req, res) => { const commits = await listCommits(cwd, { limit: req.query.limit, file: typeof req.query.file === "string" && req.query.file ? req.query.file : null, + ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null, }); res.json({ available: true, commits }); } catch (err) { - if (err.code === "EBADPATH") { + if (err.code === "EBADPATH" || err.code === "ENOBRANCH") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.json({ available: false, commits: [] }); @@ -1120,8 +1125,9 @@ router.get("/:id/git/branches", async (req, res) => { }); /** - * The git WRITES the dashboard offers: fetch, pull, push, stage, unstage, - * discard, commit, switch, branch-create and branch-delete. + * The git WRITES the dashboard offers — the list is `ACTIONS` in + * server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit, + * branches, merge, cherry-pick, revert). * Guarded (they mutate), serialized per git index (`withLaneLock` keyed on * the worktree's git dir, so a claude and a codex lane on one directory share * it) so a push and a commit fired from two tabs or two lanes cannot @@ -1191,6 +1197,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { "EBADURL", "EREMOTEEXISTS", "ENOCOMMITS", + "EBADSHA", + "EMERGECOMMIT", + "ENOPARENT", ]; // A numeric code is git's own exit status (execFile reports one that way): // git ran and refused — a rejected push, a failed credential, a diverged @@ -1200,7 +1209,16 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { // EHUNKGONE is the same kind of answer: the tree moved under the click. const status = badRequest.includes(err.code) ? 400 - : exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code) + : exited || [ + "EHUNKGONE", + "EGITAUTH", + "EMERGING", + "ENOMERGE", + "ECONFLICTS", + "EPUSHED", + "EEMPTY", + "ESTALE", + ].includes(err.code) ? 409 : 500; // git's own stderr is the useful half of a failed push or pull (a diff --git a/server/routes/run.js b/server/routes/run.js index 6fe32558..d7c6dd93 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -110,8 +110,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, and ?tool= (with a laneId) to one tool's — the sidebar reads a - * workspace's closed Terminal tabs with `tool=shell`. + * lane, and ?tool= (with a laneId) to one tool's (`shell` = Terminal tabs; + * a closed one's row is dropped on the next run-heal beat). */ // ── Drag & drop into the terminal ───────────────────────────────────────── // A local terminal answers a dropped image by typing its PATH, which is how @@ -219,8 +219,8 @@ router.get("/history", (req, res) => { }); /** - * Forget one closed Terminal tab (`tool = 'shell'`) from the history — the - * sidebar's remove. A live pane is refused; another tool's row is a 404. + * Forget one closed Terminal tab (`tool = 'shell'`) from the history at once, + * ahead of the run-heal beat. A live pane is refused; another tool's row is a 404. */ router.delete("/history/:id", (req, res) => { if (runs.listRuns().some((h) => h.id === req.params.id)) {