Skip to content

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

Open
aakhter wants to merge 3 commits into
Ark0N:masterfrom
aakhter:pr/terminal-links-live-cwd
Open

aakhter wants to merge 3 commits into
Ark0N:masterfrom
aakhter:pr/terminal-links-live-cwd

Conversation

@aakhter

@aakhter aakhter commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

#686 and #687 made relative paths in terminal output clickable. On master, resolveLinkedFilePath() joins every relative path to the session's workingDir, which is set when the session is created and never changes after a cd. Once a shell moves into a subdirectory or a sibling checkout, ls, git status, rg, compiler errors and test output all print paths relative to the new directory. Clicking one then opens the file with the same name in the workspace root, or the preview says "File not found" while the file is right there.

Master already reads #{pane_current_path} in the pane-exit watcher's batched list-panes -a (it is the last field of PANE_LIST_FORMAT), but uses it only to rebuild a rediscovered session's directory at boot.

Approach

  • No new tmux call. derivePaneCurrentPaths() (tmux-manager.ts) turns the rows the pane-exit watcher already reads every 2 s into one directory per tmux session. Only sessions with exactly one pane count, for the same reason derivePaneExits() gives, and only absolute paths are kept. A blank #{pane_current_path} on a single-pane session (tmux could not read the process cwd that tick) carries the previous entry forward; a split or unlisted session gets no entry. refreshPaneExits() saves the result before the generation check, so a respawn that races the read throws away the exit observation but keeps the directory. TmuxManager.getPaneCurrentPath() reads from that map, so a click or hover never runs tmux.
  • The workspace's own spelling. tmux reads the directory from the process cwd, which is always the resolved path, while workingDir keeps the spelling the case was created with. Each session resolves workingDir once in the background (probePathKind() first, then a realpath() raced against PATH_PROBE_TIMEOUT_MS, never on the watcher tick), and setCwd() writes a reading at or under the resolved path back in workingDir's spelling, so a symlinked ~/codeman-cases, a case linked through a symlink or a macOS /tmp workspace keeps its relative links on the workspace routes. Until the resolution settles, only a reading lexically inside workingDir is taken. An unknown probe or a failed or slow realpath() leaves readings untranslated and retries after WORKSPACE_REALPATH_RETRY_MS (60 s).
  • Same scoping as paneExit. Session.setCwd() uses paneExitApplies, so it fails closed for the same four cases: a remote session (its local pane runs the ssh client), a Docker case (its pane runs docker exec), a direct-PTY session and a rediscovered one. A relative value is refused.
  • Same tick, no new event. applyPaneExits() (server.ts) also sets SessionState.cwd on the paneExitsUpdated tick and sends session:updated only when the value changes. A directory change alone never persists. The field is written to state.json with the rest of the session state, but the constructor never reads it back, so a restart starts with no value. The server applies the manager's answer as given, undefined included. The manager keeps its map across a failed or empty read and carries a blank field forward, so after a successful read undefined means the read listed no single directory for the session: it was split into several panes or is no longer on the socket. Either way cwd is cleared, session:updated is sent once for the clear, and the browser falls back to workingDir.
  • The browser changes only the base. relativeResolutionBase(session) (constants.js) returns the live cwd when known, otherwise workingDir, and checks remote/docker again. _resolveLinkedFilePath() (terminal-ui.js) uses it, so the terminal provider, the split pane, touch taps and the response viewer all resolve the same way.

What can be opened does not change. _isExternalPreviewPath(), file-content/file-raw and the log viewer's tail -f allowlist all still check against workingDir. A relative link under a pane that has cd'd outside the workspace becomes an absolute path outside it, and goes through the attachment routes (notify: false) with the full attachment guard, the same as clicking that absolute path on master.

