diff --git a/CLAUDE.md b/CLAUDE.md index a4957ca1..28c54e8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,7 +238,7 @@ 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`. +**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. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). 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). ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ 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 or the check refused it: `_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`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree) **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). diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a269ad3..af502fe3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -991,6 +991,29 @@ and never a confinement root: file routes stay confined to `workingDir`. tmux reports the resolved directory, so for a workspace reached through a symlink a directory at or under the resolved workspace is reported in `workingDir`'s spelling. +## Live worktree (`liveWorktree`) + +Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`, +the linked git worktree the session's pane is in now, for example after Claude Code's +EnterWorktree moved the running session: + +```json +"liveWorktree": { "name": "feature-a", "path": "/home/me/repo/.claude/worktrees/feature-a" } +``` + +It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder +strictly below `workingDir`: a worktree elsewhere is never reported. `path` is in `workingDir`'s +spelling. Like `paneCwd` it is never restored after a restart and never a confinement root. + +`GET /api/v1/sessions/:id/files` takes an optional `root=live`, which starts the listing, or a +`q` search, at that worktree instead of at `workingDir`. The response's `root` names the folder +that was walked: it stays `workingDir` when the session is in no worktree, when the worktree has +gone, and when the folder no longer resolves inside `workingDir`. A `path` in the response stays +relative to `workingDir` either way, so it works unchanged on `file-content` and `file-raw`, +while a `q` containing `/` is matched against paths relative to the folder walked. The response +also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`, +whichever folder was walked and with or without `root=live`. + ## Session model (`displayModel`) Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..44e43406 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -736,7 +736,23 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. -Matching semantics: a query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. +Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. + +### Files panel in a worktree + +**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `/.claude/worktrees/`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too. + +When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktree()`, which walks up from the pane and recognises a linked worktree by its `.git` FILE (`gitdir: .../worktrees/`). It never runs git, since this fires from the pane watcher and a file read starts no process, config or filter. The walk stops at `workingDir`, at a repository or submodule of its own and at anything it cannot read; probes are bounded (#516), and a `.git` that is a symlink or a FIFO is refused before anything opens it. The answer is published as `SessionState.liveWorktree`. A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. + +⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` checks again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts. + +⚠️ **Only the check uses resolved paths.** The walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`, and the listing's `path`s stop working on the file routes. + +⚠️ `liveWorktree` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new. Serving a worktree outside the folder (`git worktree add ../feature`) would need a second root, so those stay unfollowed, and so does a worktree of a session started below the repository's top, because Claude Code puts it at the top, outside the session's folder. + +⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that report with the session's state, and an absent report would reload forever. + +Two surfaces do not move with it. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/` is refused and opens as source (a session started in the worktree renders it). Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`. ### Raw file bodies: streamed and range-aware diff --git a/docs/wiki/Git-Worktrees.md b/docs/wiki/Git-Worktrees.md index 43b4d144..cd5f23f5 100644 --- a/docs/wiki/Git-Worktrees.md +++ b/docs/wiki/Git-Worktrees.md @@ -48,9 +48,16 @@ editing. A strip under the panel's title names the worktree, and its **Session f 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. +Only the Files panel moves. The **Git** panel stays on the session's folder, so it can show +`main` while the strip names the worktree. And `.claude` is a hidden folder of the session's +folder, so an `.html` file in `.claude/worktrees/` opens as source rather than as a +rendered page. A session started in the worktree does not have that limit. + 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. +only serves files inside the session's folder. The same goes for a session started in a subfolder +of the repository, because Claude Code puts the worktree at the top of the repository, which is +outside that session's folder. Open such a worktree as a case of its own with Add Case > Link +Existing. ## Removing one diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 18597571..7fb1e5a1 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -127,9 +127,11 @@ can send what it can read to any host, open HTML only from sources you trust, as your own desktop. The **Page** pill in the viewer header switches to the source view (where **Edit** works as for any text file), and the viewer falls back to source on its own when a page cannot be rendered: a remote -(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such -as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it -into a folder of its own). The raw file routes still serve HTML as a download only. +(SSH) case, an HTML file inside a hidden folder (which includes `.claude/worktrees/` after +an agent [moved into a worktree](Git-Worktrees#when-an-agent-moves-into-a-worktree)), or one +sitting directly in a broad folder such as your home folder or `/tmp` (the page may read the +files in its own folder tree, so move it into a folder of its own). The raw file routes still +serve HTML as a download only. Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a one-gigabyte log does not try to paint one.