fix(terminal): report the pane directory in the workspace's own spelling

tmux reads a pane's directory from the process cwd, which is always the
resolved path, while workingDir keeps the spelling the case was created
with. For a workspace reached through a symlink every reading looked
like it was outside the workspace, so the browser sent each relative
link through the attachment route instead of the workspace routes.

Each session now resolves workingDir once in the background, bounded by
probePathKind() and a timed realpath(), and setCwd() writes a reading at
or under the resolved path back in workingDir's spelling. Until that
settles only a reading lexically inside workingDir is taken. An unknown
probe or a failed realpath leaves readings untranslated and retries
after WORKSPACE_REALPATH_RETRY_MS.

Also corrects two comments: cwd is written to state.json but never read
back, and a blank pane path is usually a pane whose program exited.
This commit is contained in:
Aamer Akhter committed 2026-10-28 19:29:05 -04:00
1 parent 54c1692350
commit f23e18b1fc
6 files changed
+237 -9

No files matched your search

+7
View File
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
/**
* How long a session waits before trying again to resolve its workspace's symlinks
* after the folder did not answer, so a mount that recovers is picked up (the pane
* directory reading stays in the resolved spelling meanwhile).
*/
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
+100 -1
View File
@@ -30,6 +30,7 @@
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
import { realpath } from 'node:fs/promises';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
@@ -71,6 +72,8 @@ import {
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 { 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';
import { probeRemoteCliVersion } from './remote-hosts.js';
@@ -398,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */
const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000;
/** Whether absolute `path` is `root` or lies under it (a lexical compare). */
function isSameOrUnder(path: string, root: string): boolean {
return path === root || path.startsWith(root === '/' ? '/' : `${root}/`);
}
/**
* Rewrite `path` from under `from` to the same place under `to`; a path not at or
* under `from` comes back unchanged. Exported for unit testing.
*/
export function rebasePath(path: string, from: string, to: string): string {
if (path === from) return to;
if (path.startsWith(`${from}/`)) return to + path.slice(from.length);
return path;
}
/**
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
* client can spawn at the same size and avoid the resize-flicker / scrollback
@@ -748,6 +766,16 @@ export class Session extends EventEmitter {
* serialized by toState() but never restored by the constructor.
*/
private _cwd: string | 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
* spelling. `undefined` until the first resolution settles; `null` when there
* is nothing to translate (no symlink in the path) or it could not be resolved.
*/
private _workspaceRealPath: string | null | undefined;
private _workspaceRealPathPending = false;
/** When a resolution that met an unreachable folder may be tried again (0 = never). */
private _workspaceRealPathRetryAt = 0;
/**
* How many starts, attaches or relaunches are running for this session's
* pane. While one is, a dead-pane reading may describe a pane that is being
@@ -1504,14 +1532,85 @@ export class Session extends EventEmitter {
* `docker exec`, so their pane directory is a host directory the agent was
* never in, and a direct-PTY or rediscovered session has no pane Codeman can
* vouch for. A relative value is refused rather than stored.
*
* tmux reads the pane's directory from the process cwd, which is always the
* resolved path, while `workingDir` keeps the spelling the case was created
* with. A workspace reached through a symlink would otherwise read as outside
* itself, and the browser's preview routing (a lexical compare against
* `workingDir`) would send every relative link to the attachment route. So a
* reading at or under the workspace's resolved path is written back in
* `workingDir`'s spelling. Until that resolution has settled, only a reading
* lexically inside `workingDir` is accepted.
*/
setCwd(next: string | undefined): boolean {
const resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
if (resolved !== undefined) {
if (
this._workspaceRealPath === undefined ||
(this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt)
) {
this._resolveWorkspaceRealPath();
}
if (this._workspaceRealPath === undefined) {
// The workspace's resolved path is not known yet. A reading outside
// `workingDir` could be the workspace itself seen through its symlink, so
// it waits for the next tick rather than mis-route links for one.
if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false;
} else if (this._workspaceRealPath !== null) {
resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling());
}
}
if (resolved === this._cwd) return false;
this._cwd = resolved;
return true;
}
/** `workingDir` without a trailing separator (the root stays `/`). */
private _workspaceSpelling(): string {
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
}
/**
* Resolve `workingDir`'s symlinks in the background for {@link setCwd}.
*
* Bounded: the folder can sit on a network mount that stopped answering, so it
* is probed with `probePathKind()` first and the `realpath()` is raced against
* the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no
* translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a
* folder that is absent or not a directory is settled for good.
*/
private _resolveWorkspaceRealPath(): void {
if (this._workspaceRealPathPending) return;
this._workspaceRealPathPending = true;
const spelling = this._workspaceSpelling();
void (async () => {
let real: string | null = null;
let retry = false;
try {
const kind = await probePathKind(spelling);
if (kind === 'unknown') {
retry = true;
} else if (kind === 'directory') {
let timer: ReturnType<typeof setTimeout> | undefined;
const answer = await Promise.race([
realpath(spelling).catch(() => undefined),
new Promise<undefined>((resolveTimeout) => {
timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS);
timer.unref?.();
}),
]).finally(() => clearTimeout(timer));
if (answer === undefined) retry = true;
else if (answer !== spelling && answer !== '/') real = answer;
}
} catch {
retry = true;
}
this._workspaceRealPath = real;
this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0;
this._workspaceRealPathPending = false;
})();
}
/**
* Record what the mux layer observed of this pane's agent, and say whether
* that changed the answer. The caller persists and broadcasts on a true.
+5 -3
View File
@@ -432,9 +432,11 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
* - Only sessions with EXACTLY ONE pane answer, for the reason
* {@link derivePaneExits} gives: a hand-split session has no single "the"
* pane to speak for it.
* - Only absolute paths count. A blank field is a tmux that did not answer
* (it could not read the process cwd that tick), so a single-pane session
* with a blank field keeps its entry from `previous` instead of losing it.
* - Only absolute paths count. A blank field is a tmux that could not read the
* process cwd: usually a pane whose program exited under `remain-on-exit`
* (tmux 3.4 then reports it blank on every read), otherwise a read that
* failed that tick. Either way the directory is unknown, not gone, so a
* single-pane session with a blank field keeps its entry from `previous`.
* A session split into several panes, or one the read no longer lists, gets
* no entry: those are authoritative absences, not unanswered questions.
*