tmux reports the cwd of the pane's foreground process-group leader. A shell's cd therefore moves it, but a subprocess that an agent CLI starts (Claude's Bash tool, codex exec) does not. For agent sessions the value stays the CLI's own directory, which is what their relative paths are written against.

Verification

  • Side-by-side sweep vs master. I loaded master's constants.js, terminal-ui.js and panels-ui.js (git show origin/master:...) and this branch's in the same vm harness the tests use. I ran findFilePathLinks(), then _resolveLinkedFilePath(), then _isExternalPreviewPath() over 188 lines of realistic output: git status/diff --stat, ls/ls -l, rg path:line:, tsc/eslint/rustc/go/gcc/mypy errors, vitest/jest/pytest/go test output and stack frames, build tool output, shell prompts (user@host:~/proj$, /mnt/c/proj ❯), Claude ⏺/⎿ and Codex • rows, box tables, logs, ./, ../, ~/, absolute paths, URLs, and prose with /. That gave 144 links per scenario. Results by scenario:

    scenario differing links unintended detection differences
    cwd = subdirectory of the workspace 131 0 0
    cwd = sibling checkout 126 0 0
    cwd = $HOME 131 0 0
    cwd = workingDir 0 0 0
    no cwd known 0 0 0
    remote session with a cwd 0 0 0
    Docker session with a cwd 0 0 0

    Every difference is a relative path whose target is now resolveLinkedFilePath(path, cwd) instead of resolveLinkedFilePath(path, workingDir). Absolute, ~/ and URL links are identical, and the set of detected links is identical. When cwd is outside the workspace, the changed links route through the attachment route (marked external) rather than the workspace routes. The sibling count is 5 lower because ../x paths resolve to the same target from both directories.

  • Live pane. I ran an isolated instance (CODEMAN_INSTANCE, port 5534, --host 127.0.0.1 confirmed with ss -ltn, no password) with a real bash 5.2.21 shell session on tmux next-3.7, xterm 6.0.0, and Playwright 1.60.0 headless Chromium at DSF 1. The workspace had note.md, sub/note.md and other/note.md, plus ../sibling/note.md outside it. For each cd I ran ls, called the real _terminalLinkProvider.provideLinks() on the printed row and ran activate():

    • after cd sub: GET /api/sessions showed cwd = .../proj/sub within one tick, workingDir was unchanged, and the link opened .../proj/sub/note.md
    • after cd ../../sibling (outside the workspace): the link opened .../sibling/note.md through the attachment route, and the preview rendered "SIBLING note"
    • after cd ../proj/other: the preview rendered "OTHER note"
    • after cd ..: the preview rendered "root note"
    • a child process that cds while its parent stays in the foreground (python3 -c "subprocess.Popen(['sh','-c','cd sub; sleep 6'])...") left both pane_current_path and the API cwd on the parent's directory, as the agent-CLI note above expects.
  • Symlinked workspace, live. On tmux next-3.7 a pane opened in a symlink link to phys, after cd src, reports #{pane_current_path} as phys/src, confirming the premise. The new tests use a real symlinked temp directory and the real realpath(): a reading of phys/src is stored as link/src, src/index.ts from it stays on the workspace route (_isExternalPreviewPath false), and a cd to a sibling directory still routes through the attachment route.

  • Mutation. I applied 22 mutations, one at a time, against test/pane-cwd-tracking.test.ts (41 tests). All 22 fail at least one test (14 measured on the first revision, 5 on the second, the 3 for the symlink rewrite on the current head):

    • setCwd scope dropped (5 failures); setCwd accepts a relative path (1); setCwd reports an unchanged clear as a change (6)
    • single-pane rule dropped (1); relative pane path accepted (1)
    • blank field not carried forward (3); refresh does not pass the previous map (2); previous entries carried forward for split or unlisted sessions (5)
    • refresh no longer records paths (3); paths recorded after the generation check (1)
    • server: an undefined answer skipped instead of clearing (2); no broadcast on change (1); broadcast on every tick (1); persist on a cwd change (1)
    • browser: remote/Docker re-check dropped (1); cwd ignored (3); relative cwd accepted (1)
    • terminal-ui keeps workingDir (3)
    • symlink: no rewrite to workingDir's spelling (2); a physical reading taken before the resolution settles (3); the rewrite matching a sibling that only shares the prefix, app vs application (1)
    • confinement root follows cwd (1)
  • Negatives at every layer: parser (split session, blank, relative, a path that contains the separator); manager (empty read and blank field keep the value, a split session and a session no longer listed are dropped, a remote/Docker-only manager makes no read); Session (remote, Docker, direct-PTY, rediscovered, relative); server, driving the real TmuxManager with only readPaneRows faked (single-pane read sets cwd, a two-pane read clears it, a read without the session clears it, each clear broadcast once; a blank field and a failed read keep it; remote and Docker publish nothing, no persist, no rebroadcast on an unchanged tick); browser (remote, Docker, relative cwd, absolute and ~/ passthrough, routing still decided by workingDir).

  • Bounds: no new tmux, filesystem or network call. The read is the existing async execAsync with EXEC_TIMEOUT_MS, one in flight at a time (paneExitReadInFlight), so a hung tmux cannot stack reads. The map is replaced on each read, so its size is bounded by the panes on the socket. No regex was added.

  • Gates: typecheck, lint, format:check, check:frontend-syntax, check:public-assets, check:lockfile and check:browser-excludes all pass.

  • test:ci on the current head (f23e18b): 13539 passed, 6 failed, 12 skipped (667 files). The 6 failures are all in test/tmux-prefix-table.test.ts (restrictTmuxPrefixTable against a real tmux x4, nested Docker/remote tmux prefix table x2). They come from this machine's tmux next-3.7 and fail on plain origin/master (f1f7eec) here too, which I compared by name in the previous round. The git-fetch timing test that failed on both in the previous round passed this time.

Trade-offs

  • The base is the pane's directory when the link is clicked, not when the line was printed. A relative path printed before a cd and clicked afterwards resolves against the new directory. On master the same path resolves against the start directory, which is right only if that is where it was printed. Tagging each row with the directory it was printed in would mean xterm markers per row and a pane read per write, which I kept out of scope.
  • The value lags a cd by up to one watcher tick (2 s).
  • A session the user split by hand into several panes has no single directory, so it falls back to workingDir.
  • A blank #{pane_current_path} keeps the last directory for as long as tmux leaves the field blank, the same way a failed read keeps it; it does not expire after one tick.

For review

  • New field SessionState.cwd (optional, runtime-only) in /api/v1/sessions responses and on session:updated. It is additive and has no setting, because it only changes which base a relative link joins and the default behaviour does not depend on a choice. The name and whether it belongs on the public session shape are your call and are unchanged in this revision; if you want it named differently (for example paneCwd) or kept out of the public state, it can go on a private channel. docs/api-reference.md now documents it.
  • Not filtering the tmux launch directory (/tmp) the way the rediscovery path does: a local shell that really is in /tmp should resolve there, and remote/Docker panes that never leave it are already refused by the scope rule.

…t directory

A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".

The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.

Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.

In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
@Ark0N

Ark0N commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks for this, aakhter. This PR makes a relative path printed in the terminal resolve against the directory the session's pane is in now (#{pane_current_path}, taken from the pane-exit watcher's existing list-panes -a read) instead of the fixed workingDir, while every confinement check stays on workingDir. Reusing the existing read, scoping it with paneExitApplies, and the side-by-side sweep against master are careful work, and the gates and the full suite pass here.

