diff --git a/docs/api-reference.md b/docs/api-reference.md index 4a0cb6b7..a7bb3c34 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -969,6 +969,15 @@ also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. +## Pane directory (`cwd`) + +Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `cwd`, +the directory the session's local tmux pane is in now (`#{pane_current_path}`), which +the web UI uses as the base for a relative path printed in the terminal. It is +runtime-only (never persisted, so a restart starts without it), absent for remote, +Docker, direct-PTY and rediscovered sessions and for a session split into several panes, +and never a confinement root: file routes stay confined to `workingDir`. + ## Session model (`displayModel`) Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7e7fcd8d..e61d4320 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. -**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.cwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `cwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `cwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ Runtime-only: serialized by `toState()` but never restored, and a directory change alone never persists. A read that has no answer for a session keeps its last directory; an authoritative read that no longer lists it forgets it. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.cwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `cwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `cwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ Runtime-only: serialized by `toState()` but never restored, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 3d23a373..8853ad82 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -432,13 +432,20 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map { +export function derivePaneCurrentPaths( + rows: PaneRow[], + previous: ReadonlyMap = new Map() +): Map { const panesPerSession = new Map(); for (const row of rows) { panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1); @@ -446,7 +453,7 @@ export function derivePaneCurrentPaths(rows: PaneRow[]): Map { const paths = new Map(); for (const row of rows) { if (panesPerSession.get(row.sessionName) !== 1) continue; - const path = row.currentPath; + const path = row.currentPath ?? previous.get(row.sessionName); if (!path || !path.startsWith('/')) continue; paths.set(row.sessionName, path); } @@ -3549,7 +3556,10 @@ 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. + * 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); @@ -3618,8 +3628,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { 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. - this.paneCurrentPaths = derivePaneCurrentPaths(rows); + // 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. diff --git a/src/web/server.ts b/src/web/server.ts index 07467516..6c3ea4cd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2819,10 +2819,12 @@ export class WebServer extends EventEmitter { // for such a session anyway. if (!muxName) continue; // The pane's live directory rides the same read: it is the base a relative - // path printed in the terminal resolves against. A session missing from - // the read keeps its last answer (a failed read reports nothing). - const cwd = getPaneCurrentPath?.(muxName); - const cwdChanged = cwd !== undefined && session.setCwd(cwd); + // 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) { diff --git a/test/pane-cwd-tracking.test.ts b/test/pane-cwd-tracking.test.ts index fe72770d..b6d5584d 100644 --- a/test/pane-cwd-tracking.test.ts +++ b/test/pane-cwd-tracking.test.ts @@ -12,8 +12,9 @@ * carries `#{pane_current_path}`; `derivePaneCurrentPaths()` turns it into * one directory per session (single-pane sessions only, absolute paths * only). - * 2. The manager keeps the last authoritative answer, so a failed read never - * blanks a known directory. + * 2. The manager keeps the last authoritative answer, so a failed read or a + * blank field never blanks a known directory, while a read that lists the + * session as split, or not at all, clears it. * 3. Scoping: remote and Docker sessions never carry one (their local pane * runs the ssh / docker-exec CLIENT), and neither does a direct-PTY or a * rediscovered session. @@ -63,6 +64,28 @@ describe('derivePaneCurrentPaths', () => { const rows = parsePaneRows(['codeman-a|100|0|||', 'codeman-b|200|0|||rel/ative'].join('\n')); expect(derivePaneCurrentPaths(rows).size).toBe(0); }); + + it('carries the previous directory forward for a blank field on a single-pane session', () => { + // A blank `#{pane_current_path}` is a tmux that could not read the process + // cwd that tick, not a pane that has no directory. + const previous = new Map([['codeman-a', '/work/proj/sub']]); + const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||'), previous); + expect(paths.get('codeman-a')).toBe('/work/proj/sub'); + }); + + it('carries nothing forward for a split session, an unlisted one or a relative field', () => { + // Those are authoritative absences: the read answered, and its answer is + // "no single directory". + const previous = new Map([ + ['codeman-split', '/one'], + ['codeman-gone', '/two'], + ['codeman-rel', '/three'], + ]); + const rows = parsePaneRows( + ['codeman-split|100|0|||', 'codeman-split|101|0|||', 'codeman-rel|300|0|||rel/ative'].join('\n') + ); + expect(derivePaneCurrentPaths(rows, previous).size).toBe(0); + }); }); describe('TmuxManager pane directory bookkeeping', () => { @@ -102,6 +125,26 @@ describe('TmuxManager pane directory bookkeeping', () => { expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); }); + it('keeps the last directory across a blank field on a single-pane read', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = parsePaneRows('codeman-s1|100|0|||'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + }); + + it('forgets the directory once the session is split into several panes', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = parsePaneRows(['codeman-s1|100|0|||/work/proj/sub', 'codeman-s1|101|0|||/work/proj'].join('\n')); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); + it('records the directory even when a respawn races the read', async () => { // A respawn bumps the generation and discards the read's EXIT observation; // the directory the pane was in at read time is still the best answer. @@ -252,17 +295,77 @@ describe('a pane read reaching the session record', () => { expect(persist).not.toHaveBeenCalled(); }); - it('keeps the last directory when the read has no answer for the session', () => { - const { mux, sessions } = build(); + /** + * Drive the server's REAL TmuxManager: its parsing, its derivation and its + * `getPaneCurrentPath()`. Only the tmux exec seam (`readPaneRows`) is faked, + * with raw `list-panes -a` output, as the bookkeeping tests above do. + */ + const buildWithRealManager = () => { + const { sessions } = build(); + const mux = (server as unknown as { mux: TmuxManager }).mux; + expect(mux).toBeInstanceOf(TmuxManager); + let output = ''; + (mux as unknown as { readPaneRows: () => Promise }).readPaneRows = async () => parsePaneRows(output); + mux.registerSession({ + sessionId: 'aaaa', + muxName: 'codeman-aaaa', + pid: 100, + createdAt: 0, + workingDir: '/work/proj', + mode: 'shell', + attached: true, + } as MuxSession); const session = localMuxSession(); sessions.set(session.id, session); - mux.getPaneExit = () => undefined; - let dir: string | undefined = '/work/proj/sub'; - mux.getPaneCurrentPath = () => dir; - mux.emit('paneExitsUpdated'); - dir = undefined; - mux.emit('paneExitsUpdated'); + const broadcast = vi.fn(); + (server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced = + broadcast; + const tick = async (listPanes: string) => { + output = listPanes; + await mux.refreshPaneExits(); + mux.emit('paneExitsUpdated'); + }; + return { session, broadcast, tick }; + }; + + it('clears the directory once the session is split into several panes', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); expect(session.toState().cwd).toBe('/work/proj/sub'); + expect(broadcast).toHaveBeenCalledTimes(1); + + await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n')); + // An absent cwd is what makes the browser fall back to workingDir. + expect(session.toState().cwd).toBeUndefined(); + expect(broadcast).toHaveBeenCalledTimes(2); + + // The clear is announced once, not on every later tick that agrees. + await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n')); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + it('clears the directory once a read no longer lists the session', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); + expect(session.toState().cwd).toBe('/work/proj/sub'); + + await tick('codeman-other|300|0|||/elsewhere'); + expect(session.toState().cwd).toBeUndefined(); + expect(broadcast).toHaveBeenCalledTimes(2); + await tick('codeman-other|300|0|||/elsewhere'); + expect(broadcast).toHaveBeenCalledTimes(2); + }); + + it('keeps the directory across a blank field and across a failed read', async () => { + const { session, broadcast, tick } = buildWithRealManager(); + await tick('codeman-aaaa|100|0|||/work/proj/sub'); + // tmux could not read the process cwd for one tick. + await tick('codeman-aaaa|100|0|||'); + expect(session.toState().cwd).toBe('/work/proj/sub'); + // A failed read arrives as no rows at all. + await tick(''); + expect(session.toState().cwd).toBe('/work/proj/sub'); + expect(broadcast).toHaveBeenCalledTimes(1); }); it('publishes nothing for a docker case', () => {