fix(edit-history): review round 1 — record-bound deletion, load-time bounds, older reads per source

- The history is deleted when the session's persisted record goes (forgetEditHistory):
  kill via cleanupSession (a pinned record is only demoted and keeps it), the
  persisted-only DELETE, and stale cleanup at startup. After remove() neither a sync in
  flight nor a later request writes the file again; writes are serialized.
- load() re-bounds a damaged file: cursors, pending Bash calls (MAX_PENDING_BASH) and
  subagent labels (MAX_AGENTS, oldest unused dropped first).
- Load older reads one block of every transcript, budget split between them, and only
  the files it asked for; a block short of budget reads its end. A line longer than the
  budget is skipped going back as well (floorMidLine).
- Concurrent first callers share one store; a forward request folded with an older one
  still reads forward.
- Images outside the workspace get no thumbnail (one attachment id per render); a click
  opens the shared preview, which registers once with notify: false.
- Docs: per-edit diff cap, deletion rule, load-time bounds, thumbnails, follow triggers.

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 14:30:05 +02:00
1 parent 95e2272f89
commit dcdeaab98d
17 files changed
+508 -158

No files matched your search

+1 -1
View File
@@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Edit history panel** (`showEditHistory`, per-device, default OFF; `src/edit-history-parse.ts`, `edit-history-store.ts`, `edit-history-local-source.ts`, `web/edit-history-sync.ts`, `routes/edit-history-routes.ts`, `edit-history-ui.js`): which files a LOCAL claude session edited (main agent and subagents, labelled), each edit's diff, and images a Bash call produced. Read from the session's transcripts by byte offset, never from git. `GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId` (unified diff), `POST …/edits/older`. ⚠️ **Reads only on request:** the panel sends `?sync=1` when it opens and, while it is open on that session, after an edit-capable `transcript:tool_end` or a `transcript:complete` (debounced). The server never syncs on its own: no hook, no timer, no second watcher; `server.ts` hands the reader to the routes and only calls `remove`/`flushAll` itself (wiring-guard test). The main transcript is the path the session's `TranscriptWatcher` follows (reported by the hooks), else `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's (`resolveClaudeTranscript`); a config dir set outside Codeman is found with the next hook, until then `unavailable` / `no-transcript`. ⚠️ The store holds every diff, secrets included: `dataPath('edit-history')/<id>.json`, 0600 via tmp + rename, capped per session (500 entries, 4 MiB diff text), deleted with the session, blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only (workspace `file-raw`; outside it the attachment route with `notify: false` and its extension allowlist); no route here serves a file. A Bash image counts only when the file's mtime lies in the call's window (±2 s) and no entry already covers that file version. Remote, Docker and non-claude sessions answer `unsupported`. Tests: `test/edit-history-*.test.ts`, `test/routes/edit-history-routes.test.ts`, `test/edit-history.browser.test.ts` (browser suite).
**Edit history panel** (`showEditHistory`, per-device, default OFF; `src/edit-history-parse.ts`, `edit-history-store.ts`, `edit-history-local-source.ts`, `web/edit-history-sync.ts`, `routes/edit-history-routes.ts`, `edit-history-ui.js`): which files a LOCAL claude session edited (main agent and subagents, labelled), each edit's diff, and images a Bash call produced. Read from the session's transcripts by byte offset, never from git. `GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId` (unified diff), `POST …/edits/older`. ⚠️ **Reads only on request:** the panel sends `?sync=1` when it opens and, while it is open on that session, after an edit-capable `transcript:tool_end` or a `transcript:complete` (debounced). The server never syncs on its own: no hook, no timer, no second watcher; `server.ts` hands the reader to the routes and only calls `remove` (via `forgetEditHistory`) and `flushAll` itself (wiring-guard test). The main transcript is the path the session's `TranscriptWatcher` follows (reported by the hooks), else `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's (`resolveClaudeTranscript`); a config dir set outside Codeman is found with the next hook, until then `unavailable` / `no-transcript`. ⚠️ The store holds every diff, secrets included: `dataPath('edit-history')/<id>.json`, 0600 via tmp + rename, capped per session (500 entries, 4 MiB diff text; damaged files are re-bounded on load), deleted with the session's persisted record (`forgetEditHistory`: kill, persisted-only DELETE, stale cleanup; a pinned killed session keeps it), blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only: thumbnails via `file-raw` inside the workspace; an image outside it has no thumbnail and opens in the file preview (attachment route, `notify: false`); no route here serves a file. A Bash image counts only when the file's mtime lies in the call's window (±2 s) and no entry already covers that file version. Remote, Docker and non-claude sessions answer `unsupported`. Tests: `test/edit-history-*.test.ts`, `test/routes/edit-history-routes.test.ts`, `test/edit-history.browser.test.ts` (browser suite).
**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)
+3 -3
View File
@@ -1089,11 +1089,11 @@ What the Edit history panel reads (Settings → Header & Panels → Header, per-
`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/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each hunk is capped at 64 KiB). `404` for an edit the session does not have.
`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 the transcripts and answers like the list.
`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.
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 with the session and never fed into `/api/v1/search`. No route here serves a file: the panel shows images through `file-raw` and the attachment routes.
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.
## Git worktrees of a case
+4 -4
View File
@@ -726,15 +726,15 @@ 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` (session deleted: `cleanupSession` with `killMux`) 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) or a `transcript:complete`, both events the existing `TranscriptWatcher` already broadcasts. 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) 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) or a `transcript:complete`, both events the existing `TranscriptWatcher` already broadcasts. 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. 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, 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.
⚠️ **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 (directory 0700, so an existing store never keeps wider bits) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text and paths), oldest dropped, also on load. Each hunk is capped at `MAX_HUNK_BYTES` (64 KiB), a pending Bash command at 8 KiB and agent meta strings at 1 KiB. The store is deleted with the session, 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 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`.
⚠️ **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 through the confined `file-raw` route inside the workspace, and outside it through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply) plus the attachment `/raw` route, one registration per path. 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. No route of this feature serves a file, least of all a transcript path.
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.
+13 -8
View File
@@ -262,7 +262,7 @@ header next to Attachments. It is not shown on phones.
Click it for a draggable window listing the active session's edits, newest first:
- One row per Edit, Write or MultiEdit, with `new` for a file the agent created, `edit` otherwise,
- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
@@ -271,16 +271,19 @@ Click it for a draggable window listing the active session's edits, newest first
- 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. Click one to open it in the preview.
- **Load older** reads further back in the conversation.
you count as well. Images outside the workspace 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.
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
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 edit 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.
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.
- **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
@@ -290,10 +293,12 @@ How it reads:
- **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
are dropped), never part of search, blocked from every file route, and deleted with the session.
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
delete it.
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
Viewer's route; one outside it is registered like a clicked path (no notification, same
file-type allowlist and sensitive-file blocklist). No new route serves a file.
Viewer's route. An image outside it opens like any clicked path outside the workspace (no
notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
`POST /api/sessions/:id/edits/older` (see the
+18 -4
View File
@@ -13,7 +13,9 @@
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
* report their size with an empty chunk.
* report their size with an empty chunk. A request with `len` is a block that must end at
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
* With `onlyRequested` ("older" reads) unrequested files are left out.
*
* Tests: `test/edit-history-local-source.test.ts`.
*
@@ -54,6 +56,11 @@ export interface EditSources {
metas: Record<string, string>;
}
export interface EditSourceReadOptions {
/** Read only the requested files; the others are not listed. */
onlyRequested?: boolean;
}
/** What a path turned out to be (for Bash image candidates). */
export interface PathProbe {
/** The path with symlinks resolved. */
@@ -135,7 +142,8 @@ export async function resolveClaudeTranscript(
export async function readClaudeEditSources(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
const transcriptId = basename(mainPath, '.jsonl');
@@ -170,11 +178,17 @@ export async function readClaudeEditSources(
continue;
}
const req = byPath.get(src.relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req) {
if (req?.len !== undefined) {
const blockStart = Math.min(req.offset, size);
const blockEnd = Math.min(req.offset + req.len, size);
len = Math.min(blockEnd - blockStart, remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, size);
len = Math.min(req.len ?? remaining, remaining, size - start);
len = Math.min(remaining, size - start);
} else {
len = Math.min(size, remaining);
start = size - len;
+145 -37
View File
@@ -14,16 +14,19 @@
*
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
* drops the cut first line and leaves `floor` there. A line longer than the budget is
* skipped instead of blocking the cursor.
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
* (`floorMidLine`).
*
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped.
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
* the file holds the cursors, at most {@link MAX_PENDING_BASH} unanswered Bash calls and at
* most {@link MAX_AGENTS} subagent labels.
*
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename (in a 0700
* dir), bounded, deleted with the session (`remove()`), never fed to `/api/search`, and
* blocked from the file routes by `isSensitivePath` (`web/sensitive-path.ts`).
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
* deleted with the session (`remove()`), never fed to `/api/search`, and blocked from the
* file routes by `isSensitivePath` (`web/sensitive-path.ts`).
*
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
*
@@ -37,6 +40,8 @@ import { join } from 'node:path';
import { dataPath } from './config/instance.js';
import {
MAX_HUNK_BYTES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
parseClaudeEditLines,
type EditRecord,
type EditTool,
@@ -49,6 +54,11 @@ export const MAX_ENTRIES = 500;
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
/** Longest string kept from a subagent's `meta.json` field. */
export const MAX_AGENT_META_CHARS = 1024;
/**
* Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
* {@link MAX_ENTRIES}, so besides the newest label there always is one.
*/
export const MAX_AGENTS = MAX_ENTRIES + 1;
/** Bytes read per file and pass (first read, forward step, "older" step). */
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
export const MAIN_SOURCE = 'main';
@@ -68,6 +78,8 @@ export interface SourceCursor {
floor: number;
/** The next forward read starts inside a line longer than the budget; skip to its end. */
midLine?: boolean;
/** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
floorMidLine?: boolean;
}
export interface AgentMeta {
@@ -138,12 +150,21 @@ function metaString(v: unknown): string | undefined {
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
export function parseAgentMeta(text: string): AgentMeta | null {
let raw: unknown;
try {
raw = JSON.parse(text);
return agentMetaFrom(JSON.parse(text));
} catch {
return null;
}
}
function isOffset(v: unknown): v is number {
return Number.isSafeInteger(v) && (v as number) >= 0;
}
// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
// loses the broken parts instead of failing every request or stalling a cursor.
function agentMetaFrom(raw: unknown): AgentMeta | null {
if (!isObj(raw)) return null;
const meta: AgentMeta = {
description: metaString(raw.description) ?? '',
@@ -156,6 +177,36 @@ export function parseAgentMeta(text: string): AgentMeta | null {
return meta;
}
function cursorFrom(raw: unknown): SourceCursor | null {
if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
if (raw.midLine === true) cursor.midLine = true;
if (raw.floorMidLine === true) cursor.floorMidLine = true;
return cursor;
}
function pendingBashFrom(raw: unknown): PendingBash | null {
if (!isObj(raw)) return null;
const { id, ts, command, cwd, agentId } = raw;
if (typeof id !== 'string' || typeof ts !== 'number' || typeof command !== 'string' || typeof cwd !== 'string') {
return null;
}
const pending: PendingBash = { id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
if (typeof agentId === 'string') pending.agentId = agentId;
return pending;
}
function isEntry(raw: unknown): raw is EditHistoryEntry {
return (
isObj(raw) &&
typeof raw.id === 'string' &&
typeof raw.path === 'string' &&
typeof raw.ts === 'number' &&
Array.isArray(raw.hunks) &&
raw.hunks.every((h) => isObj(h) && Array.isArray(h.lines))
);
}
export class EditHistoryStore {
readonly sessionId: string;
private readonly file: string;
@@ -168,6 +219,8 @@ export class EditHistoryStore {
/** Sum of {@link entryBytes} over `entries`. */
private bytes = 0;
private saveTimer: NodeJS.Timeout | null = null;
/** The last queued write; `remove()` waits for it. */
private writing: Promise<void> | null = null;
private removed = false;
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
@@ -189,17 +242,20 @@ export class EditHistoryStore {
if (data.version !== FILE_VERSION) return store;
const f = data as unknown as StoreFile;
store.transcriptId = typeof f.transcriptId === 'string' ? f.transcriptId : null;
for (const [k, c] of Object.entries(isObj(f.cursors) ? f.cursors : {})) {
if (isObj(c) && typeof c.offset === 'number' && typeof c.floor === 'number') store.cursors.set(k, c);
for (const [k, raw] of Object.entries(isObj(f.cursors) ? f.cursors : {})) {
const cursor = cursorFrom(raw);
if (cursor) store.cursors.set(k, cursor);
}
if (Array.isArray(f.pendingBash)) store.pendingBash = f.pendingBash;
for (const [k, a] of Object.entries(isObj(f.agents) ? f.agents : {})) store.agents.set(k, a);
if (Array.isArray(f.entries)) {
const loaded = f.entries.filter(
(e): e is EditHistoryEntry =>
isObj(e) && typeof e.id === 'string' && typeof e.path === 'string' && Array.isArray(e.hunks)
);
store.addRecords(loaded);
if (Array.isArray(f.pendingBash)) {
store.pendingBash = f.pendingBash
.map(pendingBashFrom)
.filter((p): p is PendingBash => p !== null)
.slice(-MAX_PENDING_BASH);
}
if (Array.isArray(f.entries)) store.addRecords(f.entries.filter(isEntry));
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
const meta = agentMetaFrom(raw);
if (meta) store.putAgent(k, meta);
}
return store;
}
@@ -243,9 +299,9 @@ export class EditHistoryStore {
/** Record a subagent's `meta.json`; ignored when it does not parse. */
setAgentMeta(agentId: string, metaText: string): void {
const meta = parseAgentMeta(metaText);
const meta = this.removed ? null : parseAgentMeta(metaText);
if (!meta) return;
this.agents.set(agentId, meta);
this.putAgent(agentId, meta);
this.scheduleSave();
}
@@ -284,6 +340,7 @@ export class EditHistoryStore {
/** Apply a forward chunk read at `nextRange`. Returns new entries and Bash image candidates. */
apply(input: ApplyInput): ApplyResult {
if (this.removed) return { added: 0, candidates: [] };
const { transcriptId, source, startOffset, chunk, size } = input;
if (transcriptId !== this.transcriptId) {
this.transcriptId = transcriptId;
@@ -303,8 +360,9 @@ export class EditHistoryStore {
if (cursor ? cursor.midLine : startOffset > 0) {
const nl = chunk.indexOf(NL);
if (nl === -1) {
// Still inside that line: move past it unless it is the line being written.
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
// Still inside that line: move past it unless it is the line being written. A first
// read began inside it, so the floor does too.
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
return { added: 0, candidates: [] };
}
from = nl + 1;
@@ -328,22 +386,35 @@ export class EditHistoryStore {
return { added, candidates: parsed.candidates };
}
/** Apply a chunk read at `olderRange`: it ends at the floor, a line boundary. */
/**
* Apply a chunk read at `olderRange`: it ends at the floor, which is a line start, or with
* `floorMidLine` lies inside a line longer than the budget (its part in this chunk is dropped).
*/
applyOlder(input: ApplyInput): ApplyResult {
const { transcriptId, source, startOffset, chunk } = input;
const cursor = this.cursors.get(source);
const cursor = this.removed ? undefined : this.cursors.get(source);
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
return { added: 0, candidates: [] };
}
const end = cursor.floorMidLine ? chunk.lastIndexOf(NL) + 1 : chunk.length;
let from = 0;
let insideLine = false;
if (startOffset > 0) {
const nl = chunk.indexOf(NL);
// No line start in the whole block: one oversized line, skip it.
from = nl === -1 ? chunk.length : nl + 1;
// The newline at `end - 1` closes a line that may have started before this chunk.
const nl = chunk.subarray(0, end).indexOf(NL);
insideLine = nl === -1 || nl === end - 1;
from = insideLine ? end : nl + 1;
}
if (insideLine) {
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
cursor.floor = startOffset;
cursor.floorMidLine = true;
} else {
cursor.floor = startOffset + from;
delete cursor.floorMidLine;
}
cursor.floor = startOffset + from;
// Calls whose results lie in already-read newer lines cannot be paired here.
const parsed = parseClaudeEditLines(chunk.subarray(from).toString('utf8'));
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
const added = this.addRecords(parsed.records);
this.scheduleSave();
return { added, candidates: parsed.candidates };
@@ -351,6 +422,7 @@ export class EditHistoryStore {
/** Add Bash images whose mtime was confirmed inside their call's window. */
addImageEntries(images: readonly ConfirmedImage[]): number {
if (this.removed) return 0;
const records: EditHistoryEntry[] = images.map((img) => {
const entry: EditHistoryEntry = {
id: `${img.toolUseId}:${img.path}`,
@@ -372,12 +444,39 @@ export class EditHistoryStore {
return added;
}
/** Write now (cancels a pending debounced write). */
/** Write now (cancels a pending debounced write). Writes run one after another. */
async flush(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
const run = (this.writing ?? Promise.resolve()).then(() => this.write());
this.writing = run.catch(() => undefined);
await run;
}
/**
* Delete the persisted file (session deleted) and forget the content. The store takes no
* changes afterwards, so a read still in flight neither writes nor refills it.
*/
async remove(): Promise<void> {
this.removed = true;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
this.entries.clear();
this.bytes = 0;
this.cursors.clear();
this.pendingBash = [];
this.agents.clear();
// A write already under way ends first (or sees `removed`), so it cannot rename its
// tmp file over the deleted store afterwards.
await this.writing;
await unlink(this.file).catch(() => undefined);
}
private async write(): Promise<void> {
if (this.removed) return;
const data: StoreFile = {
version: FILE_VERSION,
@@ -394,17 +493,24 @@ export class EditHistoryStore {
// makes it the store, so an existing store never keeps wider bits.
const tmp = `${this.file}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
if (this.removed) {
await unlink(tmp).catch(() => undefined);
return;
}
await rename(tmp, this.file);
}
/** Delete the persisted file (session deleted); later changes are no longer written. */
async remove(): Promise<void> {
this.removed = true;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
private putAgent(agentId: string, meta: AgentMeta): void {
this.agents.delete(agentId);
this.agents.set(agentId, meta);
if (this.agents.size <= MAX_AGENTS) return;
const used = new Set<string>();
for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
for (const id of this.agents.keys()) {
if (this.agents.size <= MAX_AGENTS) break;
// The newest label has no entries yet: they come with its transcript.
if (id !== agentId && !used.has(id)) this.agents.delete(id);
}
await unlink(this.file).catch(() => undefined);
}
private setCursor(
@@ -412,10 +518,12 @@ export class EditHistoryStore {
cursor: SourceCursor | undefined,
offset: number,
firstFloor: number,
midLine: boolean
midLine: boolean,
firstFloorMidLine = false
): void {
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
if (midLine) next.midLine = true;
if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
this.cursors.set(source, next);
}
+71 -29
View File
@@ -5,8 +5,15 @@
*
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
* looks at. One sync per session runs at a time; a request during a run is folded into one
* more run afterwards.
* looks at. One sync per session runs at a time; requests during a run are folded into one
* more forward read and/or one more older read afterwards.
*
* GONE. `remove()` (the session's record is deleted) marks the session gone before it deletes
* the file: a run in flight stops after its current read, its store no longer writes, and a
* later request reads nothing (`unavailable`, reason `removed`) instead of loading a new store.
*
* OLDER. "Load older" splits the budget evenly over the sources with older lines and reads
* only those blocks, so every source moves back with each click.
*
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
* `unsupported` and are never read.
@@ -20,7 +27,7 @@
*/
import type { SessionRemote } from '../types/session.js';
import type { EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
import type { ImageCandidate } from '../edit-history-parse.js';
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
import { getCli } from '../config/cli-registry/registry.js';
@@ -31,6 +38,8 @@ export const IMAGE_MTIME_SLACK_MS = 2000;
export const MAX_FORWARD_PASSES = 3;
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
export const NO_TRANSCRIPT = 'no-transcript';
/** `reason` once the session's history was deleted. */
export const REMOVED = 'removed';
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
@@ -53,7 +62,12 @@ 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>;
readSources(mainPath: string, requests: readonly EditSourceRequest[], budget: number): Promise<EditSources | null>;
readSources(
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts?: EditSourceReadOptions
): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
loadStore(sessionId: string): Promise<EditHistoryStore>;
budget?: number;
@@ -109,11 +123,18 @@ export function confirmImages(
return out;
}
interface SyncWant {
forward: boolean;
older: boolean;
}
interface SessionSlot {
store?: EditHistoryStore;
store?: Promise<EditHistoryStore>;
status: EditHistoryStatus;
running?: Promise<void>;
again?: { older: boolean };
again?: SyncWant;
/** The history was deleted: nothing reads for this session any more. */
gone?: boolean;
}
export class EditHistorySync {
@@ -124,10 +145,16 @@ export class EditHistorySync {
this.budget = deps.budget ?? READ_BUDGET_BYTES;
}
/** The session's store, loaded from disk on first use. */
async getStore(sessionId: string): Promise<EditHistoryStore> {
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
getStore(sessionId: string): Promise<EditHistoryStore> {
const slot = this.slot(sessionId);
if (!slot.store) slot.store = await this.deps.loadStore(sessionId);
if (!slot.store) {
const loading = this.deps.loadStore(sessionId);
slot.store = loading;
loading.catch(() => {
if (slot.store === loading) slot.store = undefined;
});
}
return slot.store;
}
@@ -142,18 +169,23 @@ export class EditHistorySync {
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
const why = unsupportedReason(session);
const slot = this.slot(session.id);
if (slot.gone) return this.getStatus(session.id);
if (why) {
slot.status = { state: 'unsupported', reason: why };
return this.getStatus(session.id);
}
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
if (slot.running) {
slot.again = { older: (slot.again?.older ?? false) || !!opts.older };
slot.again = {
forward: (slot.again?.forward ?? false) || want.forward,
older: (slot.again?.older ?? false) || want.older,
};
await slot.running;
// The queued run starts when the current one ends; wait for it too.
if (slot.running) await slot.running;
return this.getStatus(session.id);
}
slot.running = this.runLoop(session, !!opts.older);
slot.running = this.runLoop(session, slot, want);
try {
await slot.running;
} finally {
@@ -164,13 +196,19 @@ export class EditHistorySync {
/** Write every loaded store's pending changes (server shutdown). */
async flushAll(): Promise<void> {
await Promise.all([...this.slots.values()].map((slot) => slot.store?.flush()));
await Promise.all([...this.slots.values()].map(async (slot) => (await slot.store)?.flush()));
}
/** Drop a deleted session: forget it and delete its stored history. */
/**
* Delete a session's stored history (its record is gone). The session stays marked, so a
* run in flight or a late request cannot load and write a new store for it.
*/
async remove(sessionId: string): Promise<void> {
const slot = this.slot(sessionId);
slot.gone = true;
slot.again = undefined;
slot.status = { state: 'unavailable', reason: REMOVED };
const store = await this.getStore(sessionId);
this.slots.delete(sessionId);
await store.remove();
}
@@ -183,21 +221,21 @@ export class EditHistorySync {
return slot;
}
private async runLoop(session: EditHistorySession, older: boolean): Promise<void> {
const slot = this.slot(session.id);
let next: boolean | null = older;
while (next !== null) {
await this.runOnce(session, next);
next = slot.again ? slot.again.older : null;
private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> {
let next: SyncWant | undefined = want;
while (next && !slot.gone) {
if (next.forward) await this.runOnce(session, slot, false);
if (next.older && !slot.gone) await this.runOnce(session, slot, true);
next = slot.again;
slot.again = undefined;
}
}
private async runOnce(session: EditHistorySession, older: boolean): Promise<void> {
const slot = this.slot(session.id);
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
const store = await this.getStore(session.id);
let status: EditHistoryStatus;
try {
const path = await this.deps.transcriptPath(session);
const path = slot.gone ? null : await this.deps.transcriptPath(session);
let found: boolean;
if (!path) {
found = false;
@@ -205,16 +243,17 @@ export class EditHistorySync {
found = await this.readOlder(path, store);
} else {
found = true;
for (let pass = 0; pass < MAX_FORWARD_PASSES && found; pass++) {
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
const r = await this.readForward(path, store);
found = r.found;
if (!r.more) break;
}
}
slot.status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
} catch (err) {
slot.status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
}
if (!slot.gone) slot.status = status;
}
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
@@ -257,13 +296,16 @@ export class EditHistorySync {
private async readOlder(path: string, store: EditHistoryStore): Promise<boolean> {
if (!store.backfillTruncated) return true;
const sources = store.sourcesWithOlder();
if (sources.length === 0) return true;
const share = Math.max(1, Math.floor(this.budget / sources.length));
const requests: EditSourceRequest[] = [];
for (const relPath of store.sourcesWithOlder()) {
const range = store.olderRange(relPath, this.budget);
for (const relPath of sources) {
const range = store.olderRange(relPath, share);
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
}
if (requests.length === 0) return true;
const res = await this.deps.readSources(path, requests, this.budget);
const res = await this.deps.readSources(path, requests, this.budget, { onlyRequested: true });
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
const wanted = new Set(requests.map((r) => r.relPath));
+5
View File
@@ -19,6 +19,11 @@ export interface SessionPort {
*/
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
/**
* Delete a session's stored edit history once its record is gone. `cleanupSession` does it
* itself; this is for removing a record without a live session (DELETE route).
*/
forgetEditHistory(sessionId: string): void;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
persistSessionStateNow(session: Session): void;
+18 -41
View File
@@ -13,11 +13,12 @@
*
* - 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 shows a thumbnail and opens the shared file preview (panels-ui.js
* `openFilePreview`), as does a click on any path. Both go through the existing file routes only:
* inside the workspace the confined `file-raw`, outside it an id minted by the attachment route
* with `notify: false` (`_registerExternalPreview`, which enforces the attachment guard and its
* extension allowlist). A path from the transcript is never served by a route of its own.
* - 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
* 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
* never served by a route of its own.
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
*
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
@@ -26,7 +27,7 @@
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath, _registerExternalPreview)
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath)
* @dependency i18n.js (window.codemanT)
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
*/
@@ -302,32 +303,16 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Thumbnail source for an image entry, through the routes the file preview uses: inside the
* workspace the confined `file-raw` route (a string), outside it the attachment `/raw` route by
* an id the attachment route mints with `notify: false` (a promise, one per path and panel
* state, resolving to the URL or null when the attachment guard refuses the file). SVGs get none
* (both routes serve them as a download); clicking still opens the preview.
* Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file
* preview uses. An image outside the workspace gets none: showing it needs an attachment id
* (`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).
*/
_editHistoryThumbSrc(path, sid, h) {
if (!path || /\.svg$/i.test(path)) return null;
if (!this._isExternalPreviewPath?.(path, sid)) {
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
}
if (typeof this._registerExternalPreview !== 'function') return null;
if (!h.thumbs) h.thumbs = new Map();
if (!h.thumbs.has(path)) {
h.thumbs.set(
path,
this._registerExternalPreview(path, sid).then((r) =>
r?.attachmentId
? this._editHistoryUrl(
`/api/sessions/${encodeURIComponent(sid)}/attachments/${encodeURIComponent(r.attachmentId)}/raw`
)
: null
)
);
}
return h.thumbs.get(path);
_editHistoryThumbSrc(path, sid) {
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
},
_renderEditHistoryPanel() {
@@ -528,7 +513,7 @@ Object.assign(CodemanApp.prototype, {
open();
}
});
const src = this._editHistoryThumbSrc(e.path, h.sessionId, h);
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
if (src) {
const img = el('img', 'edit-history-thumb');
img.loading = 'lazy';
@@ -538,15 +523,7 @@ Object.assign(CodemanApp.prototype, {
open();
});
img.addEventListener('error', () => img.remove());
if (typeof src === 'string') {
img.src = src;
} else {
// A re-render meanwhile replaced this element; the new one reads the same promise.
src.then((url) => {
if (url) img.src = url;
else img.remove();
});
}
img.src = src;
out.push(img);
}
return out;
+2 -1
View File
@@ -1722,7 +1722,8 @@ export function registerSessionRoutes(
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
// below re-looks-up the record by id, so the returned SessionState is unused here.
findPersistedSessionOrFail(ctx.store, id, req);
ctx.store.demoteOrRemoveSession(id);
// The edit history goes with the record (same rule as `cleanupSession`).
if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id);
// Mirrors the broadcast at the tail of the live-session cleanup path
// (_doCleanupSession in server.ts) — without it, other open tabs keep
// showing the retired row until their next unrelated fetch.
+14 -8
View File
@@ -841,6 +841,7 @@ export class WebServer extends EventEmitter {
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
forgetEditHistory: this.forgetEditHistory.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this),
persistSessionStateNow: this._persistSessionStateNow.bind(this),
@@ -1576,13 +1577,6 @@ export class WebServer extends EventEmitter {
// Stop transcript watcher
this.stopTranscriptWatcher(sessionId);
// Edit history: a killed session takes its stored history (diffs can hold secrets) with it
if (killMux) {
void this.editHistorySync.remove(sessionId).catch((err) => {
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
});
}
// Stop and remove run summary tracker
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
@@ -1703,7 +1697,8 @@ export class WebServer extends EventEmitter {
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if (killMux) {
this.store.demoteOrRemoveSession(sessionId);
// The edit history goes with the record; a pinned session's demoted record keeps it.
if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved') this.forgetEditHistory(sessionId);
}
}
@@ -2509,10 +2504,21 @@ export class WebServer extends EventEmitter {
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
this.forgetEditHistory(s.id);
}
return result.count;
}
/**
* Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted
* live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale.
*/
private forgetEditHistory(sessionId: string): void {
void this.editHistorySync.remove(sessionId).catch((err) => {
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
});
}
/**
* Get lightweight state for SSE init - excludes full terminal buffers
* to prevent browser freezes. Terminal buffers are fetched on-demand.
+18
View File
@@ -120,6 +120,24 @@ describe('readClaudeEditSources', () => {
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
});
it('with onlyRequested, reads the requested files and nothing else', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
onlyRequested: true,
});
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-b2.jsonl', 1, 'BB'],
]);
});
it('reads the end of a block that does not fit the budget', async () => {
const path = put('p/sid.jsonl', '0123456789');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
});
});
describe('probeLocalPaths', () => {
+66 -1
View File
@@ -2,7 +2,7 @@
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
* Transcript bytes are built from JSONL lines shaped like real claude records.
*/
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -11,6 +11,7 @@ import {
entryBytes,
MAIN_SOURCE,
MAX_AGENT_META_CHARS,
MAX_AGENTS,
MAX_ENTRIES,
MAX_STORE_BYTES,
parseAgentMeta,
@@ -255,6 +256,23 @@ describe('EditHistoryStore: older blocks', () => {
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
});
it('skips a line longer than the budget going back and reaches the lines before it', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
const file = jsonl([...older, huge, ...newer]);
const budget = 4000;
syncForward(store, file, budget);
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
expect(store.list()).toHaveLength(15);
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
});
it('refuses an older chunk that does not end at the floor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
@@ -353,6 +371,17 @@ describe('EditHistoryStore: cap', () => {
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
expect(meta?.agentType).toBe('coding');
});
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
const agents = store.getAgents();
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
expect(agents.a0).toBeDefined(); // used by an entry
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
expect(agents.a1).toBeUndefined();
});
});
describe('EditHistoryStore: persistence', () => {
@@ -404,6 +433,33 @@ describe('EditHistoryStore: persistence', () => {
expect(loaded.get('nohunks')).toBeUndefined();
});
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {
main: { offset: 1.5, floor: 0 },
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
},
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
})
);
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
expect(loaded.list()).toEqual([]);
// The surviving pending call still pairs with its result, and nothing throws.
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('ignores a file of an unknown version', async () => {
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
@@ -436,6 +492,15 @@ describe('EditHistoryStore: persistence', () => {
await new Promise((r) => setTimeout(r, 40));
await store.flush();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
expect(store.list()).toEqual([]); // the content is forgotten too
});
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
const writing = store.flush();
await Promise.all([writing, store.remove()]);
expect(readdirSync(dir)).toEqual([]);
});
it('defaults to the data directory', () => {
+95 -6
View File
@@ -3,15 +3,21 @@
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import type { EditSourceRequest, EditSources, PathProbe } from '../src/edit-history-local-source.js';
import type {
EditSourceReadOptions,
EditSourceRequest,
EditSources,
PathProbe,
} from '../src/edit-history-local-source.js';
import {
confirmImages,
EditHistorySync,
NO_TRANSCRIPT,
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
unsupportedReason,
@@ -76,7 +82,8 @@ class FakeHost {
read = async (
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number
budget: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> => {
this.calls.push([...requests]);
this.paths.push(mainPath);
@@ -85,11 +92,16 @@ class FakeHost {
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
for (const [relPath, buf] of this.files) {
const req = requests.find((r) => r.relPath === relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req) {
if (req?.len !== undefined) {
const blockEnd = Math.min(req.offset + req.len, buf.length);
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, buf.length);
len = Math.min(req.len ?? remaining, remaining, buf.length - start);
len = Math.min(remaining, buf.length - start);
} else {
len = Math.min(buf.length, remaining);
start = buf.length - len;
@@ -115,7 +127,7 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget) => host.read(path, req, budget),
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
...over,
@@ -293,6 +305,83 @@ describe('EditHistorySync', () => {
await sync.remove('s1');
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
});
it('a deleted session stays deleted: neither a run in flight nor a later request writes it again', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
const removing = sync.remove('s1');
release();
await Promise.all([...runs, removing]);
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
expect(readdirSync(dir)).toEqual([]);
const reads = host.calls.length;
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
await new Promise((r) => setTimeout(r, 20));
expect(host.calls).toHaveLength(reads);
expect(readdirSync(dir)).toEqual([]);
expect((await sync.getStore('s1')).list()).toEqual([]);
});
it('concurrent first callers share one store', async () => {
const { sync } = setup();
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
expect(a).toBe(b);
});
it('a forward request queued together with an older one still reads forward', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
const res = await orig(...a); // the first run's bytes are taken before the append
await gate;
return res;
};
const first = sync.sync(local);
await new Promise((r) => setTimeout(r, 0));
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
const queued = [sync.sync(local), sync.sync(local, { older: true })];
release();
await Promise.all([first, ...queued]);
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
});
it('load older moves every source back, reading only the blocks it asked for', async () => {
const { host, sync } = setup({ budget: 2000 });
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (let i = 0; i < 10; i++) {
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
}
await sync.sync(local);
await sync.sync(local); // the subagent gets its first block once main is up to date
const store = await sync.getStore('s1');
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
const [main0, sub0] = floors();
expect(main0).toBeGreaterThan(0);
expect(sub0).toBeGreaterThan(0);
await sync.sync(local, { older: true });
const [main1, sub1] = floors();
expect(main1).toBeLessThan(main0);
expect(sub1).toBeLessThan(sub0);
expect(
host.calls
.at(-1)!
.map((r) => r.relPath)
.sort()
).toEqual(['main', 'subagents/agent-ab.jsonl']);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(20);
});
});
describe('confirmImages', () => {
+24 -15
View File
@@ -40,7 +40,7 @@ describe('Edit history panel in a real browser', () => {
let sessionA: string;
let sessionB: string;
const settingsPutStatuses: number[] = [];
/** Bodies of POST /api/sessions/:id/attachments (the external thumbnail registration). */
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
const attachmentPosts: unknown[] = [];
/** Every request to the mocked API: `METHOD url`. */
const calls: string[] = [];
@@ -253,8 +253,10 @@ describe('Edit history panel in a real browser', () => {
it('a path and an image open the shared file preview with the absolute path', async () => {
await page.evaluate(() => {
(window as any).__previewed = [];
(window as any).app.openFilePreview = (p: string, sid: string) => (window as any).__previewed.push([p, sid]);
const w = window as any;
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
});
await rows().nth(2).locator('.edit-history-path').click();
await page.locator('#editHistoryBody img.edit-history-thumb').click();
@@ -264,6 +266,10 @@ describe('Edit history panel in a real browser', () => {
]);
// Clicking the path never toggles the row's diff.
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
await page.evaluate(() => {
const w = window as any;
w.app.openFilePreview = w.__openFilePreview;
});
});
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
@@ -338,7 +344,7 @@ describe('Edit history panel in a real browser', () => {
expect(syncReads(sessionA)).toBe(before + 1);
});
it('an image outside the workspace gets its thumbnail through the attachment route, without a notification', async () => {
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
const s = state.get(sessionA)!;
const outside = join(ext, 'render.png');
state.set(sessionA, {
@@ -349,18 +355,21 @@ describe('Edit history panel in a real browser', () => {
],
});
await reload();
const thumb = page.locator('#editHistoryBody img.edit-history-thumb').first();
await page.waitForFunction(() =>
/\/attachments\/[^/]+\/raw$/.test(document.querySelector('img.edit-history-thumb')?.getAttribute('src') ?? '')
);
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
expect(await thumb.getAttribute('src')).toMatch(new RegExp(`^/api/sessions/${sessionA}/attachments/[^/]+/raw$`));
await page.waitForFunction(
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
);
// A re-read reuses the registration instead of minting another id.
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
await row.waitFor();
// No thumbnail for it (that would mint an attachment id per render), and no registration.
expect(
await page.evaluate(() =>
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
)
).not.toContainEqual(expect.stringContaining('render.png'));
await reload();
expect(attachmentPosts).toHaveLength(1);
expect(attachmentPosts).toEqual([]);
// A click opens the shared preview, which registers the file once, without a notification.
await row.click();
await page.waitForSelector('#filePreviewOverlay.visible');
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
await page.evaluate(() => (window as any).app.closeFilePreview());
state.set(sessionA, s);
});
+1
View File
@@ -64,6 +64,7 @@ export function createMockRouteContext(options?: {
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
forgetEditHistory: vi.fn(),
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
+10
View File
@@ -983,6 +983,16 @@ describe('session-routes', () => {
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
// Its stored edit history (diffs can hold secrets) goes with the record.
expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session');
});
it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never);
vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' });
expect(res.statusCode).toBe(200);
expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled();
});
it('404s a persisted-only session id the state store does not recognize either', async () => {