One change is needed before merge:

The session keeps a stale directory when an authoritative read stops answering for it (src/web/server.ts:2825). applyPaneExits() only calls setCwd() when getPaneCurrentPath() returns a value. TmuxManager already keeps its map across a failed or empty read (refreshPaneExits() returns before replacing it, src/tmux-manager.ts:3622), so after a successful read an undefined from the manager always means the read listed no single absolute directory for that session: it was split into several panes, or it is no longer on the socket. With the guard, Session.cwd keeps its old value instead. I reproduced it by wiring the real TmuxManager.getPaneCurrentPath() into applyPaneExits(): after a single-pane read of /work/proj/sub and then a two-pane read, the manager answers undefined and session.cwd is still /work/proj/sub, and the same happens after a read that no longer lists the session. That contradicts the PR description (a hand-split session falls back to workingDir) and the new text in docs/architecture-invariants.md:412 (an authoritative read that no longer lists it forgets it).

The ask:

  • Call session.setCwd(cwd) whenever the multiplexer has getPaneCurrentPath, without the cwd !== undefined guard. Before the first read both sides are undefined, so nothing changes there.
  • Replace keeps the last directory when the read has no answer for the session (test/pane-cwd-tracking.test.ts:255). Its stub returns undefined in a case where the real manager still returns the old value. A test that drives the real manager through a single-pane read, then a two-pane read, then a read without the session, and expects cwd to clear, pins the behaviour the docs describe. keeps the last known directory when a read comes back empty already covers retention across a failed read.
  • If a blank #{pane_current_path} on a single-pane session (tmux could not read the process cwd for one tick) should keep the last value rather than drop to workingDir for that tick, carry the previous entry forward for that case in derivePaneCurrentPaths() or refreshPaneExits(), so the server does not have to tell the cases apart.

