mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(edit-history): read remote (SSH) claude sessions, never waking the host
The edit history panel now covers remote claude sessions. The sync stays path-based; its deps get the session and server.ts routes them to the local reader or to two new ssh reads in remote-files.ts: - remoteLocateClaudeTranscript: one round trip; the conversation the remote pane's claude writes now (~/.claude/sessions/<pid>.json via the pane's process tree, since remote hosts send no hooks), else claudeSessionId, else the session id, newest $HOME/.claude/projects/*/<id>.jsonl. - remoteReadClaudeEditSources: one round trip per read pass, with the local reader's semantics (offset clamp, block end at offset+len, new files read their tail, shared main-first budget, onlyRequested, 8 KiB meta cap). So /clear catch-up, "Load older", cursors and the store LRU work remotely unchanged. Reads use buildRemoteFileCommand + runWithRemoteSshLimit only: a sleeping host is a connect timeout -> unavailable, stored entries kept. Tests: both scripts run on a real sh against a temp HOME; the read is compared byte for byte with readClaudeEditSources on 60 seeded random transcripts. A transitive import guard pins that the edit-history read path reaches neither remote-wake.ts nor session-routes.ts. Browser: a remote session lists images without thumbnails, a click previews. Docs updated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9eac5ecf01
commit
c4ba14235f
15 files changed
+968
-62
No files matched your search
@@ -1396,9 +1396,9 @@ The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-s
|
||||
|
||||
## Edit history
|
||||
|
||||
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
|
||||
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **claude session** made (local or remote SSH), main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). A remote session is read over ssh and never woken: a sleeping host answers `unavailable`. Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
|
||||
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
|
||||
|
||||
@@ -752,7 +752,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
### Edit history panel
|
||||
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
|
||||
@@ -762,11 +762,13 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path.
|
||||
|
||||
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
|
||||
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
|
||||
⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `~/.claude/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else `<claudeSessionId>` then `<id>`, each the newest `$HOME/.claude/projects/*/<id>.jsonl`; the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`) with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-<id>.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. Only the host's default `$HOME/.claude` is searched. ⚠️ **Never wakes:** the reads go through `buildRemoteFileCommand` + `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only.
|
||||
|
||||
### Config backups
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -306,20 +306,26 @@ Click it for a draggable window listing the active session's edits, newest first
|
||||
first catches up on the ones it had not read yet. **Load older** then goes back within the new
|
||||
conversation.
|
||||
|
||||
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
|
||||
It works for Claude sessions, local and remote (SSH). Docker and other CLIs' sessions say so instead.
|
||||
A remote session's images show no thumbnail (each would be one more SSH read); click one to
|
||||
preview it. A remote host that is asleep is never woken for the history: the window shows the
|
||||
stored edits with the SSH error on top.
|
||||
|
||||
How it reads:
|
||||
|
||||
- **Only while you look.** The history is read from the session's Claude transcript when you open
|
||||
the window, and again after each tool call that can change files and at the end of each turn,
|
||||
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
|
||||
the background for sessions nobody looks at.
|
||||
while it stays open on that session (a remote session at the end of each turn only: its host
|
||||
sends no tool events). A closed window reads nothing, and Codeman does no work in the
|
||||
background for sessions nobody looks at.
|
||||
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
|
||||
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
|
||||
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
|
||||
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
|
||||
is only known after the next prompt; until then the window says "No transcript found for this
|
||||
session yet".
|
||||
session yet". A remote session's transcript is looked up on its host over SSH: the
|
||||
conversation its Claude writes now, else the session's own, under the remote user's
|
||||
`~/.claude` (a `CLAUDE_CONFIG_DIR` on the remote host is not followed).
|
||||
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
|
||||
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
|
||||
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
|
||||
|
||||
Reference in new issue
Block a user