Compare commits

..
Author SHA1 Message Date
Codeman maintainer 89a93c871f fix(files): list the git worktree an agent moved into (#765)
Claude Code's EnterWorktree moves the running process into
<repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main
checkout. The Files panel kept walking workingDir, so it listed a tree the
agent had left.

paneCwd already follows the move, measured on Claude Code 2.1.318 in both
directions, so nothing new has to track the agent. It also follows every cd
in a shell, so a new SessionState.liveWorktree narrows it to a linked
worktree, read from the .git file without starting git.

Only a folder strictly below workingDir is ever an answer, and the listing
only starts there: returned paths stay relative to workingDir, so the
preview, raw and edit routes are unchanged and confinement does not widen.
A worktree outside the session folder stays unfollowed.

The panel asks with root=live, shows a strip naming the worktree with a
switch to the session folder, and follows the agent in and out while open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:47:38 +01:00
30 changed files with 893 additions and 162 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
Files panel: when a session's agent moves into a git worktree part way through (Claude Code's EnterWorktree), the panel now lists that worktree instead of the main checkout, follows the agent back out, and shows a strip that names the worktree with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder as before.
-5
View File
@@ -1,5 +0,0 @@
---
"aicodeman": patch
---
pi: the mouse wheel no longer goes dead after a tab switch or reload. pi's fullscreen TUI, the default since 1.0.0, enables mouse tracking once at start, and Codeman's replay dropped that mode in the browser, so the wheel landed in an empty local buffer. pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection. A pi in `tuiMode: regular` is unaffected. Thanks to @Randalix for the report and the fix.
+3 -1
View File
@@ -217,7 +217,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, tracked Copilot, or tracked Pi**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -238,6 +238,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ Only a folder strictly below `workingDir` is ever an answer, which is the whole security argument: the walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
+3 -5
View File
@@ -61,7 +61,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular` and below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
@@ -231,16 +231,14 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
### Terminal scrollback: strip flavors and wheel/touch forwarding
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode and copilot) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex, tracked Copilot or tracked Pi** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate.
**Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with default settings: `alternate_on=1`, `history_size=0`, `mouse_any_flag=1`, SGR): pi enables mouse tracking ONCE at start. Left on the narrow strip, the wheel worked only until Codeman replayed the buffer (tab switch, reload): the replay carries no DECSETs, xterm's `mouseTrackingMode` fell to `'none'` with `baseY` 0, and the wheel hit an empty local buffer until a resize made pi redraw. Measured on a fresh session: `drag` right after the first select, `none` after a tab switch and after a reload. With the mouse strip the server records the tracking (`cliMouseTracking`), which survives the replay; `_shouldForwardWheelToApp` forwards by hand while it is true. One SGR wheel report moves pi's transcript one row at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular` and below 1.0.0 pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback.
Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence.
Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session.
+3 -5
View File
@@ -88,11 +88,9 @@ bypass, and project trust is a decision the person at the terminal makes.
- **`--api-key`.** Never. It would put a provider secret on the spawn command
line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through
socket-scoped `tmux setenv` for exactly this reason.
- **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting.
Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because
Codeman strips pi's mouse enables, a drag selects text in the browser rather than
using pi's own selection or scrollbar drag. `tuiMode: regular`, and every pi below
1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all.
- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a
browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via
`/settings`.
- **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`,
`--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`,
`--system-prompt`.** Tracked as follow-ups in the plan doc.
+13
View File
@@ -39,6 +39,19 @@ name.
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
## When an agent moves into a worktree
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
and the session's tab stays on the case. While the session's pane is in a worktree inside the
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
editing. A strip under the panel's title names the worktree, and its **Session folder** button
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
in and back out on its own while it is open.
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
only serves files inside the session's folder. Open such a worktree as a case of its own with
Add Case > Link Existing.
## Removing one
Two ways, and both use `git worktree remove` **without** `--force`:
+3 -2
View File
@@ -6,8 +6,9 @@ until you get home.
## The File Viewer
A panel that browses the active session's working directory. Its header button is on by
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
A panel that browses the active session's working directory, or the git worktree its agent
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
It renders what it can:
+1 -15
View File
@@ -1180,21 +1180,7 @@ const PI: CliEntry = {
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
// Fullscreen is pi's default `tuiMode` since 1.0.0 (measured 1.0.0, 1.1.0, 1.4.0, 1.5.0 in
// tmux: alternate_on=1, history_size=0, mouse on, SGR): a TUI that enables tracking once at
// start. Codeman's replay (tab switch, reload) drops that mode in xterm, so the wheel hit an
// empty local buffer and went dead until a redraw. Same shape as copilot: strip the DECSETs
// (a drag selects locally), publish `cliMouseTracking`, and forward the wheel by hand from
// that flag. pi scrolls on SGR wheel reports, one row each at a low rate, faster on a spin.
// `tuiMode: regular` (and every pi below 1.0.0) is inline and emits no mouse DECSET, so the
// strip finds nothing and the flag stays false.
// ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds
// those keys to its editor in regular mode too, where they page nothing and move the caret
// of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the
// wrong line (measured). The cost: a fullscreen pi with "Wheel scrolls local history" on
// has no paging fallback.
altScreen: 'strip-mux-and-mouse',
wheelForward: { mode: 'always' },
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
+3 -3
View File
@@ -546,7 +546,7 @@ export interface CliCapabilities {
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell), not a different strip.
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
@@ -560,8 +560,8 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode, copilot and pi (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell.
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
+94
View File
@@ -0,0 +1,94 @@
/**
* @fileoverview Which linked git worktree a session's pane is in.
*
* Claude Code's worktree feature moves the running `claude` process into
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
* answers the narrower question: is the pane inside a linked worktree, and which one.
*
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder at or below `workingDir` is ever an answer, and that is the whole security
* argument: the file routes already serve everything under `workingDir`, so following a pane
* there widens nothing, and a planted `.git` file can only move where the listing STARTS inside
* that same folder. A worktree outside it (`git worktree add ../feature`) stays unfollowed, since
* listing it would need a second confinement root. Nothing here is a confinement decision: the
* routes keep judging every path against `workingDir`.
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
*
* @module live-worktree
*/
import { promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
path: string;
}
/** More than any real `.git` file. It is read only to recognise the first line. */
const MAX_DOT_GIT_FILE_BYTES = 4096;
/**
* Whether the text of a `.git` file names a linked worktree's git dir
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// lstat, so a `.git` that is a symlink or a FIFO is refused before anything opens it:
// opening a FIFO waits for a writer and would hold a threadpool worker for good.
if (!(await fs.lstat(dotGit)).isFile()) return 'stop';
handle = await fs.open(dotGit, 'r');
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
if (found === 'stop') return undefined;
}
return undefined;
}
+33 -11
View File
@@ -73,6 +73,7 @@ import type { GitFetchStatus } from './git-fetch.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { probePathKind } from './utils/bounded-path-probe.js';
import type { LiveWorktree } from './live-worktree.js';
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { probeDockerCliVersion } from './docker-hosts.js';
@@ -304,21 +305,23 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode`, `copilot` and `pi` (their own
* MIDDLE strip, isMuxMouseStripMode) and `grok` (a fullscreen alt-screen TUI with
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
* stock CLI in test/claude-scrollback-strip.test.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxMouseStripMode() or isMuxAltScreenOnlyStripMode(), which
* strip the alt-screen toggles too whenever the session is tmux-backed, and
* pi/opencode ALWAYS are (both refuse the direct-PTY fallback). What exclusion
* actually buys is the rest of the full strip: `\x1b[3J` survives, and for the
* narrow strip the mouse-tracking DECSETs too. pi (fullscreen by default since
* 1.0.0) is a mouse-aware TUI like opencode and copilot, so it takes the mouse
* strip (isMuxMouseStripMode): a drag selects locally and `cliMouseTracking`
* keeps the wheel working across a buffer replay.
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
* toggles too whenever the session is tmux-backed, and pi/opencode ALWAYS are
* (both refuse the direct-PTY fallback). What exclusion actually buys is the rest
* of the full strip: `\x1b[3J` and the mouse-tracking DECSETs survive. That is the
* real reason pi is out: its default TUI renders into the MAIN screen with
* terminal-owned scrollback and is mouse-aware, so it is a `3J`/mouse consumer in
* a way an Ink TUI repainting in place is not. Consequence to know before
* debugging it: pi's runtime-switchable fullscreen TUI (`/settings`, 0.84.0+)
* still gets its `?1049h` stripped and paints into the main buffer, exactly like
* vim inside a tmux `shell` session.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.altScreen === 'strip-full';
@@ -764,6 +767,8 @@ export class Session extends EventEmitter {
* serialized by toState() but never restored by the constructor.
*/
private _paneCwd: string | undefined;
/** See {@link liveWorktree}. */
private _liveWorktree: LiveWorktree | undefined;
/**
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
* process cwd, always the resolved path) can be put back into `workingDir`'s
@@ -1574,6 +1579,22 @@ export class Session extends EventEmitter {
return true;
}
/**
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
* never a confinement root, like `paneCwd`: it is always a folder the file routes already serve.
*/
get liveWorktree(): LiveWorktree | undefined {
return this._liveWorktree;
}
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
setLiveWorktree(next: LiveWorktree | undefined): boolean {
if (next?.path === this._liveWorktree?.path) return false;
this._liveWorktree = next;
return true;
}
/** `workingDir` without a trailing separator (the root stays `/`). */
private _workspaceSpelling(): string {
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
@@ -2298,6 +2319,7 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
paneCwd: this._paneCwd,
liveWorktree: this._liveWorktree,
remote: this._remote,
docker: this._docker,
owner: this._owner,
@@ -3001,7 +3023,7 @@ export class Session extends EventEmitter {
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
//
// Every OTHER mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
// scrollback there is tmux's own client-side smcup at attach, not anything the
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
+12 -2
View File
@@ -34,6 +34,7 @@
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
import type { GitFetchStatus } from '../git-fetch.js';
import type { LiveWorktree } from '../live-worktree.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -847,6 +848,15 @@ export interface SessionState {
* routes like any other out-of-workspace path.
*/
paneCwd?: string;
/**
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
* only starts its listing here.
*/
liveWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
@@ -956,8 +966,8 @@ export interface SessionState {
/**
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
* by the server on its way out of the stream (those sequences are stripped for
* the strip-full and strip-mux-and-mouse modes under tmux, so the browser can
* never see them itself). The browser
* the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode
* under tmux, so the browser can never see them itself). The browser
* hand-encodes a click report ONLY when this is true; without it, every click
* sent mouse reports to a CLI that never asked for them.
*/
+2
View File
@@ -2458,6 +2458,8 @@ class CodemanApp {
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// The agent moved into or out of a git worktree while the Files panel is open (#765).
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
+3
View File
@@ -1133,6 +1133,9 @@
'Filter by date range': '按日期范围筛选',
'Filter by session status': '按会话状态筛选',
'Filter files...': '筛选文件…',
Worktree: '工作树',
'Session folder': '会话文件夹',
'Back to worktree': '返回工作树',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
+5
View File
@@ -617,6 +617,11 @@
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
</div>
</div>
<div class="file-browser-root" id="fileBrowserRoot" hidden>
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()"></button>
</div>
<div class="file-browser-search">
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
</div>
+72 -5
View File
@@ -3282,6 +3282,8 @@ Object.assign(CodemanApp.prototype, {
matches: [],
deferredDirectoryTarget: null,
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
// The person asked for the session folder while the agent is in a worktree (#765).
sessionRootPinned: false,
};
}
return this._fileBrowserState;
@@ -3304,6 +3306,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3314,6 +3317,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const panel = this.$?.('fileBrowserPanel');
const treeEl = this.$?.('fileBrowserTree');
@@ -3346,6 +3350,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3356,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const treeEl = this.$?.('fileBrowserTree');
if (treeEl) treeEl.innerHTML = '';
const statusEl = this.$?.('fileBrowserStatus');
@@ -3448,6 +3454,60 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
this.renderFileBrowserTree(normalState.sessionId);
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
this._renderFileBrowserRoot(normalState.data);
},
// The strip under the panel header, present only while the session's pane is in a linked
// worktree (#765): it says which of the two folders is listed and switches between them.
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
// than the session's state a moment later.
_renderFileBrowserRoot(data) {
const strip = this.$?.('fileBrowserRoot');
if (!strip) return;
const worktree = data?.worktree;
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
strip.hidden = true;
return;
}
const inWorktree = data.root === worktree.path;
const label = this.$?.('fileBrowserRootLabel');
const name = this.$?.('fileBrowserRootName');
const button = this.$?.('fileBrowserRootSwitch');
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
if (name) name.textContent = inWorktree ? worktree.name : '';
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
strip.title = typeof data.root === 'string' ? data.root : '';
strip.hidden = false;
},
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
// a worktree, so the panel always asks unless the person chose the session folder.
_fileBrowserRootParam() {
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
},
toggleFileBrowserRoot() {
const state = this._ensureFileBrowserState();
state.sessionRootPinned = !state.sessionRootPinned;
return this._reloadFileBrowserKeepingQuery();
},
// Called on every session update of the active session. Compares with what the listing on
// screen reported rather than with the previous update, so a load already in flight when the
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
_followFileBrowserWorktree(session) {
const state = this._fileBrowserState;
if (!state || state.ownerSessionId !== session.id) return;
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
const normal = state.normalState;
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
const live = session.liveWorktree?.path ?? null;
const listed = normal.data?.worktree?.path ?? null;
if (live === listed) return;
// The pin was about the worktree the agent has just left.
state.sessionRootPinned = false;
this._reloadFileBrowserKeepingQuery();
},
_validateFileBrowserTreeEnvelope(result) {
@@ -3552,14 +3612,20 @@ Object.assign(CodemanApp.prototype, {
},
async toggleFileBrowserHidden() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
return this._reloadFileBrowserKeepingQuery();
},
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
// root), then re-run the search in the box against the new tree.
async _reloadFileBrowserKeepingQuery() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
@@ -3633,7 +3699,7 @@ Object.assign(CodemanApp.prototype, {
const request = (async () => {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -3820,6 +3886,7 @@ Object.assign(CodemanApp.prototype, {
showHidden: this.fileBrowserShowHidden === true,
rawInput,
query,
rootParam: this._fileBrowserRootParam(),
timer: null,
};
state.view = 'search-pending';
@@ -3834,7 +3901,7 @@ Object.assign(CodemanApp.prototype, {
request.timer = null;
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
);
if (!res.ok) throw new Error('Search failed');
const result = await res.json();
+39
View File
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
background: var(--bg-hover);
}
.file-browser-root {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.7rem;
color: var(--text-dim);
}
.file-browser-root[hidden] {
display: none;
}
.file-browser-root-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.file-browser-root-switch {
flex: 0 0 auto;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.file-browser-root-switch:hover {
color: var(--text);
border-color: var(--text-dim);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
+2 -2
View File
@@ -1149,8 +1149,8 @@
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (so a drag selects text),
// which leaves this xterm's own mouse encoder idle for
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
// drag selects text), which leaves this xterm's own mouse encoder idle for
// them; without this a click in such a pane never reached the CLI. Only
// while this pane's session has tracking on (cliMouseTracking), through the
// same skips as the primary pane. Bubble phase, as there. The target is
+8 -8
View File
@@ -1572,8 +1572,8 @@ Object.assign(CodemanApp.prototype, {
// A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output
// (isAltScreenStripMode, session.ts) and from the mouse-strip modes'
// (isMuxMouseStripMode, so a drag selects text) so the wheel keeps scrolling
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
// so a drag selects text) so the wheel keeps scrolling
// scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions.
@@ -5942,8 +5942,8 @@ Object.assign(CodemanApp.prototype, {
* literal text (`[<0;88;20M`) and they garble the next line typed.
*
* Fails toward silence: an unknown or stale flag reports nothing rather than
* injecting bytes. After a server restart the flag is false until the DECSET
* passes again, which tmux does itself when the server re-attaches to the pane.
* injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does.
*
* `sessionId` defaults to the primary pane's session; a TerminalTile passes
* its own (through _handleDesktopTerminalClick's target), never the active one.
@@ -6065,12 +6065,12 @@ Object.assign(CodemanApp.prototype, {
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
const sessionMode = session?.mode || 'claude';
// GitHub Copilot CLI (and pi, fullscreen by default) scrolls its own transcript on SGR wheel reports (checked with xterm
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot' || sessionMode === 'pi') return session?.cliMouseTracking === true;
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude or Codex is actually listening for the mouse. Claude's
@@ -6408,8 +6408,8 @@ Object.assign(CodemanApp.prototype, {
* The reason is that the habit and xterm's Shift mean different things once
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
* the app actually has mouse tracking on; the server strips those DECSETs for
* claude/codex/gemini (isAltScreenStripMode) and the mouse-strip modes
* (isMuxMouseStripMode), so xterm's mouseTrackingMode is
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
* so xterm's mouseTrackingMode is
* permanently `none`, that branch is unreachable, and Shift instead falls into
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
* when `selectionStart` is null, so the drag never anchors and no selection is
+25 -7
View File
@@ -1595,12 +1595,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
const { depth, showHidden, q, root } = req.query as {
depth?: string;
showHidden?: string;
q?: string;
root?: string;
};
const session = findSessionOrFail(ctx, id, req);
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
const includeHidden = showHidden === 'true';
const workingDir = session.workingDir;
// `root=live` starts the walk at the linked worktree the pane is in (#765). That folder is
// inside `workingDir` (`live-worktree.ts`), and every `path` below stays relative to
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
// paths against the same root as any other. Only the search predicate reads a path relative
// to the listing, since that is the tree the person is looking at.
const liveWorktree = session.liveWorktree;
const walkRoot =
root === 'live' && liveWorktree && (await probePathKind(liveWorktree.path, { pastCap: true })) === 'directory'
? liveWorktree.path
: workingDir;
// null for an empty/whitespace query, which is what keeps the default
// tree response byte-identical when no search is requested.
const matcher = compileFileQuery(q ?? '');
@@ -1676,17 +1691,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fullPath = join(dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
const listedPath = relative(walkRoot, fullPath);
if (entry.isDirectory()) {
totalDirectories++;
if (matcher(entry.name, relativePath)) {
if (matcher(entry.name, listedPath)) {
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
}
// Always recurse, even when this directory does not match.
await searchDirectory(fullPath, currentDepth + 1);
} else {
totalFiles++;
if (matcher(entry.name, relativePath)) {
if (matcher(entry.name, listedPath)) {
let size: number | undefined;
try {
size = (await fs.stat(fullPath)).size;
@@ -1705,12 +1721,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
};
await searchDirectory(workingDir, 1);
await searchDirectory(walkRoot, 1);
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree: [],
matches,
totalFiles,
@@ -1791,12 +1808,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
};
const tree = await scanDirectory(workingDir, 1);
const tree = await scanDirectory(walkRoot, 1);
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree,
totalFiles,
totalDirectories,
+1 -1
View File
@@ -332,7 +332,7 @@ const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[h
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
* saved lines, so history disappeared on tab switch.
* - `strip-mux-and-mouse` (tmux-backed): alt-screen toggles and mouse DECSETs.
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
* `3J` stays: a TUI is not a `clear` consumer.
+14
View File
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -2967,6 +2968,7 @@ export class WebServer extends EventEmitter {
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
if (paneCwdChanged) void this.refreshLiveWorktree(session);
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!paneCwdChanged) {
@@ -2977,6 +2979,18 @@ export class WebServer extends EventEmitter {
this.closeCleanlyExitedSessions();
}
/**
* Work out which linked worktree a session's pane moved into (#765). In the background, since
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
* A reading newer than the one this lookup started from owns the answer.
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
+1 -39
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'pi'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -372,44 +372,6 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('pi: alt-screen + mouse DECSETs stripped, 3J kept (#758)', () => {
// What fullscreen pi really emits on entering its TUI, read off a pane on 1.1.0, 1.4.0 and 1.5.0.
// Tracking comes on once, here, so a replay that drops it leaves the wheel dead unless the
// server remembers it. ?1004h (focus reporting) is not a mouse mode and stays.
const ENTER = '\x1b[?1049h\x1b[?7l\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h';
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('pi', true)).toBe(true);
expect(isMuxMouseStripMode('pi', false)).toBe(false);
});
it('drops the alt-screen and mouse enables, and keeps focus reporting and 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}\x1b[3JTUI`);
expect(session.terminalBuffer).toBe('\x1b[?7l\x1b[?1004h\x1b[3JTUI');
});
it('publishes cliMouseTracking so the wheel survives a replay', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}TUI`);
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('never sets the flag for an inline pi, which emits no mouse enable', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, '\x1b[?2004hpi');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY pi pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
+2 -4
View File
@@ -633,22 +633,20 @@ describe('capability shapes', () => {
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate, and so does pi. Unmeasured entries stay 'never'.
// forward without a version gate. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
+16 -16
View File
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(1);
expect(pending).toHaveLength(1);
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
});
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
app.activeSessionId = 'session/B';
app.filterFileBrowser('after-session');
await vi.advanceTimersByTimeAsync(250);
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
pending[0].reply.resolve(response(successfulData()));
await vi.advanceTimersByTimeAsync(0);
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
expect(pending).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
]);
});
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
});
it('rejects 257 trimmed characters without a request', async () => {
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
await toggle;
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
expect(app._fileBrowserState.view).toBe('search-pending');
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
await vi.advanceTimersByTimeAsync(250);
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
pending[2].reply.resolve(
response(
successfulData({
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
const firstToggle = app.toggleFileBrowserHidden();
const secondToggle = app.toggleFileBrowserHidden();
expect(pending.slice(1).map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(4);
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
expect(app.fileBrowserShowHidden).toBe(false);
});
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
expect(immediateState.activeSessionId).toBe('session/B');
expect(immediateState.ownerSessionId).toBe('session/B');
expect(immediateState.resizeCalls).toBe(1);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
expect(filePending).toHaveLength(1);
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
]);
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
});
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
expect(secondA).not.toBe(firstA);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+227
View File
@@ -0,0 +1,227 @@
/**
* @fileoverview Files panel following the agent into a git worktree (#765).
*
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
* why the follow compares with what the listing reported rather than with the previous update.
*
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
function element() {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
}
function loadPanel() {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route: the session's worktree, listed when the request asks for it.
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.worktree;
const data = {
root: live ? server.worktree!.path : '/repo',
...(server.worktree && { worktree: server.worktree }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
}
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
hidden: elements.fileBrowserRoot.hidden,
label: elements.fileBrowserRootLabel.textContent,
name: elements.fileBrowserRootName.textContent,
button: elements.fileBrowserRootSwitch.textContent,
title: elements.fileBrowserRoot.title,
});
describe('Files panel in a git worktree', () => {
it('asks for the live root by default', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests[0]).toContain('root=live');
});
it('shows no strip for a session in no worktree', async () => {
const { app, elements } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('says which worktree is listed, and offers the session folder', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Worktree',
name: 'probe',
button: 'Session folder',
title: WORKTREE.path,
});
});
it('switches to the session folder and back', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
expect(server.requests[1]).not.toContain('root=live');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Session folder',
name: '',
button: 'Back to worktree',
title: '/repo',
});
await app.toggleFileBrowserRoot();
expect(server.requests[2]).toContain('root=live');
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('keeps the query in the box across the switch, and searches the new root', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._fileBrowserState.filter = 'main';
elements.fileBrowserSearch.value = 'main';
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
const search = server.requests.find((url) => url.includes('q=main'))!;
expect(search).not.toContain('root=live');
});
it('follows the agent into a worktree while the panel is open', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
server.worktree = WORKTREE;
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
server.worktree = null;
app._followFileBrowserWorktree({ id: 'sess-1' });
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
});
it('settles: updates that agree with the listing reload nothing', async () => {
const { app, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('leaves another session and a closed panel alone', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
elements.fileBrowserPanel.classList.toggle('visible', false);
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('drops the pin and the strip on a session switch', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
app._activateFileBrowserSession('sess-2');
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('is wired up in the page', () => {
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
*
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
* file. Runs against a real filesystem, and one case against a worktree real git made.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
describe('isLinkedWorktreeGitFile', () => {
it('accepts what git writes for a linked worktree, absolute or relative', () => {
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
});
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
expect(isLinkedWorktreeGitFile('')).toBe(false);
});
});
describe('findLiveWorktree', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(join(repo, '.git'), { recursive: true });
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
it('finds the worktree from its own root and from below it', async () => {
const expected = { name: 'probe', path: worktree };
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
});
it('answers the same for a workingDir spelled with a trailing slash', async () => {
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
});
it('does not treat the session folder as a worktree of itself', async () => {
writeFileSync(join(worktree, 'marker'), '');
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
});
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
mkdirSync(join(repo, 'src'));
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
});
it('never answers outside the session folder', async () => {
const outside = join(base, 'repo-feature');
mkdirSync(outside);
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
});
it('stops at a submodule and at a repository of its own', async () => {
const submodule = join(repo, 'lib');
mkdirSync(submodule);
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
const nested = join(worktree, 'vendored');
mkdirSync(join(nested, '.git'), { recursive: true });
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
});
it('refuses a .git that is not a regular file', async () => {
const linked = join(repo, 'linked');
mkdirSync(linked);
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
// A FIFO would block open() for good: the test would time out rather than fail.
const piped = join(repo, 'piped');
mkdirSync(piped);
execFileSync('mkfifo', [join(piped, '.git')]);
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
});
it('answers nothing without an absolute reading', async () => {
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
expect(await findLiveWorktree(repo, '')).toBeUndefined();
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
});
it('recognises a worktree real git made', async () => {
const real = join(base, 'real');
const git = (...args: string[]) =>
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
cwd: real,
stdio: 'ignore',
});
mkdirSync(real);
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'init');
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
name: 'made',
path: join(real, '.claude', 'worktrees', 'made'),
});
});
});
+73 -2
View File
@@ -29,11 +29,11 @@
*
* Port: ephemeral
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
expect(session.toState().workingDir).toBe('/work/proj');
});
describe('liveWorktree (#765)', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const inRepo = () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
const broadcast = vi.fn();
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
mux.getPaneExit = () => undefined;
return { mux, session, web, broadcast };
};
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
const { mux, session, web, broadcast } = inRepo();
let dir = worktree;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
// What the browser receives, and what the file routes read: never the confinement root.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
).getSessionStateWithRespawn(session);
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
expect(session.toState().workingDir).toBe(repo);
const broadcasts = broadcast.mock.calls.length;
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
});
it('does nothing for a pane that has not moved', async () => {
const { mux, session } = inRepo();
mux.getPaneCurrentPath = () => repo;
const lookup = vi.spyOn(session, 'setLiveWorktree');
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
mux.emit('paneExitsUpdated');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(lookup).toHaveBeenCalledTimes(1);
});
it('drops an answer for a directory the pane has already left', async () => {
const { web, session } = inRepo();
session.setPaneCwd(worktree);
const refresh = (
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
).refreshLiveWorktree.call(web, session);
session.setPaneCwd(repo);
await refresh;
expect(session.liveWorktree).toBeUndefined();
});
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
@@ -0,0 +1,105 @@
/**
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
*
* The listing starts at the worktree the pane is in, and every `path` stays relative to
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const PREFIX = '.claude/worktrees/probe';
describe('Files panel listing of a live worktree', () => {
let harness: RouteTestHarness;
let repo: string;
let worktree: string;
const list = async (query = '') => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
});
expect(res.statusCode).toBe(200);
return res.json().data;
};
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
worktree = join(repo, PREFIX);
mkdirSync(join(repo, 'src'), { recursive: true });
mkdirSync(join(worktree, 'src'), { recursive: true });
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
Object.assign(harness.ctx._session, {
workingDir: repo,
liveWorktree: { name: 'probe', path: worktree },
});
});
afterEach(async () => {
await harness.app.close();
rmSync(repo, { recursive: true, force: true });
});
it('lists the worktree with paths relative to the session folder', async () => {
const data = await list('&root=live');
expect(data.root).toBe(worktree);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
});
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
const data = await list('&root=live&showHidden=false');
expect(data.totalFiles).toBe(1);
});
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
const data = await list();
expect(data.root).toBe(repo);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
});
it('answers exactly as before for a session in no worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: undefined });
const data = await list('&root=live');
expect(data).toEqual(await list());
expect(data.root).toBe(repo);
expect('worktree' in data).toBe(false);
});
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
rmSync(worktree, { recursive: true });
const data = await list('&root=live');
expect(data.root).toBe(repo);
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
expect(data.root).toBe(worktree);
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('serves a listed path through the unchanged preview route', async () => {
const path = `${PREFIX}/src/worktree-only.ts`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
});
expect(res.json().success).toBe(true);
expect(res.json().data.content).toBe('worktree');
});
});
-29
View File
@@ -898,35 +898,6 @@ describe('GitHub Copilot wheel forwarding', () => {
});
});
describe('pi wheel forwarding (fullscreen by default since 1.0.0)', () => {
// pi enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that
// mode in xterm (`mouseTrackingMode` none, baseY 0), so the wheel hit an empty local buffer
// and went dead until a redraw. Like Copilot, the server strips the DECSETs and publishes
// `cliMouseTracking`; the gate forwards from that flag, which survives the replay.
it('forwards the wheel while pi tracks the mouse', () => {
const { app } = hollowApp({ mode: 'pi', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel local', () => {
expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe(
false
);
});
it('sends nothing to an inline pi: page keys there move the caret of a multi-line draft', () => {
// `tuiMode: regular`, and every pi below 1.0.0, emits no mouse enable, so the flag stays false
// over a buffer that is hollow on a fresh pane. pi binds PageUp to its editor in that mode:
// measured on real pi, the wheel moved the caret and the next X landed on the first line.
const { app, sent } = hollowApp({ mode: 'pi', cliMouseTracking: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
});
describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();