diff --git a/.changeset/pane-cwd-relative-links.md b/.changeset/pane-cwd-relative-links.md new file mode 100644 index 000000000..d9f5d2bec --- /dev/null +++ b/.changeset/pane-cwd-relative-links.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Terminal links: a relative path printed in the terminal now resolves against the directory the session's pane is in now (tmux `#{pane_current_path}`), not only the session's start directory, so after a `cd` in a shell the link opens the file that is actually there. The value is read from the pane-exit watcher's existing batched pane read (no new tmux call, no new SSE event), published as a runtime-only `cwd` field on the session state, and only changes the base a relative path joins: the workspace confinement and the attachment routing are unchanged. Remote SSH, Docker, direct-PTY and rediscovered sessions keep resolving against the working directory. diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b08..45b8c3dbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,7 +226,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.cwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `cwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. diff --git a/docs/api-reference.md b/docs/api-reference.md index 4a0cb6b72..94e917b66 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -969,6 +969,18 @@ 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 +never restored after a restart (it is written to `state.json` with the rest of the +session state but not read back), 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`. +tmux reports the resolved directory, so for a workspace reached through a symlink a +directory at or under the resolved workspace is reported in `workingDir`'s spelling. + ## 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 ebba79736..22408053c 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 the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` 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`. ⚠️ tmux reads the directory from `/proc//cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, 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/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index eb5611b8a..62fa70b31 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -87,7 +87,10 @@ detected at once would fork ten multi-minute converter processes. File paths in a session are links. That works in two places: -- **In the terminal**, on any absolute path an agent prints. +- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`, + `../notes.md`) resolves against the directory the session's pane is in now, so after a `cd` + in a shell it opens the file there; until the pane reports a directory, and always for remote + SSH and Docker cases, it resolves against the session's working directory. - **In the response viewer**, where paths are usually written as prose or in backticks. They render as underlined monospace links. A Markdown link whose target is a path (`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way. diff --git a/src/config/path-probe.ts b/src/config/path-probe.ts index e69e9642b..c5a791417 100644 --- a/src/config/path-probe.ts +++ b/src/config/path-probe.ts @@ -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; diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 57fa360e0..5853e12e7 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -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 diff --git a/src/session.ts b/src/session.ts index a2b495656..8fe3bc9c6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 @@ -1485,6 +1518,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 | undefined; + const answer = await Promise.race([ + realpath(spelling).catch(() => undefined), + new Promise((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. @@ -2162,6 +2288,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, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index f6a5efaf6..8df06aa46 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map = new Map() +): Map { + const panesPerSession = new Map(); + for (const row of rows) { + panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1); + } + const paths = new Map(); + 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 = 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 = 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. diff --git a/src/types/session.ts b/src/types/session.ts index 2e72939fe..a55f4044e 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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 */ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 742ac363a..1d1a241ab 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2397,6 +2397,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//` route (src/web/routes/html-view-routes.ts, whose @@ -4025,6 +4050,7 @@ if (typeof window !== 'undefined') { absoluteFilePathPattern, findFilePathLinks, resolveLinkedFilePath, + relativeResolutionBase, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS, rendersAsPage, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 0a37bc46e..852f0afb4 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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); }, /** diff --git a/src/web/server.ts b/src/web/server.ts index ddfc296d2..6c3ea4cdf 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2812,13 +2812,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(); diff --git a/test/pane-cwd-tracking.test.ts b/test/pane-cwd-tracking.test.ts new file mode 100644 index 000000000..734e60318 --- /dev/null +++ b/test/pane-cwd-tracking.test.ts @@ -0,0 +1,606 @@ +/** + * @fileoverview A relative path printed in the terminal resolves against the + * directory the pane is actually in, not only the session's `workingDir`. + * + * `workingDir` is recorded once at create time and never follows a `cd`, so in + * a shell session that moved to a subdirectory or a sibling repo every relative + * path resolved against the wrong root, and the preview reported "File not + * found" for a file sitting right there. + * + * Pinned here: + * 1. Parsing: the pane-exit watcher's batched `list-panes -a` read already + * 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 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. + * 4. Wiring: the reading reaches `toState()` through the existing + * `paneExitsUpdated` event, with no new tmux call and no new SSE event. + * 5. Resolution order in the browser: live cwd, then `workingDir`. The + * resolved path still goes through the same preview routing, so a target + * outside the workspace takes the attachment route like any other. + * 6. Spelling: tmux reports the RESOLVED directory, so a workspace reached + * through a symlink has its reading written back in `workingDir`'s + * spelling, or every relative link would route as out-of-workspace. + * + * Port: ephemeral + */ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Session, rebasePath } from '../src/session.js'; +import { WebServer } from '../src/web/server.js'; +import type { SessionDocker, SessionRemote } from '../src/types.js'; +import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js'; +import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js'; + +const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote; +const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker; + +describe('derivePaneCurrentPaths', () => { + it("maps each single-pane session to its pane's directory", () => { + const rows = parsePaneRows(['codeman-a|100|0|||/work/proj/sub', 'codeman-b|200|0|||/srv/app'].join('\n')); + const paths = derivePaneCurrentPaths(rows); + expect(paths.get('codeman-a')).toBe('/work/proj/sub'); + expect(paths.get('codeman-b')).toBe('/srv/app'); + expect(paths.size).toBe(2); + }); + + it('keeps a separator that belongs to the directory name', () => { + const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||/home/u/weird|dir/sub')); + expect(paths.get('codeman-a')).toBe('/home/u/weird|dir/sub'); + }); + + it('answers nothing for a session split into several panes', () => { + // Codeman creates one pane per session; which of a hand-split session's + // panes "the" directory belongs to is a guess, so none is made. + const rows = parsePaneRows(['codeman-a|100|0|||/one', 'codeman-a|101|0|||/two'].join('\n')); + expect(derivePaneCurrentPaths(rows).has('codeman-a')).toBe(false); + }); + + it('drops a missing or relative directory', () => { + 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', () => { + class TestManager extends TmuxManager { + rows: PaneRow[] = []; + protected override async readPaneRows(): Promise { + return this.rows; + } + } + + const localSession = (sessionId: string): MuxSession => + ({ + sessionId, + muxName: `codeman-${sessionId}`, + pid: 100, + createdAt: 0, + workingDir: '/work/proj', + mode: 'shell', + attached: true, + }) as MuxSession; + + it('records the directory from the same read the pane-exit watcher makes', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + }); + + it('keeps the last known directory when a read comes back empty', async () => { + const manager = new TestManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub'); + await manager.refreshPaneExits(); + manager.rows = []; + await manager.refreshPaneExits(); + 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. + class RacingManager extends TestManager { + protected override async readPaneRows(): Promise { + this.clearPaneExit('codeman-s1'); + return this.rows; + } + } + const manager = new RacingManager(); + manager.registerSession(localSession('s1')); + manager.rows = parsePaneRows('codeman-s1|100|1|0||/work/proj/sub'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub'); + expect(manager.getPaneExit('codeman-s1')).toBeUndefined(); + }); + + it('makes no read, and keeps no directory, when only remote or docker sessions exist', async () => { + const manager = new TestManager(); + const read = vi.spyOn(manager as unknown as { readPaneRows: () => Promise }, 'readPaneRows'); + manager.registerSession({ ...localSession('s1'), remote } as MuxSession); + manager.registerSession({ ...localSession('s2'), docker } as MuxSession); + manager.rows = parsePaneRows('codeman-s1|100|0|||/tmp'); + await manager.refreshPaneExits(); + expect(read).not.toHaveBeenCalled(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); + + it('forgets a session that an authoritative read no longer lists', 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-other|300|0|||/elsewhere'); + await manager.refreshPaneExits(); + expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined(); + }); +}); + +const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer; +const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession; + +function localMuxSession(extra: Record = {}) { + return new Session({ + workingDir: '/work/proj', + mode: 'shell', + useMux: true, + mux: stubMux(), + muxSession: stubMuxSession(), + ...extra, + }); +} + +describe('Session.setCwd scoping', () => { + it('records an absolute directory for a local pane and reports the change once', () => { + const session = localMuxSession(); + expect(session.setCwd('/work/proj/sub')).toBe(true); + expect(session.cwd).toBe('/work/proj/sub'); + expect(session.setCwd('/work/proj/sub')).toBe(false); + expect(session.toState().cwd).toBe('/work/proj/sub'); + }); + + it('clears the directory when the reading goes away', () => { + const session = localMuxSession(); + session.setCwd('/work/proj/sub'); + expect(session.setCwd(undefined)).toBe(true); + expect(session.cwd).toBeUndefined(); + }); + + it('refuses a relative directory', () => { + const session = localMuxSession(); + expect(session.setCwd('rel/ative')).toBe(false); + expect(session.cwd).toBeUndefined(); + }); + + it('refuses a directory for a remote session, whose local pane runs the ssh client', () => { + const session = localMuxSession({ remote }); + expect(session.setCwd('/home/dev')).toBe(false); + expect(session.toState().cwd).toBeUndefined(); + }); + + it('refuses a directory for a docker case, whose local pane runs docker exec', () => { + const session = localMuxSession({ docker }); + expect(session.setCwd('/work/proj')).toBe(false); + expect(session.toState().cwd).toBeUndefined(); + }); + + it('refuses a directory for a direct-PTY session and a rediscovered one', () => { + const direct = new Session({ workingDir: '/work/proj', mode: 'shell', useMux: false }); + expect(direct.setCwd('/work/proj/sub')).toBe(false); + const discovered = localMuxSession({ discoveredMuxSession: true }); + expect(discovered.setCwd('/work/proj/sub')).toBe(false); + }); +}); + +describe('a pane read reaching the session record', () => { + let server: WebServer | null = null; + + afterEach(async () => { + await server?.stop?.(); + server = null; + }); + + const build = () => { + const web = new WebServer(0, false, true); + server = web; + const mux = (web as unknown as { mux: Record & { emit: (e: string) => void } }).mux; + const sessions = (web as unknown as { sessions: Map }).sessions; + return { mux, sessions }; + }; + + it("publishes the pane's directory on the existing paneExitsUpdated tick", () => { + const { mux, sessions } = build(); + const session = localMuxSession(); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = (muxName: string) => (muxName === 'codeman-aaaa' ? '/work/proj/sub' : undefined); + mux.emit('paneExitsUpdated'); + + expect(session.toState().cwd).toBe('/work/proj/sub'); + // A resolution base only: the confinement root is untouched. + expect(session.toState().workingDir).toBe('/work/proj'); + }); + + it('broadcasts a directory change once, and nothing on an unchanged tick', () => { + const { mux, sessions } = build(); + const session = localMuxSession(); + sessions.set(session.id, session); + const broadcast = vi.fn(); + (server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced = + broadcast; + const persist = vi.fn(); + (server as unknown as { persistSessionState: (s: Session) => void }).persistSessionState = persist; + let dir = '/work/proj/sub'; + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => dir; + + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith(session.id); + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(1); + dir = '/work/proj/other'; + mux.emit('paneExitsUpdated'); + expect(broadcast).toHaveBeenCalledTimes(2); + expect(session.toState().cwd).toBe('/work/proj/other'); + // Runtime-only: a directory change alone is never written to state.json. + expect(persist).not.toHaveBeenCalled(); + }); + + /** + * 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); + 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', () => { + const { mux, sessions } = build(); + const session = localMuxSession({ docker }); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => '/work/proj'; + mux.emit('paneExitsUpdated'); + + expect(session.toState().cwd).toBeUndefined(); + }); + + it('publishes nothing for a remote session', () => { + const { mux, sessions } = build(); + const session = localMuxSession({ remote }); + sessions.set(session.id, session); + mux.getPaneExit = () => undefined; + mux.getPaneCurrentPath = () => '/home/dev'; + mux.emit('paneExitsUpdated'); + + expect(session.toState().cwd).toBeUndefined(); + }); +}); + +describe('browser resolution order: live cwd, then workingDir', () => { + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + + const loadProto = () => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + return (context as unknown as { __proto: { _resolveLinkedFilePath: (p: string, id: string) => string } }).__proto; + }; + + const resolveFor = (session: Record, filePath: string) => { + const proto = loadProto(); + return proto._resolveLinkedFilePath.call({ sessions: new Map([['s1', session]]) }, filePath, 's1'); + }; + + it("resolves against the pane's live directory after a cd into a subdirectory", () => { + expect(resolveFor({ workingDir: '/work/proj', cwd: '/work/proj/sub' }, 'note.md')).toBe('/work/proj/sub/note.md'); + }); + + it('resolves outside the workspace when the pane cd-ed to a sibling repo', () => { + // Routing is unchanged: an absolute target outside the workspace is what + // `_isExternalPreviewPath` already sends through the attachment routes. + expect(resolveFor({ workingDir: '/work/proj', cwd: '/work/other' }, 'src/a.js')).toBe('/work/other/src/a.js'); + }); + + it('falls back to workingDir when no live directory is known', () => { + expect(resolveFor({ workingDir: '/work/proj' }, 'note.md')).toBe('/work/proj/note.md'); + }); + + it('ignores a live directory on a remote or docker session', () => { + expect(resolveFor({ workingDir: '/work/proj', cwd: '/tmp/x', remote: {} }, 'note.md')).toBe('/work/proj/note.md'); + expect(resolveFor({ workingDir: '/work/proj', cwd: '/tmp/x', docker: {} }, 'note.md')).toBe('/work/proj/note.md'); + }); + + it('ignores a relative live directory', () => { + expect(resolveFor({ workingDir: '/work/proj', cwd: 'sub' }, 'note.md')).toBe('/work/proj/note.md'); + }); + + it('leaves absolute and home paths alone', () => { + const session = { workingDir: '/work/proj', cwd: '/work/proj/sub' }; + expect(resolveFor(session, '/etc/hosts.md')).toBe('/etc/hosts.md'); + expect(resolveFor(session, '~/x.md')).toBe('~/x.md'); + }); +}); + +describe('confinement is unchanged by the live cwd', () => { + // The live cwd only picks the base. Whether the preview reads a path through + // the workspace-confined file routes or registers it through the attachment + // routes is still decided against `workingDir` by `_isExternalPreviewPath`. + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + + const loadApp = (session: Record) => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn(), getElementById: () => null }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + const proto = ( + context as unknown as { + __proto: { + _resolveLinkedFilePath: (p: string, id: string) => string; + _isExternalPreviewPath: (p: string, id: string) => boolean; + }; + } + ).__proto; + const app = { sessions: new Map([['s1', session]]) }; + return { + target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'), + external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'), + }; + }; + + it('keeps a target inside the workspace on the workspace route', () => { + const app = loadApp({ workingDir: '/work/proj', cwd: '/work/proj/sub' }); + expect(app.external(app.target('note.md'))).toBe(false); + }); + + it('sends a target outside the workspace through the attachment route', () => { + const app = loadApp({ workingDir: '/work/proj', cwd: '/work/other' }); + expect(app.target('src/a.js')).toBe('/work/other/src/a.js'); + expect(app.external(app.target('src/a.js'))).toBe(true); + }); +}); + +describe('a workspace reached through a symlink', () => { + // tmux reads `/proc//cwd`, always the resolved path, while `workingDir` + // keeps the spelling the case was created with. Without the rewrite, the + // browser's lexical routing check would call every relative link external. + let root: string; + let physical: string; + let link: string; + + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + const routingFor = (session: Record) => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn(), getElementById: () => null }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + const proto = ( + context as unknown as { + __proto: { + _resolveLinkedFilePath: (p: string, id: string) => string; + _isExternalPreviewPath: (p: string, id: string) => boolean; + }; + } + ).__proto; + const app = { sessions: new Map([['s1', session]]) }; + return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1'); + }; + + /** Feed a reading until the background resolution has settled and it is taken. */ + const settle = async (session: Session, reading: string) => { + await vi.waitFor(() => { + session.setCwd(reading); + expect(session.cwd).toBeDefined(); + }); + }; + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const setup = () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-'))); + physical = join(root, 'data', 'app'); + mkdirSync(join(physical, 'src'), { recursive: true }); + mkdirSync(join(root, 'other'), { recursive: true }); + link = join(root, 'cases-app'); + symlinkSync(physical, link); + }; + + it('writes a reading of the resolved workspace back in workingDir spelling', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + expect(session.cwd).toBe(link); + expect(session.setCwd(join(physical, 'src'))).toBe(true); + expect(session.cwd).toBe(join(link, 'src')); + }); + + it('keeps an in-workspace relative link on the workspace route from the first reading', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + const external = routingFor(session.toState() as unknown as Record); + expect(external('src/index.ts')).toBe(false); + }); + + it('still routes a cd outside the workspace through the attachment route', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, join(root, 'other')); + expect(session.cwd).toBe(join(root, 'other')); + const external = routingFor(session.toState() as unknown as Record); + expect(external('notes.md')).toBe(true); + }); + + it('withholds an out-of-workspace reading until the resolution settles', () => { + setup(); + const session = localMuxSession({ workingDir: link }); + // The first call starts the resolution; a physical reading cannot be told + // apart from a real cd yet, so it is not stored this tick. + expect(session.setCwd(physical)).toBe(false); + expect(session.cwd).toBeUndefined(); + // A reading lexically inside workingDir needs no translation and is taken. + expect(session.setCwd(join(link, 'src'))).toBe(true); + }); + + it('leaves the reading alone when workingDir has no symlink in it', async () => { + setup(); + const session = localMuxSession({ workingDir: physical }); + await settle(session, join(physical, 'src')); + expect(session.cwd).toBe(join(physical, 'src')); + }); + + it('takes readings untranslated once a missing workingDir has settled', async () => { + setup(); + const session = localMuxSession({ workingDir: join(root, 'gone') }); + await settle(session, join(root, 'other')); + expect(session.cwd).toBe(join(root, 'other')); + }); +}); + +describe('rebasePath', () => { + it('rewrites the prefix itself and paths under it, and nothing else', () => { + expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app'); + expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src'); + expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application'); + expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other'); + }); +});