feat(files): follow the agent into a worktree outside the session folder

#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.

findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.

The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-06 17:54:59 +01:00
1 parent e9b2ecd552
commit c9b79097b0
18 files changed
+1030 -56

No files matched your search

+21 -5
View File
@@ -1002,15 +1002,31 @@ EnterWorktree moved the running session:
"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.
It is derived from `paneCwd`, so it is absent wherever that is. Usually it is a folder strictly
below `workingDir`, with `path` in `workingDir`'s spelling, and then it is never a confinement
root. Like `paneCwd` it is never restored after a restart.
It can also be a worktree of the session's repository OUTSIDE `workingDir`, such as
`git worktree add ../feature`. It then carries `"outside": true`, and `path` is the folder's
real path:
```json
"liveWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
```
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
`file-raw`, `file-preview`, `file-thumbnail` and `GET /api/v1/download` accept an ABSOLUTE path
inside it, while a relative path still resolves against `workingDir` only. Codeman reports it
only when the session's own repository lists it, and never for `/`, the home folder or above, a
folder holding `workingDir`, or a Docker case workspace.
`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`,
gone, and when the folder no longer resolves inside `workingDir` (for an outside worktree, when it
no longer resolves to its recorded `path`). A `path` in the response stays relative to
`workingDir` either way, except in a listing of an outside worktree, where every `path` is
absolute. Both forms work 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`.
+14 -2
View File
@@ -768,7 +768,7 @@ 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 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.
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, except for a worktree outside it, whose `path`s are absolute), 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
@@ -780,7 +780,19 @@ When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktr
⚠️ **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 `liveWorktree` inside `workingDir` 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 for it.
**A worktree outside the session's folder** (`git worktree add ../feature`, or an EnterWorktree that the user's own tooling places beside the repository) is the one case that IS a second confinement root, and it carries `liveWorktree.outside: true`. `refreshLiveWorktree()` passes `findLiveWorktree()` the outside rules (`outsideWorktreeOptions()`, server.ts; none for a remote or Docker session), and `findOutsideWorktree()` answers only when all of these hold:
- The session's `workingDir` is the top of its repository or of a linked worktree (its `.git` sits there), so its git common dir is known. A session started in a subfolder follows no worktree outside it.
- The nearest `.git` at or above the pane is a FILE naming `<common dir>/worktrees/<id>`, and that registry entry's own `gitdir` file names the candidate back. Or it is a `.git` FOLDER that is the common dir itself, which is the main worktree seen from a session started in a linked one. These are the files `git worktree list` reads; git is never run, because this fires from the pane watcher on every directory change. A forged `.git` file pointing at a real entry fails the back link.
- The candidate's real path is not `/`, not the home folder or above it, and does not hold `workingDir`.
- It is not at, inside or around a Docker case workspace. An unreadable `docker-cases.json` follows nothing, since it could hide a container's workspace.
- In multi-user mode, the owner's `folderAllowedForUser` rule admits it.
Every comparison uses real paths, and the recorded `path` is the real path. ⚠️ **The routes must re-check it on every request**: `outsideWorktreeRootFor()` (route-helpers.ts) and `canWalkOutsideWorktree()` (file-routes.ts) accept the folder only while `realpath` returns exactly the recorded path, so a symlink swapped in later serves nothing from it. ⚠️ Its listing returns ABSOLUTE `path`s, and only an absolute path inside it is confined to it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). A relative path still resolves against `workingDir` alone, so `../feature/x` is refused as before. A symlink inside the worktree into `workingDir` is served, since `workingDir` is served anyway. ⚠️ The browser routes a path inside it to those routes, not to the read-only attachment routes (`_isOutsideWorktreePath()`, panels-ui.js), and the terminal opens it in the preview, because the log viewer's `tail-file` stays confined to `workingDir`.
A worktree of a session started below the repository's top is still not followed, because Claude Code puts it at the top, outside the session's folder, and that session has no common dir to check it against.
⚠️ 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.
+9
View File
@@ -342,6 +342,15 @@ escapes the session working directory (`..`, absolute paths, and symlinks that
resolve outside). The realpath‑before‑check ordering closes the validation‑time
TOCTOU window.
A local session has one more root while its agent works in a git worktree of the
same repository OUTSIDE its folder (`liveWorktree.outside`). An absolute path inside
that worktree is confined to it by `validateLocalSessionFilePath()`, and only while the
worktree folder still `realpath`s to the path recorded for it. Codeman records such a
worktree only after the session's own repository vouches for it, and never `/`, the
home folder or above, a folder holding the working directory or a Docker case
workspace (`src/live-worktree.ts`). A relative path is still confined to the working
directory alone.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
+10 -5
View File
@@ -53,11 +53,16 @@ Only the Files panel moves. The **Git** panel stays on the session's folder, so
folder, so an `.html` file in `.claude/worktrees/<name>` 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. 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.
A worktree beside the repository (`git worktree add ../feature`, or a setup that makes
EnterWorktree put worktrees there) is followed too, as long as the session was started at the top
of the repository or of one of its worktrees. The panel then lists that folder with full paths,
and you can preview and edit its files there. Codeman follows such a worktree only when the
session's own repository lists it, and never one that is your home folder or above it, one that
holds the session's folder, or a Docker case's workspace.
A session started in a subfolder of the repository follows no worktree outside that subfolder,
including the one Claude Code creates at the top of the repository. Open such a worktree as a case
of its own with Add Case > Link Existing.
## Removing one