Merge branch 'relprep/pr-772' into relprep/pr-614

Both sides rewrite the /files walk. Keep the fair rounds from #614 and carry
the two things #772 does to the walk onto them: both queues start at walkRoot,
and the search predicate reads a path relative to the listing while every
returned path stays relative to workingDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:50 +01:00
commit bb42f8105e
21 files changed
+1002 -36

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
/**
* @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 strictly below `workingDir` is ever an answer: the file routes already serve
* everything under it, 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.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and 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;
}
+21
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';
@@ -766,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
@@ -1576,6 +1579,23 @@ 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 judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request.
*/
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;
@@ -2300,6 +2320,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,
+11
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,16 @@ 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, after `GET .../files` has checked that the real folder still
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
*/
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 */
+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);
+45 -7
View File
@@ -782,6 +782,26 @@ export function isPathWithinRoot(root: string, candidate: string): boolean {
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
/**
* Whether a `root=live` listing may start at a session's `liveWorktree` (#765). `findLiveWorktree()`
* judged that path lexically when the pane moved, and a symlink can take its place, or a parent's,
* without moving the pane. The probe and `readdir` both follow one, and the walk's `path`s only look
* relative to `workingDir`, so this is the request-time half of the rule, the same one
* `validateSessionFilePath()` applies to a file: resolved, the worktree must lie strictly inside the
* resolved `workingDir`. Only the CHECK uses the resolved paths: the walk keeps the session's spelling,
* or a workspace reached through a symlink would stop producing paths relative to `workingDir`.
* Probed first (#516); both realpaths then walk the components that probe has just answered for.
*/
async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Promise<boolean> {
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
try {
const [realRoot, realWorktree] = await Promise.all([fs.realpath(workingDir), fs.realpath(worktreePath)]);
return realWorktree !== realRoot && isPathWithinRoot(realRoot, realWorktree);
} catch {
return false;
}
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
@@ -1595,12 +1615,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), once that folder
// proves to be inside `workingDir` right now, 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 canWalkLiveWorktree(workingDir, liveWorktree.path))
? 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 ?? '');
@@ -1654,7 +1689,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
index: number;
}
const queue: SearchDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, index: 0 }];
const queue: SearchDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, index: 0 }];
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
// Give each discovered directory a slice before returning to a large sibling.
const directoriesInRound = queue.length;
@@ -1692,17 +1727,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fullPath = join(cursor.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' });
}
// Queue siblings together so one large subtree cannot starve later branches.
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 });
} else {
totalFiles++;
if (matcher(entry.name, relativePath)) {
if (matcher(entry.name, listedPath)) {
let size: number | undefined;
try {
size = (await fs.stat(fullPath)).size;
@@ -1728,7 +1764,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree: [],
matches,
totalFiles,
@@ -1750,7 +1787,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const tree: FileTreeNode[] = [];
const queue: TreeDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, nodes: tree, index: 0 }];
const queue: TreeDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }];
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) {
const directoriesInRound = queue.length;
const nodesPerDirectory = Math.max(
@@ -1818,7 +1855,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree,
totalFiles,
totalDirectories,
+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