Optional: docs/api-reference.md does not mention the new SessionState.cwd. One line saying it is runtime-only, absent for remote, Docker, direct-PTY and rediscovered sessions, and never a confinement root would help anyone reading the session state from the API.

Still open: you asked whether the field should be named differently (for example paneCwd) or kept off the public session state. That is a decision about the public /api/v1 session shape and it has not been made yet, so there is nothing to change for it in this round.

Once the guard and the test are updated, this is ready for another review.

…has none

applyPaneExits() only applied the manager's directory when it returned a
value, so after a successful read a session that was split into several
panes, or was no longer on the socket, kept its stale cwd. The manager
already keeps its map across a failed or empty read, so the server now
applies its answer as given, undefined included, and setCwd() broadcasts
the clear once.

A blank #{pane_current_path} on a single-pane session (tmux could not read
the process cwd that tick) now carries the previous entry forward in
derivePaneCurrentPaths(), so only a split or unlisted session clears.

The server tests now drive the real TmuxManager with only the tmux read
faked. docs/api-reference.md documents SessionState.cwd.
@aakhter

aakhter commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All items are in 54c1692, on top of 1607b1e.

  • Stale directory after an authoritative read (required). applyPaneExits() now applies getPaneCurrentPath() as given whenever the multiplexer has it, undefined included (src/web/server.ts). setCwd(undefined) clears the value and reports the change once, so the clear is broadcast once and a later tick that agrees sends nothing. Pinned by clears the directory once the session is split into several panes and clears the directory once a read no longer lists the session (both also assert one broadcast for the clear and none on the next agreeing tick).
  • Test replaced (required). keeps the last directory when the read has no answer for the session is gone. The new server tests drive the server's own TmuxManager (asserted instanceof), with only readPaneRows faked to return parsed raw list-panes -a output, so parsing, derivePaneCurrentPaths(), refreshPaneExits() and getPaneCurrentPath() are the real ones: single-pane read sets cwd, a two-pane read clears it, and separately a read that no longer lists the session clears it. keeps the last known directory when a read comes back empty stays, and the new keeps the directory across a blank field and across a failed read covers a failed read end to end too.
  • Blank #{pane_current_path} on a single-pane session. derivePaneCurrentPaths(rows, previous) carries the previous entry forward for a blank field on a single-pane session, and refreshPaneExits() passes the current map in. A split session, an unlisted one and a relative value get no entry, so the server never has to tell the cases apart. Pinned by carries the previous directory forward for a blank field on a single-pane session, carries nothing forward for a split session, an unlisted one or a relative field, keeps the last directory across a blank field on a single-pane read and forgets the directory once the session is split into several panes (manager), plus the server test above.
  • docs/api-reference.md (optional). New short "Pane directory (cwd)" section next to the other session-state fields: runtime-only, absent for remote, Docker, direct-PTY and rediscovered sessions and for a split session, never a confinement root.
  • Docs and description. docs/architecture-invariants.md now states the retention rule exactly (failed or empty read and blank field keep; split or unlisted clear, so a hand-split session falls back to workingDir; the server applies the answer as given). The PR description's Approach, Verification and Trade-offs are updated to match, so "a hand-split session falls back to workingDir" is now true.

Same class, one step further: I checked every consumer of a per-session answer from the pane read for an undefined treated as "no answer" when it is an authoritative absence. The only other one is paneExit next to it: setPaneExit(getPaneExit(muxName)) already passes undefined through to UNKNOWN with no guard, and getPaneExitReadCount() answers 0, which is right, so nothing changed there. The rediscovery path's use of currentPath skips a blank value at boot, which is a different decision (no directory to rebuild from) and also correct. In the browser, _onSessionUpdated() replaces the session object wholesale, so a cleared cwd (absent from the JSON) does reach the client instead of leaving the old value merged in.

Trade-off, stated plainly: a blank field keeps the last directory for as long as tmux leaves it blank, the same rule as a failed read; it does not expire after one tick.

Still yours: the field name and whether cwd belongs on the public /api/v1 session shape. Both are unchanged in this revision pending your call.

