Merge pull request #740: feat(terminal): resolve relative path links against the pane's current directory

This commit is contained in:
Codeman maintainer committed 2026-11-04 16:57:30 +01:00
commit 4f50af6a9f
14 files changed
+896 -8

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;
+11 -1
View File
@@ -271,7 +271,7 @@ export interface PaneCaptureOptions {
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -396,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/**
* The directory the session's pane was in at the last pane read
* (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
* batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
* path printed in the terminal resolves against it; it is never a
* confinement root. A multiplexer without it leaves relative paths resolving
* against `workingDir`.
*/
getPaneCurrentPath?(muxName: string): string | undefined;
/**
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
* reports, or 0 when it reports none. The exited-agent sweep closes a session
+127
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
@@ -743,6 +761,21 @@ export class Session extends EventEmitter {
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* The pane's live directory, from the pane-exit watcher's read. Runtime-only:
* 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
@@ -1496,6 +1529,99 @@ export class Session extends EventEmitter {
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
/** The pane's live working directory, when the pane read has reported one. */
get cwd(): string | undefined {
return this._cwd;
}
/**
* Record the pane's live directory. Returns whether it CHANGED, so the caller
* broadcasts only on a real transition.
*
* Scoped by {@link paneExitApplies}, the same rule as the exit reading: a
* remote session's local pane runs the ssh client and a docker case's runs a
* `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 {
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.
@@ -2173,6 +2299,7 @@ export class Session extends EventEmitter {
pid: this.pid,
status: this._status,
workingDir: this.workingDir,
cwd: this._cwd,
remote: this._remote,
docker: this._docker,
owner: this._owner,
+65
View File
@@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
return exits;
}
/**
* Decide, from every pane tmux listed, which directory each tmux session's pane
* is in now (`#{pane_current_path}`). Exported for unit testing.
*
* This is what a relative path printed in the terminal is relative TO: a
* session's `workingDir` is fixed at create time and never follows a `cd`.
*
* - 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 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.
*
* The launch directory is NOT filtered here (unlike the rediscovery path): a
* remote or docker pane that never leaves it is refused by `Session.setCwd()`,
* and a local pane reporting it really is there.
*/
export function derivePaneCurrentPaths(
rows: PaneRow[],
previous: ReadonlyMap<string, string> = new Map()
): Map<string, string> {
const panesPerSession = new Map<string, number>();
for (const row of rows) {
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
}
const paths = new Map<string, string>();
for (const row of rows) {
if (panesPerSession.get(row.sessionName) !== 1) continue;
const path = row.currentPath ?? previous.get(row.sessionName);
if (!path || !path.startsWith('/')) continue;
paths.set(row.sessionName, path);
}
return paths;
}
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
@@ -2005,6 +2045,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* raw pane reading.
*/
private paneExits: Map<string, TrackedPaneExit> = new Map();
/**
* muxName -> `#{pane_current_path}` from the last authoritative pane read
* ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
* so it is bounded by the panes on the socket. Like {@link paneExits} it holds
* the raw reading; `Session.setCwd()` decides which sessions may use it.
*/
private paneCurrentPaths: Map<string, string> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
@@ -3507,6 +3554,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
/**
* The directory the session's pane was in at the last authoritative pane
* read, or `undefined` when unknown. Costs nothing: it reads the map the
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
* per click. A failed or empty read keeps the previous answers, and so does
* a blank `#{pane_current_path}` on a single-pane session. After a successful
* read, `undefined` means that read listed no single directory for the
* session: it was split into several panes or is no longer on the socket.
*/
getPaneCurrentPath(muxName: string): string | undefined {
return this.paneCurrentPaths.get(muxName);
}
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
@@ -3568,6 +3628,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.paneExitReadInFlight = false;
}
if (rows.length === 0) return;
// The directory reading rides the same read. It is applied before the
// generation check below: a respawn invalidates an exit observation, but the
// pane's directory at read time is still the best answer there is. A blank
// field on a single-pane session carries the previous answer forward.
this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths);
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
+13
View File
@@ -834,6 +834,19 @@ export interface SessionState {
status: SessionStatus;
/** Working directory path */
workingDir: string;
/**
* The directory the session's local tmux pane is in NOW (`#{pane_current_path}`),
* when known. `workingDir` is fixed at create time and never follows a `cd`;
* this is what a relative path printed in the terminal is relative to.
*
* Runtime-only: refreshed from the pane-exit watcher's read and never restored
* from disk, so a restart starts blank. Absent for remote and docker sessions
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
* rediscovered sessions. NOT a confinement root: the file routes stay confined
* to `workingDir`, and a path resolved outside it goes through the attachment
* routes like any other out-of-workspace path.
*/
cwd?: string;
/** 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 */
+26
View File
@@ -2426,6 +2426,31 @@ function resolveLinkedFilePath(filePath, workingDir) {
return `/${parts.join('/')}`;
}
/**
* The directory a RELATIVE path printed for a session is relative TO.
*
* `workingDir` is recorded once at create time and never follows a `cd`, so it
* stops describing where a shell (or an agent that changed directory) really
* is. `session.cwd` is the pane's live `#{pane_current_path}`, published by the
* server from its pane read. It only changes which BASE a relative path joins;
* the result is then routed like any other path, so a target outside the
* workspace still goes through the attachment routes.
*
* Remote and Docker sessions are excluded (the server never sends them a cwd,
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
* so its directory says nothing about the agent. An unknown cwd falls back to
* `workingDir`.
*
* @param {{workingDir?: string, cwd?: string, remote?: unknown, docker?: unknown}} [session]
* @returns {string|undefined}
*/
function relativeResolutionBase(session) {
if (!session) return undefined;
const cwd = session.cwd;
if (!session.remote && !session.docker && typeof cwd === 'string' && cwd.startsWith('/')) return cwd;
return session.workingDir;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
@@ -4088,6 +4113,7 @@ if (typeof window !== 'undefined') {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
relativeResolutionBase,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
+5 -2
View File
@@ -2146,13 +2146,16 @@ Object.assign(CodemanApp.prototype, {
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* directory the session's pane is in now, else its working directory
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
const session = this.sessions?.get?.(sessionId);
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
return resolveLinkedFilePath(filePath, base);
},
/**
+13 -2
View File
@@ -2854,13 +2854,24 @@ export class WebServer extends EventEmitter {
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
if (!getPaneExit) return;
const getPaneCurrentPath = this.mux.getPaneCurrentPath?.bind(this.mux);
for (const session of this.sessions.values()) {
const muxName = session.muxName;
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
// for such a session anyway.
if (!muxName) continue;
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
// The pane's live directory rides the same read: it is the base a relative
// path printed in the terminal resolves against. The manager's answer is
// applied as given, `undefined` included: it keeps its map across a failed
// or empty read and carries a blank `#{pane_current_path}` forward, so an
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const cwdChanged = getPaneCurrentPath ? session.setCwd(getPaneCurrentPath(muxName)) : false;
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!cwdChanged) {
continue;
}
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();