mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge #766 feat: edit history panel for remote (SSH) claude sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
76e40668fc
21 files changed
+1670
-97
No files matched your search
@@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
|
||||
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk (the one exception: the Edit history store keeps the diffs read from a remote session's transcripts), are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
|
||||
|
||||
**Wake-on-LAN (`remote-wake.ts`)**: optional `RemoteHost.wakeMac` (magic packet) or `RemoteHost.wakeCommand` (single executable, no shell, takes precedence) lets HTTP input, `POST /api/sessions/:id/wake` and the user's create/attach (`ensureHostAwake`) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery or `cron-service.ts`, and `GET /api/sessions/:id/reachability` must never wake. ⚠️ Detection is a throttled bare TCP probe; never add `ServerAliveInterval`, and a `jumpHost`/`socksProxy`/`ProxyCommand` host is reachability-UNKNOWN (`isProbeable()`): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (`host-wake-ui.js`) must not promise queued input. Waiting requests use the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`. → [architecture-invariants#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases)
|
||||
|
||||
@@ -244,7 +244,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()`, a remote session's only through `remoteProbePaths()`. ⚠️ **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)
|
||||
|
||||
|
||||
@@ -524,7 +524,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
|
||||
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host apart from the diffs the Edit history panel keeps; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
|
||||
|
||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||
|
||||
@@ -1419,9 +1419,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; a remote session sends no hooks, so its host is asked over ssh for the conversation the pane's claude writes now, else the most recently written of the stored conversation, the session's `claudeSessionId` and its id, under the host's `CLAUDE_CONFIG_DIR` or `~/.claude`) 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.
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
|
||||
|
||||
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
|
||||
|
||||
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk; the one exception is the Edit history store, which keeps the diffs parsed from a remote session's transcripts under `dataPath('edit-history')` (see [Edit history panel](#edit-history-panel)). ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
@@ -774,7 +774,7 @@ Two surfaces do not move with it. The Git panel keeps working from `workingDir`,
|
||||
|
||||
### 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`.
|
||||
|
||||
@@ -784,11 +784,15 @@ Two surfaces do not move with it. The Git panel keeps working from `workingDir`,
|
||||
|
||||
⚠️ **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.
|
||||
⚠️ **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`). A remote session's candidates go through `remoteProbePaths()` instead, one bounded ssh round trip per 40 paths; a failed probe ends the probing for that read, and its edits stay in. `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. 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 `<config dir>/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the most recently written `<config dir>/projects/*/<id>.jsonl` of the stored history's conversation, `<claudeSessionId>` and `<id>` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`), or several with more than 300 subagent requests (below), 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 per read, which the local reader does not have: a meta comes only with a subagent's first read, so one past the budget is never sent and that subagent shows its id instead of its description). `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`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen. A round trip's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the lookup and the reads go through `buildRemoteEditHistoryCommand` (`buildRemoteFileCommand` with `exec`, so a timeout reaches ssh and not only the local `sh -c`, which on dash stays its parent), the image probes through `remoteProbePaths`, and all of them through `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.
|
||||
|
||||
⚠️ **A read with more than 300 subagent requests spans several round trips** (`planRemoteEditSourceRounds`, `EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP`). The script is one argument of the ssh line, Linux caps a single argv string at 128 KiB (`E2BIG` at about 1000 subagents), the cursors have no cap and the stored conversation is read first, so one script for all of them left a long history `unavailable`, and not even a `/clear` got past it. The cut is by NAME RANGE, not only by count: the script takes a file no `case` line names for a new one (its tail, its meta), so plain groups would read each other's files as new. Each round trip walks only the names between its cuts (one `awk` per round trip, never a fork per file), so a new file lands in exactly one range. Main comes with the first round trip, budget and meta budget carry over, and a later round trip still lists its files, because a requested file missing from the result loses its cursor. A failed round trip fails the whole read and nothing of it reaches the store; each one takes its own limiter slot. The merged result equals the local reader's, byte for byte (the fuzz test cuts every read at a random size). A read of up to 300 runs no `awk` at all. ⚠️ No `case` inside a `$(…)`: bash 3.2, macOS `sh`, cannot parse it, which is why the names come from a shell function.
|
||||
|
||||
### Config backups
|
||||
|
||||
|
||||
@@ -449,8 +449,9 @@ still has it.
|
||||
|
||||
**Not available over ssh (by choice, not by accident):** editing a file (writes would
|
||||
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
|
||||
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
|
||||
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
|
||||
generated thumbnails (both need the bytes on the server's disk: no remote file is ever
|
||||
spilled onto the server, the Edit history store's diffs being the one exception), the
|
||||
file-tree/picker listings, and `tail-file`. Those routes
|
||||
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
|
||||
the two views can only disagree when that mount is stale. Docker cases are unaffected:
|
||||
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
|
||||
|
||||
@@ -84,9 +84,11 @@ clamping each other, and discovery shows a shared badge with the client count.
|
||||
|
||||
Previews, downloads and text reads in a remote case go over the same ssh connection the
|
||||
session uses, so a clicked path opens the file on the machine the agent is on, `Range`
|
||||
seeking included. Nothing is copied to the Codeman host. Editing, Office previews,
|
||||
thumbnails, the file tree and the tail viewer are not available remotely and answer a clear
|
||||
400 rather than a misleading 404. Details in [Working With Files](Working-With-Files).
|
||||
seeking included. Nothing is copied to the Codeman host, apart from the diffs the
|
||||
[Edit history](Working-With-Files#edit-history) panel keeps, which works for remote Claude
|
||||
sessions too. Editing, Office previews, thumbnails, the file tree and the tail viewer are not
|
||||
available remotely and answer a clear 400 rather than a misleading 404. Details in
|
||||
[Working With Files](Working-With-Files).
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ be wrong for at least one of them:
|
||||
| **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. |
|
||||
| **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. |
|
||||
| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. |
|
||||
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. |
|
||||
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host, apart from the diffs the Edit history panel reads from a remote Claude session's transcripts while it is open, kept in a private bounded store (0600) that is deleted with the session's record. Writes, Office previews and thumbnails are refused. |
|
||||
|
||||
Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS
|
||||
credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -150,8 +150,8 @@ session uses: one `realpath` plus `stat` probe for the file and the workspace ro
|
||||
streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host
|
||||
that can resolve them, the size cap applies to the remote size before a byte is requested,
|
||||
and an unreachable host answers 502 rather than pretending the file is missing. Nothing is
|
||||
ever copied onto the Codeman host, and a same-named local file is never served under a
|
||||
remote name.
|
||||
ever copied onto the Codeman host, apart from the diffs the [edit history](#edit-history)
|
||||
stores, and a same-named local file is never served under a remote name.
|
||||
|
||||
Not available over ssh, and said so with a 400 instead of a misleading 404: editing in
|
||||
place, Office previews and generated thumbnails (both need the bytes on the server's disk),
|
||||
@@ -317,20 +317,28 @@ 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 most recently written of the one the window
|
||||
showed last and the session's own, under the remote user's `~/.claude`, or under
|
||||
`CLAUDE_CONFIG_DIR` when the host sets it for every SSH command (`.zshenv`,
|
||||
`/etc/environment`); one set only in `.profile`, `.bash_profile` or `.zshrc` is not seen.
|
||||
- **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
|
||||
|
||||
+551
-1
@@ -30,8 +30,9 @@ import { exec, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } 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,555 @@ 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 (the local reader has none). A meta comes only with a
|
||||
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
|
||||
* as usual, and the panel names it by its id instead of its description.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Subagent requests per ssh round trip of an edit-history read. Like
|
||||
* {@link REMOTE_PROBE_CHUNK_SIZE}: the script is ONE argument of the ssh line, Linux caps a
|
||||
* single argv string at 128 KiB, and each request adds a `case` line of 125 bytes once
|
||||
* shellescaped twice (about 370 with the longest name a filesystem allows; the test pins that
|
||||
* worst case). See {@link planRemoteEditSourceRounds}.
|
||||
*/
|
||||
const EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP = 300;
|
||||
|
||||
/**
|
||||
* The slowest link an edit-history read is given time for. Such a read can carry megabytes
|
||||
* (a forward pass its budget plus the metas, "Load older" up to three budgets), which a flat
|
||||
* {@link REMOTE_READ_TIMEOUT_MS} would cut off below ~1 MiB/s. 256 KiB/s ≈ 2 Mbit/s.
|
||||
*/
|
||||
const EDIT_SOURCE_MIN_BYTES_PER_SEC = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Timeout of an edit-history read that may return up to `maxBytes`: the flat read timeout plus
|
||||
* the transfer time at {@link EDIT_SOURCE_MIN_BYTES_PER_SEC}. It ends a hung read (a host that
|
||||
* fell asleep mid-transfer; no keepalives, see the wake invariants), it does not cut a slow one.
|
||||
*/
|
||||
export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
|
||||
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
|
||||
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
|
||||
* plain command, which would leave ssh running and the remote read holding its connection.
|
||||
*/
|
||||
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
|
||||
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
|
||||
}
|
||||
|
||||
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 `cfg` to claude's config dir on the remote host: an absolute
|
||||
* `CLAUDE_CONFIG_DIR` from the ssh command's environment, else `$HOME/.claude` — claude's own
|
||||
* rule. ⚠️ The remote claude runs under `$SHELL -i -l -c` (`remoteLoginShellCommand`), this
|
||||
* script under a plain non-interactive `sh` from sshd's `$SHELL -c`: a `CLAUDE_CONFIG_DIR` set
|
||||
* only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen
|
||||
* here; one from `.zshenv`, sshd `SetEnv` or the system environment is.
|
||||
*/
|
||||
function claudeConfigDirLines(): string[] {
|
||||
return ['cfg=$HOME/.claude', 'case ${CLAUDE_CONFIG_DIR:-} in /*) cfg=$CLAUDE_CONFIG_DIR ;; esac'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
|
||||
* or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). 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 `<config dir>/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`).
|
||||
*
|
||||
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
|
||||
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
|
||||
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
|
||||
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
|
||||
*
|
||||
* ⚠️ `=name:` is an EXACT session match. Without the colon tmux reads a window target, so a
|
||||
* session that is gone resolves to a window of that name or a session the name is a prefix of,
|
||||
* and that session's conversation would land in this one's edit history.
|
||||
*/
|
||||
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
|
||||
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
|
||||
return [
|
||||
'live=',
|
||||
`pp=$(${withRemoteTmuxPath(`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="$cfg/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}); without one, the most recently
|
||||
* written transcript of ALL `ids`, not the first id that has one: the conversation claude
|
||||
* wrote last is the current one whether a `/clear` moved on from the launch id or a respawn
|
||||
* resumed it again, and no order of the ids gets both right. Each id is looked up as
|
||||
* `<config dir>/projects/*\/<id>.jsonl` ({@link claudeConfigDirLines}). `$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=',
|
||||
...claudeConfigDirLines(),
|
||||
// newest <id>: `best` becomes that id's transcript if it is newer than the current best.
|
||||
// BusyBox's (and bash 3.2's) `-nt` compares whole seconds: a tie keeps the earlier id, the
|
||||
// stored conversation, until the next write; only without a live id.
|
||||
'newest() {',
|
||||
' for f in "$cfg"/projects/*/"$1".jsonl; do',
|
||||
' [ -f "$f" ] || continue',
|
||||
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
|
||||
' done',
|
||||
'}',
|
||||
...liveLines,
|
||||
...(liveLines.length ? ['[ -z "$live" ] || newest "$live"'] : []),
|
||||
'if [ -z "$best" ]; then',
|
||||
` for id in ${safeIds.map((id) => shellescape(id)).join(' ')}; do newest "$id"; done`,
|
||||
'fi',
|
||||
`[ -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 = buildRemoteEditHistoryCommand(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;
|
||||
}
|
||||
|
||||
/** The requests a read script may take: the first per relPath, valid ones only. */
|
||||
function validEditSourceRequests(requests: readonly EditSourceRequest[]): Map<string, EditSourceRequest> {
|
||||
const byRelPath = new Map<string, EditSourceRequest>();
|
||||
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, request);
|
||||
}
|
||||
return byRelPath;
|
||||
}
|
||||
|
||||
/** One ssh round trip of an edit-history read ({@link planRemoteEditSourceRounds}). */
|
||||
export interface RemoteEditSourceRound {
|
||||
requests: EditSourceRequest[];
|
||||
/** It reads the main transcript: the first round trip of a read does. */
|
||||
main: boolean;
|
||||
/** It takes the subagent files after this name ('' = from the first), in byte order. */
|
||||
after: string;
|
||||
/** It takes them up to and including this name ('' = to the last). */
|
||||
upTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ssh round trips a read takes. Up to `perRound` subagent requests fit one; more are sorted
|
||||
* and cut into groups of `perRound`, or the ssh line would hit `E2BIG` and the session would
|
||||
* stay `unavailable`: the cursors have no cap and the stored conversation is read first, so
|
||||
* not even a `/clear` would get past it.
|
||||
*
|
||||
* ⚠️ The cuts are NAME RANGES, not only groups of requests. The script takes a file that no
|
||||
* `case` line names for a new one (its tail, its meta), so a plain group would read every
|
||||
* file of the other groups as new. Each round trip walks only the names between its cuts; the
|
||||
* ranges cover every possible name exactly once, so a new file lands in one of them and the
|
||||
* merged result is the one a single script would give (the fuzz test compares it with the
|
||||
* local reader).
|
||||
*/
|
||||
export function planRemoteEditSourceRounds(
|
||||
requests: readonly EditSourceRequest[],
|
||||
perRound: number = EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP
|
||||
): RemoteEditSourceRound[] {
|
||||
const valid = validEditSourceRequests(requests);
|
||||
const subagents = [...valid.values()].filter((request) => request.relPath !== 'main');
|
||||
if (subagents.length <= perRound) return [{ requests: [...valid.values()], main: true, after: '', upTo: '' }];
|
||||
|
||||
const main = valid.get('main');
|
||||
const fileName = (request: EditSourceRequest): string => request.relPath.slice('subagents/'.length);
|
||||
subagents.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
|
||||
const rounds: RemoteEditSourceRound[] = [];
|
||||
for (let i = 0; i < subagents.length; i += perRound) {
|
||||
const group = subagents.slice(i, i + perRound);
|
||||
rounds.push({
|
||||
requests: i === 0 && main ? [main, ...group] : group,
|
||||
main: i === 0,
|
||||
after: i === 0 ? '' : fileName(subagents[i - 1]),
|
||||
upTo: i + perRound < subagents.length ? fileName(group[group.length - 1]) : '',
|
||||
});
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
/** What {@link buildRemoteClaudeEditSourcesCommand} takes beyond the local reader's options. */
|
||||
export interface RemoteEditSourceCommandOptions extends EditSourceReadOptions {
|
||||
/** Which part of a split read this command is; without it, all of it. */
|
||||
round?: Pick<RemoteEditSourceRound, 'main' | 'after' | 'upTo'>;
|
||||
/** Meta bytes this command may send; without it, {@link EDIT_SOURCE_META_BUDGET_BYTES}. */
|
||||
metaBudget?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Script for one round trip of a read of a remote claude session's main transcript at
|
||||
* `mainPath` and its subagent transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`),
|
||||
* 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. A read
|
||||
* with more requests than fit one script is several of these ({@link planRemoteEditSourceRounds}).
|
||||
*
|
||||
* 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: RemoteEditSourceCommandOptions = {}
|
||||
): string {
|
||||
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
|
||||
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
|
||||
|
||||
const byRelPath = validEditSourceRequests(requests);
|
||||
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
||||
const requestArgs = (request: EditSourceRequest): string =>
|
||||
`r ${request.offset} ${request.len === undefined ? "''" : String(request.len)}`;
|
||||
const { main: readsMain, after, upTo } = opts.round ?? { main: true, after: '', upTo: '' };
|
||||
|
||||
const main = byRelPath.get('main');
|
||||
const mainLine = !readsMain
|
||||
? ''
|
||||
: main
|
||||
? `emit main "$best" ${requestArgs(main)}`
|
||||
: opts.onlyRequested
|
||||
? ''
|
||||
: 'emit main "$best" t 0 \'\'';
|
||||
// The names come from validated relPaths, and `$0` stays a string: every name starts `agent-`.
|
||||
const rangeFilter =
|
||||
after || upTo
|
||||
? ` | awk -v after=${shellescape(after)} -v upto=${shellescape(upTo)} '$0 > after && (upto == "" || $0 <= upto)'`
|
||||
: '';
|
||||
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=${opts.metaBudget ?? 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"',
|
||||
'}',
|
||||
// The valid names, one per line in byte order (the glob's order under `C`). Nothing but
|
||||
// `[A-Za-z0-9_.-]` is left, so the unquoted `$(…)` below splits on them and globs nothing.
|
||||
// A function, because bash 3.2 (macOS `sh`) cannot parse a `case` inside `$(…)`.
|
||||
'subagent_names() {',
|
||||
' for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
|
||||
' b=${f##*/}',
|
||||
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
|
||||
' printf \'%s\\n\' "$b"',
|
||||
' done',
|
||||
'}',
|
||||
mainLine,
|
||||
`for b in $(subagent_names${rangeFilter}); do`,
|
||||
' f=${best%.jsonl}/subagents/$b',
|
||||
' [ -f "$f" ] || continue',
|
||||
' 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one read command on the host: its stdout, at most `maxBytes`, or null for "no such
|
||||
* transcript" ({@link TRANSCRIPT_NOT_FOUND_EXIT}).
|
||||
*/
|
||||
export type RemoteEditSourceRunner = (command: string, maxBytes: number) => Promise<Buffer | null>;
|
||||
|
||||
/**
|
||||
* Run the round trips of a read ({@link planRemoteEditSourceRounds}) one after the other and
|
||||
* merge them into what one read returns. Budget and meta budget carry over: a later round trip
|
||||
* gets what the earlier ones left, and still lists its files, because a requested file missing
|
||||
* from the result counts as deleted and loses its cursor. So a read is all or nothing: a failed
|
||||
* round trip fails it, and nothing of it reaches the store.
|
||||
*/
|
||||
export async function readRemoteEditSourceRounds(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions,
|
||||
run: RemoteEditSourceRunner,
|
||||
perRound?: number
|
||||
): Promise<EditSources | null> {
|
||||
let budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
||||
let metaBudget = EDIT_SOURCE_META_BUDGET_BYTES;
|
||||
const merged: EditSources = { transcriptId: '', files: [], metas: {} };
|
||||
for (const round of planRemoteEditSourceRounds(requests, perRound)) {
|
||||
const stdout = await run(
|
||||
buildRemoteClaudeEditSourcesCommand(mainPath, round.requests, budget, { ...opts, round, metaBudget }),
|
||||
budget + metaBudget + EDIT_SOURCE_HEADER_SLACK_BYTES
|
||||
);
|
||||
if (!stdout) return null;
|
||||
const parsed = parseRemoteClaudeEditSources(stdout);
|
||||
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
|
||||
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
|
||||
}
|
||||
merged.transcriptId = parsed.transcriptId;
|
||||
merged.files.push(...parsed.files);
|
||||
Object.assign(merged.metas, parsed.metas);
|
||||
for (const file of parsed.files) budget -= file.chunk.length;
|
||||
for (const text of Object.values(parsed.metas)) metaBudget = Math.max(0, metaBudget - Buffer.byteLength(text));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a remote claude session's transcripts (see {@link readRemoteEditSourceRounds}), the
|
||||
* remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an unreachable
|
||||
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Each round trip takes its
|
||||
* own slot of the ssh limiter. Never wakes.
|
||||
*/
|
||||
export async function remoteReadClaudeEditSources(
|
||||
remote: SessionRemote,
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
assertNotUnderTest();
|
||||
return readRemoteEditSourceRounds(mainPath, requests, budgetBytes, opts, async (command, maxBytes) => {
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(buildRemoteEditHistoryCommand(remote, command), {
|
||||
timeout: remoteEditSourcesTimeoutMs(maxBytes),
|
||||
maxBuffer: maxBytes,
|
||||
encoding: 'buffer',
|
||||
})
|
||||
);
|
||||
return 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)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Command that writes a remote file's bytes to stdout.
|
||||
*
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
* 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 (more for a
|
||||
* conversation with hundreds of subagents). 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`.
|
||||
*
|
||||
@@ -81,15 +85,22 @@ 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 main transcript path, or null when it has none yet. `storedTranscriptId` is
|
||||
* the conversation the stored history holds (null before the first read); see
|
||||
* {@link remoteTranscriptIds}.
|
||||
*/
|
||||
transcriptPath(session: EditHistorySession, storedTranscriptId: string | null): 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>;
|
||||
@@ -102,9 +113,21 @@ export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation ids a REMOTE session's transcript lookup considers when its claude writes
|
||||
* none now; the most recently written of them wins (`buildRemoteClaudeTranscriptLocateCommand`).
|
||||
* The stored history's conversation must be among them: no hook tells Codeman of a remote
|
||||
* `/clear`, so `claudeSessionId` stays the launch id, and without the stored one the panel
|
||||
* would flip back to the cleared conversation as soon as the remote claude exits.
|
||||
*/
|
||||
export function remoteTranscriptIds(session: EditHistorySession, storedTranscriptId: string | null): string[] {
|
||||
return [...new Set([storedTranscriptId, session.claudeSessionId || session.id, session.id])].filter(
|
||||
(id): id is string => !!id
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -324,16 +347,16 @@ export class EditHistorySync {
|
||||
try {
|
||||
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
||||
const store = await this.storeOf(session.id, slot);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session, store.getTranscriptId());
|
||||
let found: boolean;
|
||||
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 +382,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 +405,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 +441,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 +473,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 +497,7 @@ export class EditHistorySync {
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
await this.addImages(session, store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -472,13 +507,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;
|
||||
|
||||
@@ -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,21 +357,24 @@ 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') {
|
||||
body.append(
|
||||
el(
|
||||
'div',
|
||||
'edit-history-note edit-history-note--warn',
|
||||
data.reason === 'no-transcript'
|
||||
// A remote host sends no hooks: its transcript is only ever found by the lookup over ssh.
|
||||
const note =
|
||||
data.reason !== 'no-transcript'
|
||||
? t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
: this.sessions?.get?.(h.sessionId)?.remote
|
||||
? t(
|
||||
'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.'
|
||||
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.'
|
||||
)
|
||||
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
)
|
||||
);
|
||||
: t(
|
||||
'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.'
|
||||
);
|
||||
body.append(el('div', 'edit-history-note edit-history-note--warn', note));
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
|
||||
@@ -565,10 +565,13 @@
|
||||
'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 无法被找到。',
|
||||
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.':
|
||||
'远程主机上尚未找到记录(Claude 会在第一次提示时写入)。仅在 shell 配置文件(.profile、.bash_profile、.zshrc)中设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* POST /api/sessions/:id/edits/older read the next older block, then list
|
||||
*
|
||||
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
|
||||
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
|
||||
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
|
||||
* background). A remote session is read over ssh and never woken; Docker and non-claude
|
||||
* sessions answer `state: 'unsupported'` without being read. Ownership goes through `findSessionOrFail` with `req`, like every
|
||||
* session-scoped route. No route here serves a file: an image entry is shown through the
|
||||
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
|
||||
*/
|
||||
@@ -86,7 +86,7 @@ function listOf(
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
/** The answer for a session that is never read (Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
|
||||
+26
-12
@@ -140,9 +140,11 @@ import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
|
||||
import { guardSessionConfigDirHome } from './routes/session-routes.js';
|
||||
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { EditHistorySync } from './edit-history-sync.js';
|
||||
import { EditHistorySync, remoteTranscriptIds } 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,
|
||||
@@ -359,19 +361,31 @@ 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, else the stored conversation's (`remoteTranscriptIds`), and is
|
||||
* never woken for it (remote-files.ts).
|
||||
*/
|
||||
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, storedTranscriptId) =>
|
||||
session.remote
|
||||
? remoteLocateClaudeTranscript(
|
||||
session.remote,
|
||||
remoteTranscriptIds(session, storedTranscriptId),
|
||||
remoteTmuxLocation(session.remote, session.id)
|
||||
)
|
||||
: resolveClaudeTranscript(
|
||||
this.transcriptWatchers.get(session.id)?.getPath(),
|
||||
[session.claudeSessionId || session.id, session.id],
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('edit history image probe on an unresponsive mount', () => {
|
||||
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: async (_path, requests, budget): Promise<EditSources> => {
|
||||
readSources: async (_session, _path, requests, budget): Promise<EditSources> => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = Math.min(req?.offset ?? 0, main.length);
|
||||
const len = Math.min(budget, main.length - start);
|
||||
@@ -89,7 +89,7 @@ describe('edit history image probe on an unresponsive mount', () => {
|
||||
metas: {},
|
||||
};
|
||||
},
|
||||
probe: (paths) => probeLocalPaths(paths),
|
||||
probe: (_session, paths) => probeLocalPaths(paths),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
|
||||
sweepStores: async () => 0,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
REMOVED,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
remoteTranscriptIds,
|
||||
unsupportedReason,
|
||||
} from '../src/web/edit-history-sync.js';
|
||||
|
||||
@@ -129,13 +130,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 +157,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,14 +169,24 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remoteTranscriptIds', () => {
|
||||
it('includes the stored conversation besides the launch id: no hook tells Codeman of a remote /clear', () => {
|
||||
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
|
||||
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
|
||||
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
|
||||
expect(remoteTranscriptIds({ ...remote, claudeSessionId: null }, null)).toEqual(['s2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistorySync', () => {
|
||||
it('reads new edits forward from the stored offset', async () => {
|
||||
const { host, sync } = setup();
|
||||
@@ -230,11 +249,11 @@ describe('EditHistorySync', () => {
|
||||
});
|
||||
|
||||
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
|
||||
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
|
||||
const probe = vi.fn(async (_session: unknown, paths: readonly string[]) => paths.map(() => null));
|
||||
let removal: Promise<void> | undefined;
|
||||
const ctx = setup({
|
||||
probe,
|
||||
readSources: async (path, req, budget, opts) => {
|
||||
readSources: async (_session, path, req, budget, opts) => {
|
||||
const res = await ctx.host.read(path, req, budget, opts);
|
||||
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
|
||||
return res;
|
||||
@@ -285,16 +304,61 @@ 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('hands the transcript lookup the conversation the stored history holds', async () => {
|
||||
const stored: Array<string | null> = [];
|
||||
const { host, sync } = setup({
|
||||
transcriptPath: async (_session, storedTranscriptId) => {
|
||||
stored.push(storedTranscriptId);
|
||||
return host.path;
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(remote);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(remote);
|
||||
await sync.sync(remote);
|
||||
expect(stored).toEqual([null, 'conv1', 'conv2']);
|
||||
});
|
||||
|
||||
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'));
|
||||
@@ -670,6 +734,16 @@ describe('reads only on request (wiring guard)', () => {
|
||||
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
|
||||
});
|
||||
|
||||
it('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const start = server.indexOf('new EditHistorySync({');
|
||||
const lookup = server.slice(start, server.indexOf('readSources:', start));
|
||||
expect(lookup).toMatch(
|
||||
/session\.remote\s*\?\s*remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/
|
||||
);
|
||||
expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/);
|
||||
});
|
||||
|
||||
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
|
||||
const root = join(__dirname, '..');
|
||||
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
|
||||
|
||||
@@ -379,6 +379,33 @@ 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);
|
||||
try {
|
||||
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]]);
|
||||
} finally {
|
||||
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 =
|
||||
@@ -445,14 +472,32 @@ describe('Edit history panel in a real browser', () => {
|
||||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||||
expect(note).toContain('No transcript found for this session yet');
|
||||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
// A remote host sends no hooks: no promise that the next prompt finds it.
|
||||
await page.evaluate((sid) => {
|
||||
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
|
||||
}, sessionA);
|
||||
try {
|
||||
await reload();
|
||||
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
|
||||
hasText: 'No transcript found on the remote host yet',
|
||||
});
|
||||
await remoteNote.waitFor();
|
||||
const text = (await remoteNote.textContent()) ?? '';
|
||||
expect(text).not.toContain('next prompt');
|
||||
expect(text).toContain('.bash_profile');
|
||||
} finally {
|
||||
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* @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 { exec, 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,
|
||||
buildRemoteEditHistoryCommand,
|
||||
buildRemoteClaudeTranscriptLocateCommand,
|
||||
isRemoteClaudeTranscriptPath,
|
||||
parseRemoteClaudeEditSources,
|
||||
parseRemoteClaudeTranscriptLocate,
|
||||
planRemoteEditSourceRounds,
|
||||
readRemoteEditSourceRounds,
|
||||
remoteEditSourcesTimeoutMs,
|
||||
remoteLocateClaudeTranscript,
|
||||
remoteReadClaudeEditSources,
|
||||
} from '../src/remote-files.js';
|
||||
import { readClaudeEditSources, type EditSourceRequest } from '../src/edit-history-local-source.js';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a read command the way a {@link readRemoteEditSourceRounds} runner does, here on `sh`. */
|
||||
async function runRead(command: string): Promise<Buffer | null> {
|
||||
const { code, stdout } = runSh(command);
|
||||
return code === 0 ? stdout : null;
|
||||
}
|
||||
|
||||
/** 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('takes the most recently written transcript of all ids, not the first id that has one', () => {
|
||||
// conv-1 lies in old/ (1000 s) and new/ (2000 s); solo is older than both, then newest.
|
||||
const solo = join(projects(), 'old', 'solo.jsonl');
|
||||
try {
|
||||
utimesSync(solo, 1_500, 1_500);
|
||||
expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
|
||||
expect(locate(['solo', 'conv-1'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
|
||||
expect(locate(['missing', 'solo'])).toBe(solo);
|
||||
// A respawn resumed the launch conversation after a /clear: the one written last wins.
|
||||
utimesSync(solo, 3_000, 3_000);
|
||||
expect(locate(['conv-1', 'solo'])).toBe(solo);
|
||||
} finally {
|
||||
// Older than conv-1, so the live tests below cannot pass by mtime alone.
|
||||
utimesSync(solo, 1_500, 1_500);
|
||||
}
|
||||
});
|
||||
|
||||
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('looks in an absolute CLAUDE_CONFIG_DIR instead of ~/.claude, as claude does; a relative one is ignored', () => {
|
||||
const cfg = join(home, 'alt-config');
|
||||
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
|
||||
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
|
||||
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(
|
||||
join(cfg, 'projects', 'w', 'alt-1.jsonl')
|
||||
);
|
||||
expect(locate(['conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(3);
|
||||
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: 'alt-config' })).toBe(
|
||||
join(projects(), 'new', 'conv-1.jsonl')
|
||||
);
|
||||
});
|
||||
|
||||
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. tmux sits in the temp
|
||||
// $HOME/.local/bin, which withRemoteTmuxPath() puts ahead of the system dirs, so a real
|
||||
// tmux on this machine is never asked.
|
||||
let bin: string;
|
||||
const pane = { socket: 'codeman-remote', sessionName: 'codeman-ssh-conv1abc' };
|
||||
beforeAll(() => {
|
||||
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-tmux-'));
|
||||
const localBin = join(home, '.local', 'bin');
|
||||
mkdirSync(localBin, { recursive: true });
|
||||
// Only the exact `=name:` target reaches the pane. Real tmux (3.4, measured) reads `=name`
|
||||
// without the colon as a window target too, so it also answers for a window of that name
|
||||
// or a session the name is a prefix of: the second pattern is that second case.
|
||||
writeFileSync(
|
||||
join(localBin, 'tmux'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'for a; do',
|
||||
' case "$a" in =codeman-ssh-conv1abc:|=codeman-ssh-conv1) echo 100; exit 0 ;; esac',
|
||||
'done',
|
||||
'echo "no such session" >&2',
|
||||
'exit 1',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf " 1 0\\n100 1\\n200 100\\n300 200\\n"\n');
|
||||
chmodSync(join(localBin, '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, even an older one', () => {
|
||||
// solo (1500 s) is older than conv-1 (2000 s): a /resume into an older conversation
|
||||
// that has not written yet still wins.
|
||||
session('{"pid":300,"sessionId":"solo","cwd":"/w","kind":"interactive"}');
|
||||
expect(locateIn(pane)).toBe(join(projects(), 'old', 'solo.jsonl'));
|
||||
});
|
||||
|
||||
it('finds the pane with tmux only under $HOME/.local/bin, on a PATH that does not list it', () => {
|
||||
// A host where tmux is a per-user install: sshd's non-interactive PATH has no such dir.
|
||||
const PATH = `${bin}:/usr/bin:/bin`;
|
||||
expect(PATH).not.toContain('.local/bin');
|
||||
expect(existsSync(join(bin, 'tmux'))).toBe(false);
|
||||
session('{"pid":300,"sessionId":"solo"}');
|
||||
expect(locate(['conv-1'], pane, { PATH })).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('never takes the pane of another tmux session whose name starts with this one', () => {
|
||||
// codeman-ssh-conv1 is gone, codeman-ssh-conv1abc is alive: its conversation is not ours.
|
||||
session('{"pid":300,"sessionId":"solo"}');
|
||||
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-conv1' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
|
||||
});
|
||||
|
||||
it('reads the live id from the sessions dir under CLAUDE_CONFIG_DIR', () => {
|
||||
const cfg = join(home, 'alt-config');
|
||||
mkdirSync(join(cfg, 'sessions'), { recursive: true });
|
||||
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
|
||||
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
|
||||
writeFileSync(join(cfg, 'projects', 'w', 'conv-1.jsonl'), 'c\n');
|
||||
writeFileSync(join(cfg, 'sessions', '300.json'), '{"pid":300,"sessionId":"alt-1"}');
|
||||
session('{"pid":300,"sessionId":"solo"}'); // ~/.claude's file is not claude's here
|
||||
expect(locate(['conv-1'], pane, { PATH: `${bin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: cfg })).toBe(
|
||||
join(cfg, 'projects', 'w', 'alt-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('planRemoteEditSourceRounds', () => {
|
||||
const agents = (...ids: string[]): EditSourceRequest[] =>
|
||||
ids.map((id) => ({ relPath: `subagents/agent-${id}.jsonl`, offset: 1 }));
|
||||
|
||||
it('keeps a read that fits one script in one round trip, with no name range', () => {
|
||||
const requests = [{ relPath: 'main', offset: 5 }, ...agents('b', 'a')];
|
||||
expect(planRemoteEditSourceRounds(requests, 2)).toEqual([{ requests, main: true, after: '', upTo: '' }]);
|
||||
expect(planRemoteEditSourceRounds([], 2)).toEqual([{ requests: [], main: true, after: '', upTo: '' }]);
|
||||
});
|
||||
|
||||
it('cuts more requests by name: ranges that join up, main in the first, every request once', () => {
|
||||
const main = { relPath: 'main', offset: 5 };
|
||||
const rounds = planRemoteEditSourceRounds([...agents('e', 'c'), main, ...agents('a', 'd', 'b')], 2);
|
||||
expect(rounds.map((r) => [r.main, r.after, r.upTo, r.requests.map((q) => q.relPath)])).toEqual([
|
||||
[true, '', 'agent-b.jsonl', ['main', 'subagents/agent-a.jsonl', 'subagents/agent-b.jsonl']],
|
||||
[false, 'agent-b.jsonl', 'agent-d.jsonl', ['subagents/agent-c.jsonl', 'subagents/agent-d.jsonl']],
|
||||
[false, 'agent-d.jsonl', '', ['subagents/agent-e.jsonl']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts only valid requests, the first per relPath', () => {
|
||||
const rounds = planRemoteEditSourceRounds(
|
||||
[
|
||||
...agents('a'),
|
||||
{ relPath: 'subagents/agent-a.jsonl', offset: 9 },
|
||||
{ relPath: '../../etc/passwd', offset: 0 },
|
||||
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
|
||||
],
|
||||
1
|
||||
);
|
||||
expect(rounds).toEqual([{ requests: agents('a'), main: true, after: '', upTo: '' }]);
|
||||
});
|
||||
|
||||
it('keeps every ssh line under the 128 KiB argv limit, longest names and offsets included', () => {
|
||||
// 255 bytes is the longest file name a filesystem allows; a request without `len` is the
|
||||
// longer one, because its `''` costs 13 bytes per quote once shellescaped twice.
|
||||
const requests: EditSourceRequest[] = Array.from({ length: 5000 }, (_, i) => ({
|
||||
relPath: `subagents/agent-${String(i).padStart(6, '0')}${'x'.repeat(237)}.jsonl`,
|
||||
offset: Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
const mainPath = `/${'p/'.repeat(2000)}conv-1.jsonl`;
|
||||
const line = (reqs: EditSourceRequest[], round?: { main: boolean; after: string; upTo: string }) =>
|
||||
Buffer.byteLength(
|
||||
buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(mainPath, reqs, 1000, { round }))
|
||||
);
|
||||
const ARGV_LIMIT = 128 * 1024;
|
||||
expect(line(requests)).toBeGreaterThan(ARGV_LIMIT);
|
||||
const rounds = planRemoteEditSourceRounds(requests);
|
||||
expect(rounds.length).toBeGreaterThan(1);
|
||||
for (const round of rounds) expect(line(round.requests, round)).toBeLessThan(ARGV_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
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('remoteEditSourcesTimeoutMs', () => {
|
||||
it('gives a read of megabytes its transfer time at 256 KiB/s on top of the flat 30 s', () => {
|
||||
expect(remoteEditSourcesTimeoutMs(0)).toBe(30_000);
|
||||
expect(remoteEditSourcesTimeoutMs(-5)).toBe(30_000);
|
||||
expect(remoteEditSourcesTimeoutMs(256 * 1024)).toBe(31_000);
|
||||
// a forward pass: 8 MiB budget + 4 MiB metas + 1 MiB header slack
|
||||
expect(remoteEditSourcesTimeoutMs(13 * 1024 * 1024)).toBe(82_000);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
// The same read cut into several round trips must come out the same.
|
||||
const split = await readRemoteEditSourceRounds(path, requests, budget, { onlyRequested }, runRead, ri(1, 3));
|
||||
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));
|
||||
expect(shape(split)).toEqual(shape(local));
|
||||
} catch {
|
||||
failures.push(`seed ${seed}: ${JSON.stringify({ requests, budget, onlyRequested })}`);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a read too large for one ssh line', () => {
|
||||
// `ssh` is a fake that runs the remote command on `sh`, so the whole ssh line really goes
|
||||
// through the local `sh -c` argv: what must not hit E2BIG.
|
||||
let bin: string;
|
||||
beforeAll(() => {
|
||||
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-read-'));
|
||||
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor arg; do :; done\nexec sh -c "$arg"\n');
|
||||
chmodSync(join(bin, 'ssh'), 0o755);
|
||||
});
|
||||
afterAll(() => rmSync(bin, { recursive: true, force: true }));
|
||||
|
||||
/** A runner that goes through the fake ssh, counting its round trips. */
|
||||
function overSsh(): { run: (command: string, maxBytes: number) => Promise<Buffer | null>; trips: () => number } {
|
||||
let trips = 0;
|
||||
return {
|
||||
trips: () => trips,
|
||||
run: async (command, maxBytes) => {
|
||||
trips++;
|
||||
try {
|
||||
return execFileSync('sh', ['-c', buildRemoteEditHistoryCommand(REMOTE, command)], {
|
||||
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
|
||||
cwd: home,
|
||||
maxBuffer: maxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { status?: number }).status === 3) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const shape = (r: Awaited<ReturnType<typeof readClaudeEditSources>>) =>
|
||||
r && {
|
||||
transcriptId: r.transcriptId,
|
||||
metas: r.metas,
|
||||
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
|
||||
};
|
||||
|
||||
describe('as a read', () => {
|
||||
const project = () => join(home, '.claude', 'projects', 'many');
|
||||
const main = () => join(project(), 'conv-many.jsonl');
|
||||
const sub = () => join(project(), 'conv-many', 'subagents');
|
||||
const id = (i: number) => `a${String(i).padStart(4, '0')}`;
|
||||
/** A cursor for each of 1500 subagents; only every 100th still has a file. */
|
||||
const requests: EditSourceRequest[] = [
|
||||
{ relPath: 'main', offset: 2 },
|
||||
...Array.from({ length: 1500 }, (_, i) => ({ relPath: `subagents/agent-${id(i)}.jsonl`, offset: 1 })),
|
||||
];
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(sub(), { recursive: true });
|
||||
writeFileSync(main(), 'main transcript\n');
|
||||
for (let i = 0; i < 1500; i += 100) writeFileSync(join(sub(), `agent-${id(i)}.jsonl`), `subagent ${i}\n`);
|
||||
// New files in the first, a middle and the last name range: each read once, with its meta.
|
||||
for (const name of ['agent-0first', 'agent-a0750new', 'agent-zlast']) {
|
||||
writeFileSync(join(sub(), `${name}.jsonl`), `${name}\n`);
|
||||
writeFileSync(join(sub(), `${name}.meta.json`), `{"description":"${name}"}`);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 40, 8 * 1024 * 1024])('reads what the local reader reads, budget %i', async (budget) => {
|
||||
const ssh = overSsh();
|
||||
const read = await readRemoteEditSourceRounds(main(), requests, budget, {}, ssh.run);
|
||||
expect(shape(read)).toEqual(shape(await readClaudeEditSources(main(), requests, budget)));
|
||||
expect(ssh.trips()).toBe(5);
|
||||
});
|
||||
|
||||
it('is null when the transcript goes away during the read', async () => {
|
||||
let trips = 0;
|
||||
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
|
||||
return ++trips === 2 ? null : runRead(command);
|
||||
});
|
||||
await expect(read).resolves.toBeNull();
|
||||
expect(trips).toBe(2);
|
||||
});
|
||||
|
||||
it('fails as a whole on broken output from a later round trip', async () => {
|
||||
let trips = 0;
|
||||
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
|
||||
return ++trips === 2 ? Buffer.from('\0other-conversation\0') : runRead(command);
|
||||
});
|
||||
await expect(read).rejects.toBeInstanceOf(RemoteFileAccessError);
|
||||
expect(trips).toBe(2);
|
||||
});
|
||||
|
||||
it('gives a later round trip the meta budget the earlier ones left', async () => {
|
||||
const commands: string[] = [];
|
||||
await readRemoteEditSourceRounds(
|
||||
main(),
|
||||
requests,
|
||||
1000,
|
||||
{},
|
||||
async (command) => {
|
||||
commands.push(command);
|
||||
return runRead(command);
|
||||
},
|
||||
900
|
||||
);
|
||||
const budgets = commands.map((c) => Number(/mbudget=(\d+)/.exec(c)?.[1]));
|
||||
expect(budgets).toHaveLength(2);
|
||||
// `agent-0first` sorts before every request, so its meta went with the first round trip.
|
||||
expect(budgets[0] - budgets[1]).toBe(
|
||||
Buffer.byteLength('{"description":"agent-0first"}') + Buffer.byteLength('{"description":"agent-a0750new"}')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('through the sync', () => {
|
||||
const project = () => join(home, '.claude', 'projects', 'long-history');
|
||||
const sub = () => join(project(), 'conv-old', 'subagents');
|
||||
const edit = (id: string, path: string) =>
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-10-15T20:00:00.000Z',
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
}) + '\n';
|
||||
|
||||
it('keeps a conversation with 1200 subagents available, and a /clear gets past them', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-edit-store-'));
|
||||
try {
|
||||
mkdirSync(sub(), { recursive: true });
|
||||
const oldMain = join(project(), 'conv-old.jsonl');
|
||||
const newMain = join(project(), 'conv-new.jsonl');
|
||||
writeFileSync(oldMain, '{}\n');
|
||||
// Ids as long as claude's own: with these, 1200 requests do not fit one ssh line.
|
||||
const name = (i: number) => `agent-a${String(i).padStart(16, '0')}`;
|
||||
for (let i = 0; i < 1200; i++) writeFileSync(join(sub(), `${name(i)}.jsonl`), '{}\n');
|
||||
|
||||
const ssh = overSsh();
|
||||
let path = oldMain;
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => path,
|
||||
readSources: (_session, mainPath, requests, budget, opts) =>
|
||||
readRemoteEditSourceRounds(mainPath, requests, budget, opts ?? {}, ssh.run),
|
||||
probe: async () => [],
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
deleteStore: async () => {},
|
||||
sweepStores: async () => 0,
|
||||
});
|
||||
const session: EditHistorySession = {
|
||||
id: 's1',
|
||||
mode: 'claude',
|
||||
remote: REMOTE,
|
||||
claudeSessionId: 'conv-old',
|
||||
};
|
||||
// The cursors of a history that was read before, one per file at its end.
|
||||
const store = await sync.getStore('s1');
|
||||
const seed = (source: string) =>
|
||||
store.apply({ transcriptId: 'conv-old', source, startOffset: 0, chunk: Buffer.from('{}\n'), size: 3 });
|
||||
seed('main');
|
||||
for (let i = 0; i < 1200; i++) seed(`subagents/${name(i)}.jsonl`);
|
||||
expect(store.knownSources()).toHaveLength(1201);
|
||||
|
||||
// Past the argv limit: one script would not even start.
|
||||
const all = store.knownSources().map((relPath) => ({ relPath, offset: 3 }));
|
||||
const line = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(oldMain, all, 1000));
|
||||
expect(() => execFileSync('sh', ['-c', line])).toThrow(/E2BIG/);
|
||||
|
||||
// A known file that grew in the last name range, a new one in the middle.
|
||||
writeFileSync(join(sub(), `${name(1199)}.jsonl`), `{}\n${edit('late', '/w/late.ts')}`);
|
||||
const fresh = `${name(600)}new`;
|
||||
writeFileSync(join(sub(), `${fresh}.jsonl`), edit('fresh', '/w/fresh.ts'));
|
||||
writeFileSync(join(sub(), `${fresh}.meta.json`), '{"description":"new one","agentType":"Explore"}');
|
||||
expect(await sync.sync(session)).toEqual({ state: 'ok' });
|
||||
expect(ssh.trips()).toBe(4);
|
||||
expect(
|
||||
store
|
||||
.list()
|
||||
.map((e) => e.path)
|
||||
.sort()
|
||||
).toEqual(['/w/fresh.ts', '/w/late.ts']);
|
||||
expect(store.getAgents()[fresh.slice('agent-'.length)]).toMatchObject({ description: 'new one' });
|
||||
expect(store.knownSources()).toHaveLength(1202); // no cursor lost
|
||||
|
||||
// /clear: the stored conversation is read to its end first, then the new one.
|
||||
writeFileSync(newMain, edit('after', '/w/after.ts'));
|
||||
path = newMain;
|
||||
expect(await sync.sync(session)).toEqual({ state: 'ok' });
|
||||
expect(store.getTranscriptId()).toBe('conv-new');
|
||||
expect(
|
||||
store
|
||||
.list()
|
||||
.map((e) => e.path)
|
||||
.sort()
|
||||
).toEqual(['/w/after.ts', '/w/fresh.ts', '/w/late.ts']);
|
||||
await store.remove();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a timeout ends ssh itself', () => {
|
||||
// Node's timeout signals the local `sh -c`. On dash that shell stays the parent of a plain
|
||||
// command, so only `exec` makes the signal reach ssh. The fake ssh records its pid and then
|
||||
// waits; the test sends the timeout's SIGTERM itself, once that pid is there, so a slow
|
||||
// machine cannot kill the fake before it wrote it.
|
||||
it('kills the ssh process, not only the shell around it', async () => {
|
||||
const bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-'));
|
||||
const pidFile = join(bin, 'pid');
|
||||
try {
|
||||
writeFileSync(
|
||||
join(bin, 'ssh'),
|
||||
`#!/bin/sh\necho $$ > '${pidFile}.tmp'\nmv '${pidFile}.tmp' '${pidFile}'\nexec sleep 30\n`
|
||||
);
|
||||
chmodSync(join(bin, 'ssh'), 0o755);
|
||||
const command = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeTranscriptLocateCommand(['conv-1']));
|
||||
expect(command).toMatch(/^exec ssh /);
|
||||
const child = exec(command, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
await expect.poll(() => existsSync(pidFile), { timeout: 10_000 }).toBe(true);
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'));
|
||||
child.kill();
|
||||
const alive = (): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
await expect.poll(alive, { timeout: 3000 }).toBe(false);
|
||||
} finally {
|
||||
if (alive()) process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
} finally {
|
||||
rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user