Evidence:

  • Mutations for this round, 5 of 5 caught, against test/pane-cwd-tracking.test.ts (34 tests): server guard restored (2 failures), blank field not carried forward (3), refresh not passing the previous map (2), previous entries carried for split and unlisted sessions (5), setCwd reporting an unchanged clear as a change (6).
  • test/pane-cwd-tracking.test.ts, test/tmux-manager.test.ts, test/session-pane-exit.test.ts, test/pane-exit-sweep.test.ts, test/link-provider-regex.test.ts, test/file-path-link-forms.test.ts: 217 passed.
  • typecheck, lint, format:check, check:frontend-syntax, check:public-assets, check:browser-excludes, check:lockfile: pass.
  • test:ci, branch vs plain origin/master (f1f7eec) in a detached worktree: 13531 vs 13497 passed, 7 vs 7 failed, 12 vs 12 skipped. The +34 is this file; the 7 failures are identical by name on both (6 in test/tmux-prefix-table.test.ts from this machine's tmux next-3.7, 1 timing test in test/git-fetch.test.ts).

@Ark0N

Ark0N commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks for the second round, aakhter. This PR makes a relative path printed in the terminal or the response viewer resolve against the directory the session's tmux pane is in now, instead of the fixed workingDir. The round-1 change is in and correct: applyPaneExits() now applies getPaneCurrentPath() as given, the clear is broadcast once, the browser replaces the session object wholesale so the clear reaches the page, and the new server tests drive the real TmuxManager. Typecheck, lint, format, frontend syntax and the touched test files all pass here.

One change is needed before merge:

A workspace reached through a symlink turns every relative link into an out-of-workspace one (src/session.ts:1508, decided by _isExternalPreviewPath() at src/web/public/panels-ui.js:4186). tmux reads a pane's directory from /proc/<pgrp>/cwd, which is always the resolved path. On tmux 3.4, a pane started in a symlinked folder reports the target folder, and after cd src the target's src (the same effect TMUX_LAUNCH_CWD_SPELLINGS handles for /tmp and /private/tmp). workingDir keeps the path as typed: local cases are join(homedir(), 'codeman-cases', name) or CODEMAN_CASES_PATH, and Link Existing stores the folder as entered (src/web/routes/case-routes.ts:1588-1612). _isExternalPreviewPath() compares lexically, so with this branch's frontend code, a session with workingDir: '/home/u/codeman-cases/app' and cwd: '/mnt/data/app' resolves src/index.ts to /mnt/data/app/src/index.ts, which reads as external. From the first watcher tick, with no cd, a terminal .ts/.json/.log link that master opens in the live-follow log viewer opens a static preview through the attachment route instead, the preview loses its Edit button, and response-viewer links change route the same way. Server-side confinement is fine (route-helpers.ts:96 realpaths both sides); only the browser's routing decision changes. This hits anyone whose ~/codeman-cases or home is a symlink, cases linked through a symlinked path, and macOS workspaces under /tmp or /var.

The ask:

  • Resolve realpath(workingDir) once per session on the server (async and bounded, since the folder can sit on a stalled mount: no realpathSync on the 2 s watcher tick, see the fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516 rule in CLAUDE.md; skip the translation when the probe answers unknown or fails), cache it on the Session, and in setCwd() rewrite a reading equal to or under that prefix back into workingDir's spelling plus the remainder.
  • Add a test with workingDir behind a symlink and the pane reporting the physical path: an in-workspace relative link keeps _isExternalPreviewPath false, and a cd outside the workspace still routes through the attachment route.

Two wording nits, which I can also fix at merge:

  • docs/api-reference.md:977 says cwd is never persisted. _persistSessionStateNow() spreads toState() into state.json, so it is written with every other persist and simply never read back. "Never restored after a restart" is accurate.
  • src/tmux-manager.ts:435: on tmux 3.4 a pane that exited under remain-on-exit reports a blank #{pane_current_path} on every read, so a dead pane is the usual cause of a blank field. The carry-forward rule is right for it; the comment can say so.

Still open: whether the field keeps the name cwd (or becomes paneCwd) and whether it belongs on the public /api/v1 session state. That decision has not been made yet, so there is nothing to change for it in this round.

Once the symlinked-workspace case is handled and pinned by a test, this is ready for another review.

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.
@aakhter

aakhter commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Round 2 is in f23e18b.

Symlinked workspace. Session.setCwd() now writes a pane reading back in workingDir's spelling when it is at or under the workspace's resolved path.

  • The resolution runs once per session in the background and is bounded the fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516 way: probePathKind() first, then a realpath() raced against PATH_PROBE_TIMEOUT_MS. Nothing synchronous runs on the 2 s watcher tick.
  • An unknown probe, a failed realpath() or a slow one leaves readings untranslated. It tries again after WORKSPACE_REALPATH_RETRY_MS (60 s, a plain constant, no env var), so a mount that recovers is picked up. A folder that is absent or not a directory is settled with no translation.
  • The first tick usually lands before the resolution finishes. Until it does, only a reading lexically inside workingDir is taken, and anything else waits for the next tick. So the first watcher tick cannot route links as external either.
  • Pinned in test/pane-cwd-tracking.test.ts, with a real symlinked temp directory and the real realpath():
    • phys/src is stored as link/src.
    • src/index.ts from there keeps _isExternalPreviewPath false.
    • A cd to a sibling directory still routes through the attachment route.
    • A physical reading before the resolution settles is withheld, while an in-workspace one is taken.
    • A workingDir with no symlink is left alone, and so is a missing one once it has settled.
    • rebasePath() leaves /mnt/data/application alone when rebasing /mnt/data/app.
  • On tmux next-3.7 here, a pane opened in a symlinked folder reports the target after cd src, the same as you saw on 3.4.

Wording.

  • docs/api-reference.md now says cwd is written to state.json with the rest of the session state but never read back after a restart. It also says the value is reported in workingDir's spelling for a symlinked workspace.
  • The derivePaneCurrentPaths() comment now names a pane whose program exited under remain-on-exit as the usual cause of a blank field.
  • docs/architecture-invariants.md covers both changes.

Still yours: the field name (cwd or paneCwd) and whether it belongs on the public /api/v1 session state. Nothing in this round depends on either.

Evidence.

  • 3 new mutations, each caught: no rewrite (2 failures), the physical reading taken before the resolution settles (3), and the rewrite matching a sibling that only shares the prefix (1). That makes 22 across all rounds.
  • test/pane-cwd-tracking.test.ts has 41 tests. bounded-path-probe, the pane-exit, tmux-manager and session-routes files pass with it (406 tests).
  • typecheck, lint and prettier on the touched files pass.
  • test:ci on f23e18b: 13539 passed and 6 failed. All 6 are the tmux-prefix-table tests that fail on master here too, because this machine runs tmux next-3.7.

@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thanks for round 3, aakhter. This PR makes a relative path printed in the terminal or the response viewer resolve against the directory the session's tmux pane is in now, instead of the fixed workingDir, while every routing and confinement check stays on workingDir.

The round-2 change is in and correct. setCwd() rewrites a reading at or under the workspace's resolved path back into workingDir's spelling, the realpath() runs once per session in the background behind probePathKind() and a timeout (never on the watcher tick), and until it settles only a reading lexically inside workingDir is taken, so the first tick cannot mis-route links. The tests use a real symlinked directory and the real realpath(), and rebasePath() keeps /mnt/data/application apart from /mnt/data/app. Both wording nits landed as well. Typecheck, lint, format, frontend syntax and the touched test files (463 tests) pass here, and I confirmed cwd reaches the browser: getSessionStateWithRespawn() spreads toState().

Two small things, which I will apply at merge time, so there is nothing for you to push:

  • test/pane-cwd-tracking.test.ts:263: the server tests assert session.toState().cwd, but the browser receives getSessionStateWithRespawn(session) (src/web/server.ts:2528). It carries the field today because toLightDetailedState() spreads toState(); one assertion on that payload keeps a future explicit field list from dropping it unnoticed.
  • docs/architecture-invariants.md:412: the pane-directory text sits in the middle of "Path forms beyond the rooted one", ahead of the ~/ and hard-wrap material, so the paragraph now has two Tests: lines. It moves into its own paragraph with the text unchanged.

Still open: whether the field keeps the name cwd (or becomes paneCwd) and whether it belongs on the public /api/v1 session state. That decision has not been made yet. Nothing in it needs a change from you, and the rest of the PR is ready from your side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants