feat(edit-history): read remote (SSH) claude sessions, never waking the host

The edit history panel now covers remote claude sessions. The sync stays
path-based; its deps get the session and server.ts routes them to the local
reader or to two new ssh reads in remote-files.ts:

- remoteLocateClaudeTranscript: one round trip; the conversation the remote
  pane's claude writes now (~/.claude/sessions/<pid>.json via the pane's
  process tree, since remote hosts send no hooks), else claudeSessionId,
  else the session id, newest $HOME/.claude/projects/*/<id>.jsonl.
- remoteReadClaudeEditSources: one round trip per read pass, with the local
  reader's semantics (offset clamp, block end at offset+len, new files read
  their tail, shared main-first budget, onlyRequested, 8 KiB meta cap).

So /clear catch-up, "Load older", cursors and the store LRU work remotely
unchanged. Reads use buildRemoteFileCommand + runWithRemoteSshLimit only:
a sleeping host is a connect timeout -> unavailable, stored entries kept.

Tests: both scripts run on a real sh against a temp HOME; the read is
compared byte for byte with readClaudeEditSources on 60 seeded random
transcripts. A transitive import guard pins that the edit-history read path
reaches neither remote-wake.ts nor session-routes.ts. Browser: a remote
session lists images without thumbnails, a click previews. Docs updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-11-05 11:19:46 +01:00
1 parent 9eac5ecf01
commit c4ba14235f
15 files changed
+968 -62

No files matched your search

+1 -1
View File
@@ -242,7 +242,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a claude session (local or remote SSH) edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. ⚠️ **Remote reads never wake a host:** only the bounded ssh read path of `remote-files.ts`, a sleeping host answers `unavailable` (wiring-guard test). Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
+2 -2
View File
@@ -1396,9 +1396,9 @@ The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-s
## Edit history
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **claude session** made (local or remote SSH), main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). A remote session is read over ssh and never woken: a sleeping host answers `unavailable`. Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
+5 -3
View File
@@ -752,7 +752,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Edit history panel
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
@@ -762,11 +762,13 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path.
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker and non-claude sessions answer `unsupported` and are never read.
⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `~/.claude/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else `<claudeSessionId>` then `<id>`, each the newest `$HOME/.claude/projects/*/<id>.jsonl`; the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`) with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-<id>.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. Only the host's default `$HOME/.claude` is searched. ⚠️ **Never wakes:** the reads go through `buildRemoteFileCommand` + `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only.
### Config backups
+1 -1
View File
@@ -108,7 +108,7 @@ records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode st
**Edit history** (per device, off by default, never on phones) adds a header button for the
active session's edit history: every edit the agent and its subagents made, with its diff, and
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
images it produced. Claude sessions, local and remote (SSH). Nothing is read until you open the window, and it
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
[Working With Files](Working-With-Files#edit-history).
+10 -4
View File
@@ -306,20 +306,26 @@ Click it for a draggable window listing the active session's edits, newest first
first catches up on the ones it had not read yet. **Load older** then goes back within the new
conversation.
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
It works for Claude sessions, local and remote (SSH). Docker and other CLIs' sessions say so instead.
A remote session's images show no thumbnail (each would be one more SSH read); click one to
preview it. A remote host that is asleep is never woken for the history: the window shows the
stored edits with the SSH error on top.
How it reads:
- **Only while you look.** The history is read from the session's Claude transcript when you open
the window, and again after each tool call that can change files and at the end of each turn,
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
the background for sessions nobody looks at.
while it stays open on that session (a remote session at the end of each turn only: its host
sends no tool events). A closed window reads nothing, and Codeman does no work in the
background for sessions nobody looks at.
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
is only known after the next prompt; until then the window says "No transcript found for this
session yet".
session yet". A remote session's transcript is looked up on its host over SSH: the
conversation its Claude writes now, else the session's own, under the remote user's
`~/.claude` (a `CLAUDE_CONFIG_DIR` on the remote host is not followed).
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
+362
View File
@@ -32,6 +32,7 @@ import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
const execAsync = promisify(exec);
@@ -413,6 +414,367 @@ export async function remoteReadFile(remote: SessionRemote, remotePath: string,
}
}
/** Conversation ids are uuids; anything else never reaches the remote shell. */
const CLAUDE_TRANSCRIPT_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Exit status the edit-history scripts use for "no such transcript on that host". */
const TRANSCRIPT_NOT_FOUND_EXIT = 3;
/** Transcript files an edit-source request may name; nothing else reaches the shell. */
const EDIT_SOURCE_REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
/** The meta record's relPath; group 1 is the agentId. */
const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.meta\.json$/;
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
const EDIT_SOURCE_META_MAX_BYTES = 8192;
/** Cap on all meta records of one read; a meta past it is sent with a later read. */
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
/** Room for the record headers (one per transcript file) on top of budget and metas. */
const EDIT_SOURCE_HEADER_SLACK_BYTES = 1024 * 1024;
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
export interface RemoteClaudePane {
socket: string;
sessionName: string;
}
function isByteCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
/** De-duplicated ids that match {@link CLAUDE_TRANSCRIPT_ID_PATTERN}, in order. */
function safeClaudeTranscriptIds(ids: readonly string[]): string[] {
return [...new Set(ids)].filter((id) => CLAUDE_TRANSCRIPT_ID_PATTERN.test(id));
}
/** A main transcript path the read script takes: absolute, `<id>.jsonl`, no NUL or newline. */
export function isRemoteClaudeTranscriptPath(path: string): boolean {
if (!path.startsWith('/') || !path.endsWith('.jsonl') || /[\0\n]/.test(path)) return false;
return CLAUDE_TRANSCRIPT_ID_PATTERN.test(path.slice(path.lastIndexOf('/') + 1, -'.jsonl'.length));
}
/**
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
* or leave it empty. Codeman launches a remote claude with `--session-id <id>`, but a
* `/clear` (or a resume picked inside claude) starts a new conversation, and with no
* hooks from a remote host Codeman never hears of it. claude itself keeps
* `~/.claude/sessions/<pid>.json` with the current `sessionId`, so: the pane's pid, its
* children and grandchildren (a wrapper shell may sit in between), the first of those
* with such a file. Only `[A-Za-z0-9._-]` survives the `sed`, the id pattern, so `$live`
* is safe unquoted. `ps -A -o pid= -o ppid=` is POSIX (no GNU `--ppid`).
*/
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
return [
'live=',
`pp=$(tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}`)} -F '#{pane_pid}' 2>/dev/null | head -n 1)`,
"case $pp in ''|*[!0-9]*) pp= ;; esac",
'if [ -n "$pp" ]; then',
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
' kids=$(printf \'%s\\n\' "$tree" | awk -v p="$pp" \'$2 == p { print $1 }\')',
' grand=$(for k in $kids; do printf \'%s\\n\' "$tree" | awk -v p="$k" \'$2 == p { print $1 }\'; done)',
' for c in $pp $kids $grand; do',
' f="$HOME/.claude/sessions/$c.json"',
' [ -f "$f" ] || continue',
' live=$(sed -n \'s/.*"sessionId":"\\([A-Za-z0-9._-]*\\)".*/\\1/p\' "$f" | head -n 1)',
' [ -n "$live" ] && break',
' done',
'fi',
];
}
/**
* Script that finds a remote claude session's main transcript and prints `\0<path>\0`;
* exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when there is none. First the conversation the
* claude in `pane` writes now ({@link liveClaudeIdLines}), then each of `ids`; per id the
* newest `$HOME/.claude/projects/*\/<id>.jsonl`. `$HOME` is expanded by the REMOTE shell:
* the transcript lives in the remote user's home, which the server cannot know.
*
* ⚠️ Runs under an explicit `sh` with `LC_ALL=C`: sshd runs a command in the remote user's
* LOGIN shell, and on a zsh host `ls` can be an alias and an unmatched glob aborts the whole
* script ("no matches found"). So: no `ls` at all, the newest file wins via `-nt`, and an
* `[ -f ]` guard on every glob. `C` makes bracket ranges and glob order plain bytes.
*/
export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], pane?: RemoteClaudePane): string {
const safeIds = safeClaudeTranscriptIds(ids);
if (safeIds.length === 0) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const liveLines = liveClaudeIdLines(pane);
const script = [
'LC_ALL=C',
'export LC_ALL',
'best=',
...liveLines,
`for id in ${liveLines.length ? '$live ' : ''}${safeIds.map((id) => shellescape(id)).join(' ')}; do`,
' for f in "$HOME"/.claude/projects/*/"$id".jsonl; do',
' [ -f "$f" ] || continue',
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
' done',
' [ -n "$best" ] && break',
'done',
`[ -n "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
'printf \'\\0%s\\0\' "$best"',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/** The path from {@link buildRemoteClaudeTranscriptLocateCommand} output; null when there is none. */
export function parseRemoteClaudeTranscriptLocate(stdout: Buffer): string | null {
const start = stdout.indexOf(0);
const end = start === -1 ? -1 : stdout.indexOf(0, start + 1);
if (end === -1) return null;
const path = stdout.toString('utf8', start + 1, end);
return isRemoteClaudeTranscriptPath(path) ? path : null;
}
/**
* Find a remote claude session's main transcript in ONE ssh round trip (see
* {@link buildRemoteClaudeTranscriptLocateCommand}). `null` when it has none; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes a host: a
* sleeping one is a connect timeout, like every other read in this module.
*/
export async function remoteLocateClaudeTranscript(
remote: SessionRemote,
ids: readonly string[],
pane?: RemoteClaudePane
): Promise<string | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_READ_TIMEOUT_MS, maxBuffer: READ_BUFFER_SLACK_BYTES, encoding: 'buffer' })
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const path = parseRemoteClaudeTranscriptLocate(stdout);
if (!path) throw new RemoteFileAccessError('unexpected output from the remote transcript lookup');
return path;
}
/**
* Script that reads a remote claude session's main transcript at `mainPath` and its subagent
* transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`) in ONE ssh round trip,
* with exactly the local reader's rules (`readClaudeEditSources`):
*
* - a request without `len` reads from `offset` (clamped to the size);
* - a request with `len` is a block that ends at `offset + len`: short of budget, its end;
* - a file without a request (one the caller has not seen) reads its last bytes, and a new
* subagent's `.meta.json` (≤ {@link EDIT_SOURCE_META_MAX_BYTES}, outside the budget) comes
* along as its own record; with `onlyRequested` such files are left out.
*
* `budgetBytes` is shared by all transcript files, main first, then the subagents in byte
* order. A file that gets none of it still produces a zero-length record, because the caller
* needs its size. Exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when `mainPath` is not a file.
*
* Output: `\0<transcriptId>\0`, then per record `<relPath>\0<size>\0<start>\0<len>\0`
* followed by exactly `<len>` raw bytes. Should a file shrink between `wc -c` and the read,
* the slice is NUL-padded to `<len>` so the framing never shifts.
*
* Inputs never reach the script unchecked: the path via {@link isRemoteClaudeTranscriptPath}
* (and shellescaped), relPaths via {@link EDIT_SOURCE_REL_PATH_PATTERN}, offsets and lengths as
* plain digits; anything invalid is dropped. A subagent file whose name the pattern would
* reject is skipped on the remote side too. Same `sh` + `LC_ALL=C` rules as the lookup.
*/
export function buildRemoteClaudeEditSourcesCommand(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): string {
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
const byRelPath = new Map<string, { offset: number; len: number | null }>();
for (const request of requests) {
if (!request || typeof request.relPath !== 'string') continue;
if (!EDIT_SOURCE_REL_PATH_PATTERN.test(request.relPath)) continue;
if (!isByteCount(request.offset)) continue;
if (request.len !== undefined && !isByteCount(request.len)) continue;
if (byRelPath.has(request.relPath)) continue;
byRelPath.set(request.relPath, { offset: request.offset, len: request.len ?? null });
}
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const requestArgs = (request: { offset: number; len: number | null }): string =>
`r ${request.offset} ${request.len === null ? "''" : String(request.len)}`;
const main = byRelPath.get('main');
const mainLine = main
? `emit main "$best" ${requestArgs(main)}`
: opts.onlyRequested
? ''
: 'emit main "$best" t 0 \'\'';
const subagentCases = [...byRelPath.entries()]
.filter(([relPath]) => relPath !== 'main')
.map(([relPath, request]) => {
const name = relPath.slice('subagents/'.length);
return ` ${shellescape(name)}) emit "subagents/$b" "$f" ${requestArgs(request)} ;;`;
});
const newSubagent = opts.onlyRequested
? [' *) ;;']
: [
' *) emit "subagents/$b" "$f" t 0 \'\'',
' m=${f%.jsonl}.meta.json',
' if [ -f "$m" ]; then',
' fsize "$m"',
` mn=$s; [ "$mn" -le ${EDIT_SOURCE_META_MAX_BYTES} ] || mn=${EDIT_SOURCE_META_MAX_BYTES}`,
' if [ "$mn" -le "$mbudget" ]; then',
' mbudget=$((mbudget - mn))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "subagents/${b%.jsonl}.meta.json" "$s" 0 "$mn"',
' [ "$mn" -eq 0 ] || bytes "$m" 0 "$mn"',
' fi',
' fi ;;',
];
const script = [
'LC_ALL=C',
'export LC_ALL',
`best=${shellescape(mainPath)}`,
`[ -f "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
`printf '\\0%s\\0' ${shellescape(transcriptId)}`,
`budget=${budget}`,
`mbudget=${EDIT_SOURCE_META_BUDGET_BYTES}`,
// Size via `wc -c <` (POSIX; BSD pads it with spaces, hence the tr).
'fsize() {',
' s=$(wc -c < "$1" 2>/dev/null | tr -d \' \\t\\n\')',
" case $s in ''|*[!0-9]*) s=0 ;; esac",
'}',
// Exactly $3 bytes of $1 from offset $2, NUL-padded if the file shrank meanwhile.
'bytes() {',
' { tail -c +$(($2 + 1)) "$1" 2>/dev/null | head -c "$3"; head -c "$3" /dev/zero; } | head -c "$3"',
'}',
// emit <relPath> <file> <r|t> <offset> <len|''>: r = a request, t = the tail of a new file.
'emit() {',
' fsize "$2"',
' if [ "$3" = r ] && [ -n "$5" ]; then',
' bs=$4; [ "$bs" -le "$s" ] || bs=$s',
' be=$(($4 + $5)); [ "$be" -le "$s" ] || be=$s',
' n=$((be - bs)); [ "$n" -le "$budget" ] || n=$budget',
' start=$((be - n))',
' elif [ "$3" = r ]; then',
' start=$4; [ "$start" -le "$s" ] || start=$s',
' n=$((s - start)); [ "$n" -le "$budget" ] || n=$budget',
' else',
' n=$s; [ "$n" -le "$budget" ] || n=$budget',
' start=$((s - n))',
' fi',
' budget=$((budget - n))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "$1" "$s" "$start" "$n"',
' [ "$n" -eq 0 ] || bytes "$2" "$start" "$n"',
'}',
mainLine,
'for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
' [ -f "$f" ] || continue',
' b=${f##*/}',
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
' case $b in',
...subagentCases,
...newSubagent,
' esac',
'done',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteClaudeEditSourcesCommand} output, byte-exact on the Buffer (a chunk
* may end mid UTF-8 sequence, so nothing here measures decoded strings). Everything before
* the first NUL is rc-file chatter. `null` = protocol broken: a missing or malformed header
* field, an unknown relPath, a slice outside the file or a body shorter than its length.
*/
export function parseRemoteClaudeEditSources(stdout: Buffer): EditSources | null {
let pos = stdout.indexOf(0);
if (pos === -1) return null;
pos += 1;
const readField = (): string | null => {
const end = stdout.indexOf(0, pos);
if (end === -1) return null;
const field = stdout.toString('utf8', pos, end);
pos = end + 1;
return field;
};
const readNumber = (): number | null => {
const field = readField();
if (field === null || !/^\d+$/.test(field)) return null;
const value = Number(field);
return Number.isSafeInteger(value) ? value : null;
};
const transcriptId = readField();
if (!transcriptId || !CLAUDE_TRANSCRIPT_ID_PATTERN.test(transcriptId)) return null;
const files: EditSources['files'] = [];
const metas: Record<string, string> = {};
while (pos < stdout.length) {
const relPath = readField();
const size = readNumber();
const startOffset = readNumber();
const len = readNumber();
if (relPath === null || size === null || startOffset === null || len === null) return null;
if (startOffset + len > size || pos + len > stdout.length) return null;
const chunk = stdout.subarray(pos, pos + len);
pos += len;
const meta = EDIT_SOURCE_META_REL_PATH_PATTERN.exec(relPath);
if (meta) {
metas[meta[1]] = chunk.toString('utf8');
} else if (EDIT_SOURCE_REL_PATH_PATTERN.test(relPath)) {
files.push({ relPath, size, startOffset, chunk });
} else {
return null;
}
}
return { transcriptId, files, metas };
}
/**
* Read a remote claude session's transcripts (see {@link buildRemoteClaudeEditSourcesCommand}),
* the remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an
* unreachable host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes.
*/
export async function remoteReadClaudeEditSources(
remote: SessionRemote,
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
assertNotUnderTest();
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const command = buildRemoteFileCommand(remote, buildRemoteClaudeEditSourcesCommand(mainPath, requests, budget, opts));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, {
timeout: REMOTE_READ_TIMEOUT_MS,
maxBuffer: budget + EDIT_SOURCE_META_BUDGET_BYTES + EDIT_SOURCE_HEADER_SLACK_BYTES,
encoding: 'buffer',
})
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const parsed = parseRemoteClaudeEditSources(stdout);
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
}
return parsed;
}
/**
* Command that writes a remote file's bytes to stdout.
*
+38 -17
View File
@@ -31,11 +31,14 @@
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
* with older lines per click; with more sources they take turns.
*
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
* `unsupported` and are never read.
* WHICH SESSIONS. Claude sessions, local and remote. Docker and non-claude sessions answer
* `unsupported` and are never read. The deps get the session and read a remote one over ssh
* (`remote-files.ts`): one round trip to find its transcript, one per read. A remote read never
* wakes its host; a sleeping one is a connect timeout (`unavailable`, stored entries kept).
*
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing. Transcript times and mtimes come
* from the same host, so a remote host's clock skew to this server does not matter.
*
* Tests: `test/edit-history-sync.test.ts`.
*
@@ -83,13 +86,16 @@ export interface EditHistorySession {
export interface EditHistorySyncDeps {
/** The session's main transcript path, or null when it has none yet. */
transcriptPath(session: EditHistorySession): Promise<string | null>;
/** The session's transcripts (a remote session's on its host). */
readSources(
session: EditHistorySession,
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts?: EditSourceReadOptions
): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
/** Bash image candidates of the session, on the host it runs on. */
probe(session: EditHistorySession, paths: readonly string[]): Promise<Array<PathProbe | null>>;
loadStore(sessionId: string): Promise<EditHistoryStore>;
/** Delete a session's stored history without loading it. */
deleteStore(sessionId: string): Promise<void>;
@@ -104,7 +110,6 @@ export interface SyncOptions {
/** Why a session has no edit history, or undefined when it can have one. */
export function unsupportedReason(session: EditHistorySession): string | undefined {
if (session.remote) return 'remote';
if (session.docker) return 'docker';
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
return undefined;
@@ -329,11 +334,11 @@ export class EditHistorySync {
if (!path) {
found = false;
} else if (older) {
found = await this.readOlder(path, store, slot);
found = await this.readOlder(session, path, store, slot);
} else {
found = true;
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
const r = await this.readForward(path, store);
const r = await this.readForward(session, path, store);
found = r.found;
if (!r.more) break;
}
@@ -359,13 +364,17 @@ export class EditHistorySync {
* short by the shared budget and left for the next pass. A transcript other than the stored
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
*/
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
private async readForward(
session: EditHistorySession,
path: string,
store: EditHistoryStore
): Promise<{ found: boolean; more: boolean }> {
const own = this.storedTranscript(path, store);
if (own !== path && store.knownSources().length > 0) {
const previous = await this.readFrom(own, store, this.forwardRequests(store));
const previous = await this.readFrom(session, own, store, this.forwardRequests(store));
if (previous.more) return { found: true, more: true };
}
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
return this.readFrom(session, path, store, own === path ? this.forwardRequests(store) : []);
}
/**
@@ -378,11 +387,12 @@ export class EditHistorySync {
}
private async readFrom(
session: EditHistorySession,
path: string,
store: EditHistoryStore,
requests: EditSourceRequest[]
): Promise<{ found: boolean; more: boolean }> {
const res = await this.deps.readSources(path, requests, this.budget);
const res = await this.deps.readSources(session, path, requests, this.budget);
if (!res) return { found: false, more: false };
this.dropMissing(store, requests, res);
for (const [agentId, text] of Object.entries(res.metas)) {
@@ -413,11 +423,16 @@ export class EditHistorySync {
candidates.push(...r.candidates);
if (f.startOffset + f.chunk.length < f.size) more = true;
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return { found: true, more };
}
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
private async readOlder(
session: EditHistorySession,
path: string,
store: EditHistoryStore,
slot: SessionSlot
): Promise<boolean> {
if (!store.backfillTruncated) return true;
// Older lines belong to the stored conversation, even before a forward read moved on.
path = this.storedTranscript(path, store);
@@ -440,7 +455,9 @@ export class EditHistorySync {
}
if (requests.length === 0) return true;
slot.olderAfter = picked[picked.length - 1];
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
const res = await this.deps.readSources(session, path, requests, this.budget * requests.length, {
onlyRequested: true,
});
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
this.dropMissing(store, requests, res);
@@ -462,7 +479,7 @@ export class EditHistorySync {
});
candidates.push(...r.candidates);
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return true;
}
@@ -472,13 +489,17 @@ export class EditHistorySync {
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
}
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
private async addImages(
session: EditHistorySession,
store: EditHistoryStore,
candidates: readonly ImageCandidate[]
): Promise<void> {
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
// A removed store keeps nothing: do not probe agent-printed paths for it.
if (paths.length === 0 || store.isRemoved()) return;
let probes: Array<PathProbe | null>;
try {
probes = await this.deps.probe(paths);
probes = await this.deps.probe(session, paths);
} catch {
// The edits of this read are in; only its images are lost. The read itself succeeded.
return;
+11 -5
View File
@@ -9,12 +9,14 @@
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
* (the end of a turn). No poll, and nothing at all while the panel is closed.
* (the end of a turn). No poll, and nothing at all while the panel is closed. A remote session has
* no transcript watcher (no hooks reach back from its host), so it follows turn ends only.
*
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
* on any path; inside a local session's workspace it also shows a thumbnail (a remote one would
* stream every listed image over ssh). Both go through the existing file
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
* enforces the attachment guard and its extension allowlist). A path from the transcript is
@@ -318,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
* session's real attachments out of its capped registry. Clicking it opens the file preview,
* which registers it like any other external file. SVGs get none either (`file-raw` serves
* them as a download).
* them as a download), nor does a remote session's image: its `file-raw` streams over ssh, and
* a list of generated images would open one ssh read per row; a click still previews it.
*/
_editHistoryThumbSrc(path, sid) {
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
if (!path || /\.svg$/i.test(path) || this.sessions?.get?.(sid)?.remote) return null;
if (this._isExternalPreviewPath?.(path, sid) !== false) return null;
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
},
@@ -353,7 +357,9 @@ Object.assign(CodemanApp.prototype, {
}
const data = h.data;
if (data.state === 'unsupported') {
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
body.append(
el('div', 'edit-history-empty', t('Edit history is only available for Claude sessions outside Docker.'))
);
return;
}
if (data.state === 'unavailable') {
+2 -1
View File
@@ -565,7 +565,8 @@
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
'Reading the edit history…': '正在读取编辑历史…',
'Could not read the edit history.': '无法读取编辑历史。',
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
'Edit history is only available for Claude sessions outside Docker.':
'编辑历史仅适用于 Docker 之外的 Claude 会话。',
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
+18 -11
View File
@@ -140,6 +140,8 @@ import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { EditHistorySync } from './edit-history-sync.js';
import { EditHistoryStore } from '../edit-history-store.js';
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
import { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js';
import { remoteTmuxLocation } from '../tmux-manager.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -356,19 +358,24 @@ export class WebServer extends EventEmitter {
private app: FastifyInstance;
private sessions: Map<string, Session> = new Map();
/**
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
* the main transcript is the one the transcript watcher follows (hook-reported path), or,
* before the first hook after a restart, a lookup under the session's claude config dir.
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks.
* A local session's main transcript is the one the transcript watcher follows (hook-reported
* path), or, before the first hook after a restart, a lookup under the session's claude
* config dir. A remote session sends no hooks: its host is asked over ssh for the transcript
* the pane's claude writes (remote-files.ts), and is never woken for it.
*/
private readonly editHistorySync = new EditHistorySync({
transcriptPath: (session) =>
resolveClaudeTranscript(
this.transcriptWatchers.get(session.id)?.getPath(),
[session.claudeSessionId || session.id, session.id],
session.claudeConfigDir
),
readSources: readClaudeEditSources,
probe: probeLocalPaths,
transcriptPath: (session) => {
const ids = [session.claudeSessionId || session.id, session.id];
return session.remote
? remoteLocateClaudeTranscript(session.remote, ids, remoteTmuxLocation(session.remote, session.id))
: resolveClaudeTranscript(this.transcriptWatchers.get(session.id)?.getPath(), ids, session.claudeConfigDir);
},
readSources: (session, mainPath, requests, budget, opts) =>
session.remote
? remoteReadClaudeEditSources(session.remote, mainPath, requests, budget, opts)
: readClaudeEditSources(mainPath, requests, budget, opts),
probe: (session, paths) => (session.remote ? remoteProbePaths(session.remote, paths) : probeLocalPaths(paths)),
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
sweepStores: (keep) => EditHistoryStore.sweep(keep),
+2 -2
View File
@@ -51,8 +51,8 @@ function setup(budget?: number, base = root) {
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
readSources: (_session, path, requests, budget, opts) => readClaudeEditSources(path, requests, budget, opts),
probe: async (_session, paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
+44 -7
View File
@@ -129,13 +129,21 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
/** The session each read (and, as `probe:<id>`, each probe) was made for. */
const readers: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
readSources: (session, path, req, budget, opts) => {
readers.push(session.id);
return host.read(path, req, budget, opts);
},
probe: async (session, paths) => {
readers.push(`probe:${session.id}`);
return paths.map((p) => probes.get(p) ?? null);
},
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
@@ -148,7 +156,7 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
opened.push(store);
return store;
};
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
return { host, probes, lookups, loads, readers, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
@@ -160,9 +168,10 @@ const remote: EditHistorySession = {
};
describe('unsupportedReason', () => {
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
it('accepts a local or remote claude session, rejects docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBe('remote');
expect(unsupportedReason(remote)).toBeUndefined();
expect(unsupportedReason({ ...remote, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
@@ -285,16 +294,44 @@ describe('EditHistorySync', () => {
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
it('marks docker and non-claude sessions unsupported without looking for a transcript', async () => {
const { host, lookups, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('reads a remote session through the same deps, each call with that session (ssh is the server’s part)', async () => {
const { host, lookups, readers, probes, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0 + 1000, T0 + 3000, 'render out.png'));
probes.set('/w/out.png', { realPath: '/w/out.png', kind: 'file', size: 9, mtimeMs: T0 + 2000 });
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect(lookups).toEqual(['s2']);
expect(readers).toEqual(['s2', 'probe:s2']);
expect((await sync.getStore('s2')).list().map((e) => e.path)).toEqual(['/w/out.png', '/w/a.ts']);
});
it('an unreachable remote host is unavailable with the stored entries kept, and the next sync reads on', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
const transcriptPath = deps.transcriptPath;
deps.transcriptPath = async () => {
throw new Error('remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out');
};
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
expect(await sync.sync(remote)).toEqual({
state: 'unavailable',
reason: 'remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out',
});
expect((await sync.getStore('s2')).list()).toHaveLength(1);
deps.transcriptPath = transcriptPath;
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect((await sync.getStore('s2')).list()).toHaveLength(2);
});
it('runs one sync per session at a time and folds occasions into one more run', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
+27 -1
View File
@@ -379,6 +379,30 @@ describe('Edit history panel in a real browser', () => {
state.set(sessionA, s);
});
it('a remote session lists its images without a thumbnail (no ssh read per row); a click still previews', async () => {
const thumbs = () => page.locator('#editHistoryBody img.edit-history-thumb').count();
expect(await thumbs()).toBe(1);
await page.evaluate((sid) => {
const w = window as any;
w.app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
}, sessionA);
await reload();
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
expect(await thumbs()).toBe(0);
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
await page.evaluate((sid) => {
const w = window as any;
delete w.app.sessions.get(sid).remote;
w.app.openFilePreview = w.__openFilePreview;
}, sessionA);
await reload();
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
});
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
const s = state.get(sessionA)!;
const long =
@@ -452,7 +476,9 @@ describe('Edit history panel in a real browser', () => {
it('follows the active session, and says when a session is not a Claude session', async () => {
await select(sessionB);
await page.waitForFunction(() =>
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
/only available for Claude sessions outside Docker/.test(
document.getElementById('editHistoryBody')!.textContent ?? ''
)
);
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
expect(await rows().count()).toBe(0);
+433
View File
@@ -0,0 +1,433 @@
/**
* @fileoverview Tests for the remote edit-history reads in `src/remote-files.ts`: the transcript
* lookup and the transcript read a remote claude session's edit history uses.
*
* Both scripts are executed by a real `/bin/sh` against a temp `$HOME`: the script IS the
* remote command, so `sh -c <script>` reproduces what sshd runs on the other end. The read is
* checked byte for byte against the local reader (`readClaudeEditSources`) on seeded random
* transcripts, so the edit history sync sees the same thing from a remote host as from a
* local one.
*
* Port: N/A (no HTTP server).
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import {
RemoteFileAccessError,
buildRemoteClaudeEditSourcesCommand,
buildRemoteClaudeTranscriptLocateCommand,
isRemoteClaudeTranscriptPath,
parseRemoteClaudeEditSources,
parseRemoteClaudeTranscriptLocate,
remoteLocateClaudeTranscript,
remoteReadClaudeEditSources,
} from '../src/remote-files.js';
import { readClaudeEditSources, type EditSourceRequest } from '../src/edit-history-local-source.js';
import type { SessionRemote } from '../src/types/session.js';
let home: string;
beforeAll(() => {
// A throwaway HOME: the scripts only ever see this directory as $HOME.
home = mkdtempSync(join(tmpdir(), 'codeman-remote-edit-'));
});
afterAll(() => rmSync(home, { recursive: true, force: true }));
/** Run a built command (`sh -c '…'` or `exit 3`) the way sshd would; status and raw stdout. */
function runSh(command: string, env: Record<string, string> = {}): { code: number; stdout: Buffer } {
expect(home.startsWith(tmpdir())).toBe(true);
try {
return {
code: 0,
stdout: execFileSync('sh', ['-c', command], { env: { ...process.env, HOME: home, ...env }, cwd: home }),
};
} catch (err) {
const e = err as { status: number; stdout: Buffer };
return { code: e.status, stdout: e.stdout };
}
}
/** One edit-source record in the script's wire format. */
function editRecord(relPath: string, size: number, start: number, body: Buffer | string): Buffer {
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);
return Buffer.concat([Buffer.from(`${relPath}\0${size}\0${start}\0${bytes.length}\0`), bytes]);
}
const REMOTE: SessionRemote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
describe('isRemoteClaudeTranscriptPath', () => {
it('takes an absolute <id>.jsonl path and nothing else', () => {
expect(isRemoteClaudeTranscriptPath('/home/u/.claude/projects/-w/conv-1.jsonl')).toBe(true);
expect(isRemoteClaudeTranscriptPath('rel/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/conv-1.json')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/bad id.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/$(x).jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p\n/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/.jsonl')).toBe(false);
});
});
describe('the transcript lookup on a real shell', () => {
const projects = () => join(home, '.claude', 'projects');
beforeAll(() => {
mkdirSync(join(projects(), 'old'), { recursive: true });
mkdirSync(join(projects(), 'new'), { recursive: true });
writeFileSync(join(projects(), 'old', 'conv-1.jsonl'), 'x\n');
writeFileSync(join(projects(), 'new', 'conv-1.jsonl'), 'y\n');
writeFileSync(join(projects(), 'old', 'solo.jsonl'), 'z\n');
utimesSync(join(projects(), 'old', 'conv-1.jsonl'), 1_000, 1_000);
utimesSync(join(projects(), 'new', 'conv-1.jsonl'), 2_000, 2_000);
});
const locate = (ids: string[], pane?: Parameters<typeof buildRemoteClaudeTranscriptLocateCommand>[1], env = {}) => {
const { code, stdout } = runSh(buildRemoteClaudeTranscriptLocateCommand(ids, pane), env);
return code === 0 ? parseRemoteClaudeTranscriptLocate(stdout) : code;
};
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeTranscriptLocateCommand(['conv-1']);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('finds the newest <id>.jsonl of the first id that has one', () => {
expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['missing', 'solo'])).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('exits 3 when no id has a transcript, and drops ids that are not plain tokens', () => {
expect(locate(['nope'])).toBe(3);
expect(buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', "a'b", '../../x'])).toBe('exit 3');
const command = buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', 'conv-1']);
expect(command).toContain('conv-1');
expect(command).not.toContain('pwned');
});
it('parses only an absolute transcript path after the first NUL (a login banner comes before it)', () => {
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('motd\n\0/p/conv-1.jsonl\0'))).toBe('/p/conv-1.jsonl');
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('no nul'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/conv-1.jsonl'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0p/conv-1.jsonl\0'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/notes.txt\0'))).toBeNull();
});
describe('after a /clear: the conversation the pane’s claude writes now', () => {
// Fake tmux (pane pid 100) and ps (100 → 200 wrapper → 300 claude); claude's own
// ~/.claude/sessions/<pid>.json names the current conversation.
let bin: string;
const pane = { socket: 'codeman-remote', sessionName: 'codeman-ssh-conv1abc' };
beforeAll(() => {
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-tmux-'));
writeFileSync(
join(bin, 'tmux'),
'#!/bin/sh\ncase "$*" in *"=codeman-ssh-conv1abc"*) echo 100 ;; *) echo "no such session" >&2; exit 1 ;; esac\n'
);
writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf " 1 0\\n100 1\\n200 100\\n300 200\\n"\n');
chmodSync(join(bin, 'tmux'), 0o755);
chmodSync(join(bin, 'ps'), 0o755);
mkdirSync(join(home, '.claude', 'sessions'), { recursive: true });
});
afterAll(() => rmSync(bin, { recursive: true, force: true }));
const locateIn = (p: typeof pane | undefined) => locate(['conv-1'], p, { PATH: `${bin}:${process.env.PATH}` });
const session = (body: string) => writeFileSync(join(home, '.claude', 'sessions', '300.json'), body);
it('finds the live conversation of a grandchild claude before the stored ids', () => {
session('{"pid":300,"sessionId":"solo","cwd":"/w","kind":"interactive"}');
expect(locateIn(pane)).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('falls back to the stored ids without a pane, for an unknown session, or a live id with no transcript', () => {
session('{"pid":300,"sessionId":"solo"}');
expect(locateIn(undefined)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-other' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
session('{"pid":300,"sessionId":"gone"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
});
it('takes only an id-shaped value from the sessions file, and only plain socket/session names', () => {
session('{"pid":300,"sessionId":"x$(touch pwned)"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(existsSync(join(home, 'pwned'))).toBe(false);
expect(buildRemoteClaudeTranscriptLocateCommand(['conv-1'], { socket: "a';x", sessionName: 's' })).not.toContain(
'list-panes'
);
});
});
});
describe('buildRemoteClaudeEditSourcesCommand', () => {
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeEditSourcesCommand('/p/conv-1.jsonl', [], 1024);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('exits 3 for a path that is not an absolute <id>.jsonl, without putting it in a script', () => {
for (const path of ['rel/conv-1.jsonl', "/p/a';touch x;'.jsonl", '/p/conv-1.txt', '/p\n/conv-1.jsonl']) {
expect(buildRemoteClaudeEditSourcesCommand(path, [], 10)).toBe('exit 3');
}
});
it('ignores invalid relPaths, offsets and lengths; the first request per relPath wins', () => {
const command = buildRemoteClaudeEditSourcesCommand(
'/p/conv-1.jsonl',
[
{ relPath: '../../etc/passwd', offset: 0 },
{ relPath: "subagents/agent-a';touch x;'.jsonl", offset: 0 },
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
{ relPath: 'subagents/agent-frac.jsonl', offset: 1.5 },
{ relPath: 'subagents/agent-nan.jsonl', offset: Number.NaN },
{ relPath: 'subagents/agent-badlen.jsonl', offset: 0, len: -5 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 7, len: 3 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 99 },
],
100
);
expect(command).not.toMatch(/passwd|touch|agent-neg|agent-frac|agent-nan|agent-badlen/);
expect(command).toContain('agent-ok.jsonl');
expect(command).toContain('r 7 3');
expect(command).not.toContain('r 99');
});
});
describe('parseRemoteClaudeEditSources', () => {
it('splits several records byte-exactly and keeps a UTF-8 sequence cut at a chunk edge intact as bytes', () => {
const euro = Buffer.from('a€', 'utf8'); // 61 e2 82 ac
const stdout = Buffer.concat([
Buffer.from('banner from .zshrc\n\0conv-1\0'),
editRecord('main', 10, 6, euro.subarray(0, 3)), // ends mid-€
editRecord('subagents/agent-x1.jsonl', 4, 0, '{}\n\0'),
editRecord('subagents/agent-x1.meta.json', 23, 0, '{"agentType":"Explore"}'),
]);
const parsed = parseRemoteClaudeEditSources(stdout);
expect(parsed?.transcriptId).toBe('conv-1');
expect(parsed?.files).toHaveLength(2);
expect(parsed?.files[0]).toMatchObject({ relPath: 'main', size: 10, startOffset: 6 });
expect(parsed?.files[0].chunk.equals(euro.subarray(0, 3))).toBe(true);
expect(parsed?.files[1].chunk.equals(Buffer.from('{}\n\0'))).toBe(true);
expect(parsed?.metas).toEqual({ x1: '{"agentType":"Explore"}' });
});
it('accepts a zero-length record (budget used up) and a header with no records', () => {
const parsed = parseRemoteClaudeEditSources(
Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 500, 500, '')])
);
expect(parsed?.files).toEqual([{ relPath: 'main', size: 500, startOffset: 500, chunk: Buffer.alloc(0) }]);
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0'))).toEqual({
transcriptId: 'conv-1',
files: [],
metas: {},
});
});
it('is null when the protocol is broken', () => {
expect(parseRemoteClaudeEditSources(Buffer.from('no nul at all'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0bad id\0'))).toBeNull();
// Body shorter than declared.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x000\x005\x00abc'))).toBeNull();
// Truncated header.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x00'))).toBeNull();
// Non-numeric size, unknown relPath, slice outside the file.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\0x\x000\x000\0'))).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('../x', 1, 0, 'a')]))
).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 2, 1, 'ab')]))
).toBeNull();
});
});
describe('the transcript read on a real shell', () => {
const project = () => join(home, '.claude', 'projects', 'read');
const main = () => join(project(), 'conv-r.jsonl');
const read = (requests: EditSourceRequest[], budget: number, onlyRequested = false) => {
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(main(), requests, budget, { onlyRequested }));
return code === 0 ? parseRemoteClaudeEditSources(stdout) : code;
};
beforeAll(() => {
const sub = join(project(), 'conv-r', 'subagents');
mkdirSync(sub, { recursive: true });
writeFileSync(main(), 'main-0123456789\n');
writeFileSync(join(sub, 'agent-a1.jsonl'), 'AAAAAAAAAA');
writeFileSync(join(sub, 'agent-a1.meta.json'), '{"agentType":"Explore"}');
writeFileSync(join(sub, 'agent-b2.jsonl'), 'BBBBB');
writeFileSync(join(sub, 'agent-bad name.jsonl'), 'never read');
});
it('exits 3 when the transcript is gone', () => {
const { code } = runSh(buildRemoteClaudeEditSourcesCommand(join(project(), 'gone.jsonl'), [], 100));
expect(code).toBe(3);
});
it('continues from the offsets, reads a new subagent’s end with its meta, and an older block to its end', () => {
const parsed = read(
[
{ relPath: 'main', offset: 5 },
{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 3 },
],
1000
);
expect(parsed).not.toBe(3);
if (typeof parsed !== 'object' || !parsed) return;
expect(parsed.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString()])).toEqual([
['main', 16, 5, '0123456789\n'],
['subagents/agent-a1.jsonl', 10, 0, 'AAAAAAAAAA'],
['subagents/agent-b2.jsonl', 5, 1, 'BBB'],
]);
expect(parsed.metas).toEqual({ a1: '{"agentType":"Explore"}' });
});
it('with onlyRequested lists only the requested files and sends no meta', () => {
const parsed = read([{ relPath: 'subagents/agent-a1.jsonl', offset: 2, len: 4 }], 1000, true);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-a1.jsonl', 2, 'AAAA'],
]);
expect(parsed.metas).toEqual({});
});
it('treats a hostile request as data: it never reaches the shell', () => {
const marker = join(home, 'pwned-read');
const parsed = read([{ relPath: `subagents/agent-x.jsonl'; touch ${marker}; '`, offset: 0 }], 1000);
expect(existsSync(marker)).toBe(false);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => f.relPath)).toEqual([
'main',
'subagents/agent-a1.jsonl',
'subagents/agent-b2.jsonl',
]);
});
it('reads exactly what the local reader reads (seeded random transcripts, requests and budgets)', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 60; seed++) {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const dir = join(home, '.claude', 'projects', `fuzz-${seed}`);
const path = join(dir, `conv-${seed}.jsonl`);
const sub = join(dir, `conv-${seed}`, 'subagents');
mkdirSync(sub, { recursive: true });
const text = (n: number) => Array.from({ length: n }, () => ['a', 'ä', '€', '\n', '{', 'z'][ri(0, 5)]).join('');
writeFileSync(path, text(ri(0, 300)));
const names: string[] = [];
for (let i = ri(0, 5); i > 0; i--) {
const name = `agent-${['A', 'b', '0', '_', '-'][ri(0, 4)]}${ri(0, 99)}`;
names.push(name);
writeFileSync(join(sub, `${name}.jsonl`), text(ri(0, 200)));
if (rnd() < 0.6)
writeFileSync(join(sub, `${name}.meta.json`), JSON.stringify({ description: text(ri(0, 40)) }));
}
const relPaths = ['main', ...names.map((n) => `subagents/${n}.jsonl`), 'subagents/agent-missing.jsonl'];
const requests: EditSourceRequest[] = [];
for (const relPath of relPaths) {
if (rnd() < 0.5) continue;
const r: EditSourceRequest = { relPath, offset: ri(0, 350) };
if (rnd() < 0.4) r.len = ri(0, 250);
requests.push(r);
}
const budget = ri(0, 700);
const onlyRequested = rnd() < 0.3;
const local = await readClaudeEditSources(path, requests, budget, { onlyRequested });
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(path, requests, budget, { onlyRequested }));
const remote = code === 0 ? parseRemoteClaudeEditSources(stdout) : null;
const shape = (r: typeof local) =>
r && {
transcriptId: r.transcriptId,
metas: r.metas,
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
};
try {
expect(shape(remote)).toEqual(shape(local));
} catch {
failures.push(`seed ${seed}: ${JSON.stringify({ requests, budget, onlyRequested })}`);
}
}
expect(failures).toEqual([]);
});
});
describe('under vitest', () => {
it('never opens a connection: the lookup and the read reject with a clear error', async () => {
await expect(remoteLocateClaudeTranscript(REMOTE, ['a'])).rejects.toBeInstanceOf(RemoteFileAccessError);
await expect(remoteReadClaudeEditSources(REMOTE, '/p/a.jsonl', [], 10)).rejects.toBeInstanceOf(
RemoteFileAccessError
);
});
});
describe('never wakes a host', () => {
// Invariant: only real user input or an explicit wake request may wake a host. The edit
// history reads a remote transcript whenever the panel is open, so nothing it can reach may
// hold the wake registry: a sleeping host must stay asleep and the read ends `unavailable`.
// `test/remote-wake.test.ts` pins who imports remote-wake; this pins that the edit-history
// read path reaches neither it nor the route module that owns the registry, transitively.
const SRC = fileURLToPath(new URL('../src', import.meta.url));
const ROOTS = [
'edit-history-local-source.ts',
'edit-history-parse.ts',
'edit-history-store.ts',
'remote-files.ts',
join('web', 'edit-history-sync.ts'),
join('web', 'routes', 'edit-history-routes.ts'),
];
function resolveImport(from: string, spec: string): string | null {
if (!spec.startsWith('.')) return null;
const base = join(dirname(from), spec).replace(/\.js$/, '');
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
try {
readFileSync(candidate);
return candidate;
} catch {
/* next candidate */
}
}
throw new Error(`unresolved import ${spec} in ${relative(SRC, from)}`);
}
it('the read path never imports remote-wake or session-routes, even transitively', () => {
const seen = new Set<string>();
const queue = ROOTS.map((r) => join(SRC, r));
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
const text = readFileSync(file, 'utf-8');
// Static imports/re-exports start a line; a dynamic import() counts outside comment lines.
const specs = [
...text.matchAll(/^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gms),
...text.matchAll(/^(?!\s*(?:\*|\/\/)).*\bimport\(\s*['"]([^'"]+)['"]/gm),
].map((m) => m[1]);
for (const spec of specs) {
const next = resolveImport(file, spec);
if (next) queue.push(next);
}
}
const reached = [...seen].map((f) => relative(SRC, f));
expect(reached).toContain(join('web', 'edit-history-sync.ts'));
// `config/remote-wake-limits.ts` (the MAC-count constant of the host schema) may be reached; it wakes nothing.
expect(reached).not.toContain('remote-wake.ts');
expect(reached).not.toContain(join('web', 'routes', 'session-routes.ts'));
});
});
+12 -7
View File
@@ -62,7 +62,7 @@ async function setup(
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
probe: async (_session, p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
@@ -183,7 +183,6 @@ describe('GET /api/sessions/:id/edits', () => {
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
@@ -242,7 +241,12 @@ describe('POST /api/sessions/:id/edits/older', () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
async (
_session: unknown,
_path: string,
requests: readonly { relPath: string; offset: number; len?: number }[],
budget: number
) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
@@ -260,12 +264,13 @@ describe('POST /api/sessions/:id/edits/older', () => {
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
it('reads a remote session, handing the reader that session', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'ok', entries: [{ path: '/w/src/a.ts' }] });
expect(read).toHaveBeenCalledTimes(1);
expect((read.mock.calls[0] as unknown[])[0]).toMatchObject({ id: session.id, remote: { host: 'huff' } });
});
});