fix(edit-history): review round 2 — whole lines around skipped ones, full-budget reads, orphan sweep

- Forward: after a line longer than the budget, the next line is read from
  its start instead of being skipped with it when it crosses the chunk end.
- Backward: the line before a skipped long line is read whole; a floor that
  turned out to be a line start no longer stalls.
- A chunk the shared forward budget cut short is not applied (it read as one
  over-long line) but read in a later pass, so a first open of a long main
  transcript reaches the subagents in the same sync.
- Load older reads a full budget block of up to three transcripts per click,
  taking turns, instead of splitting one budget (a normal Write longer than
  the share was skipped for good).
- A subagent transcript that vanished loses its cursor, so Load older does not
  stay on forever.
- POST /api/cleanup-state forgets the history of every record it drops; a
  startup sweep deletes edit-history files no session or record owns and
  stale tmp files of other processes.
- load() rebuilds entries from known fields (string diff lines, per-edit cap
  re-applied, ids counted against the byte cap), bounds pending Bash fields
  and agent ids, and does not parse a file over 32 MiB.
- No re-read after Codeman's own keep-alive turn.
- Tests: regression tests for each of the above (they fail on the previous
  commit); the remove-during-write test now meets the write under way; the
  sync and route tests remove their stores before the temp dir, so no late
  save recreates it in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-23 15:00:43 +02:00
1 parent f5ce80dce2
commit 321268754a
15 files changed
+540 -89

No files matched your search

+3 -3
View File
@@ -1087,13 +1087,13 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay
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.
`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; 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, 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; 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` once the session's history was 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/: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.
`POST /api/sessions/:id/edits/older` reads the next older block of every transcript (main and subagents share the read budget) and answers like the list.
`POST /api/sessions/:id/edits/older` reads the next older block of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list.
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
## Git worktrees of a case
+3 -3
View File
@@ -726,13 +726,13 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
**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.
⚠️ **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) 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`.
⚠️ **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`.
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume) is read from its end with fresh cursors; the stored entries stay.
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first); a first read starts that far before the end, and "Load older" reads one older block of every transcript, the budget split between them. A line longer than a read's budget is skipped, going forward and going back. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run.
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first); a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run.
⚠️ **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 and paths), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8 KiB each and `MAX_AGENTS` (501) subagent labels of at most 1 KiB per string, and `load()` drops whatever in a damaged file breaks those bounds. 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`, and for every record `cleanupStaleSessions` drops at startup. After `remove()` nothing writes the file again, neither a sync in flight nor a later request (the session answers `unavailable` / `removed`). The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
⚠️ **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 at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each 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). 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 other processes. After `remove()` nothing writes the file again, neither a sync in flight nor a later request (the session answers `unavailable` / `removed`). 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.
+6 -6
View File
@@ -269,12 +269,12 @@ Click it for a draggable window listing the active session's edits, newest first
its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
their file, with the totals on the file's row.
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp` or `.avif` path named in the command or its output
counts when the file's modification time falls within that command's run. Images the agent sent
you count as well. Images outside the workspace show no thumbnail. Click one to open it in the
preview.
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or
its output counts when the file's modification time falls within that command's run. Images the
agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one
to open it in the preview.
- **Load older** reads further back in the conversation, in the main agent's transcript and the
subagents' alike.
subagents' alike (up to three transcripts per click; with more, they take turns).
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
@@ -294,7 +294,7 @@ How it reads:
`~/.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
are dropped), never part of search, blocked from every file route, and deleted when you delete
the session. A pinned session you kill keeps its record and its history until you unpin and
the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and
delete it.
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
Viewer's route. An image outside it opens like any clicked path outside the workspace (no