mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge pull request #693: feat: edit history panel for local claude sessions (files edited, diffs, generated images)
This commit is contained in:
commit
f36bea86c7
39 files changed
+6183
-8
No files matched your search
@@ -242,6 +242,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
|
||||
|
||||
**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)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
@@ -50,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
|
||||
@@ -1383,6 +1383,19 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay
|
||||
|
||||
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
`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/: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, within the current conversation, 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; 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
|
||||
|
||||
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
|
||||
|
||||
@@ -750,6 +750,24 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
### 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.
|
||||
|
||||
⚠️ **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): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending.
|
||||
|
||||
⚠️ **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), plus up to 8 KiB of `meta.json` per subagent seen for the first time; 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. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write.
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
### Config backups
|
||||
|
||||
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
|
||||
|
||||
@@ -62,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
@@ -106,6 +106,12 @@ nothing waits for a login, and that is a default, never an override: your own `G
|
||||
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
|
||||
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
|
||||
|
||||
**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
|
||||
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
|
||||
[Working With Files](Working-With-Files#edit-history).
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
|
||||
@@ -280,6 +280,60 @@ data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-d
|
||||
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Edit history
|
||||
|
||||
The Git panel shows what is different now. The edit history shows what the agent did, one edit
|
||||
at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
|
||||
Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
|
||||
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 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
|
||||
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`, `.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 (up to three transcripts per click; with more, they take turns).
|
||||
- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window
|
||||
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.
|
||||
|
||||
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.
|
||||
- **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".
|
||||
- **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 when you delete
|
||||
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
|
||||
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
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
|
||||
* transcript and its subagent transcripts, each from a byte offset.
|
||||
*
|
||||
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
|
||||
* hooks report, so a session is read from wherever its claude really writes. Before the first
|
||||
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
|
||||
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
|
||||
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
|
||||
* guessed; it is found with the next hook. Subagents live in
|
||||
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
|
||||
*
|
||||
* 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. 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`.
|
||||
*
|
||||
* @module edit-history-local-source
|
||||
*/
|
||||
|
||||
import { open, readdir, realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join } from 'node:path';
|
||||
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
|
||||
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
|
||||
const META_MAX_BYTES = 8192;
|
||||
|
||||
/** One file to read from where the last read stopped (`len` only for an "older" block). */
|
||||
export interface EditSourceRequest {
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
relPath: string;
|
||||
offset: number;
|
||||
len?: number;
|
||||
}
|
||||
|
||||
export interface EditSourceFile {
|
||||
relPath: string;
|
||||
/** The file's size when it was read. */
|
||||
size: number;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
}
|
||||
|
||||
export interface EditSources {
|
||||
/** The conversation id: the main transcript's file name without `.jsonl`. */
|
||||
transcriptId: string;
|
||||
files: EditSourceFile[];
|
||||
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
|
||||
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. */
|
||||
realPath: string;
|
||||
kind: 'file' | 'directory' | 'other';
|
||||
/** Size in bytes (0 for anything that is not a regular file). */
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
|
||||
if (len <= 0) return Buffer.alloc(0);
|
||||
const fh = await open(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} catch {
|
||||
return Buffer.alloc(0);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSize(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? s.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
|
||||
export async function findClaudeTranscript(
|
||||
ids: readonly string[],
|
||||
projectsDir: string = claudeProjectsDir()
|
||||
): Promise<string | null> {
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const id of new Set(ids)) {
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const dir of dirs) {
|
||||
const path = join(projectsDir, dir, `${id}.jsonl`);
|
||||
try {
|
||||
const s = await stat(path);
|
||||
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
|
||||
} catch {
|
||||
// not in this project dir
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
|
||||
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
|
||||
* override; the server's config dir when unset). Null when neither finds one.
|
||||
*/
|
||||
export async function resolveClaudeTranscript(
|
||||
watcherPath: string | null | undefined,
|
||||
ids: readonly string[],
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
|
||||
* is not an absolute `<id>.jsonl` path or the file does not exist.
|
||||
*/
|
||||
export async function readClaudeEditSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
|
||||
const transcriptId = basename(mainPath, '.jsonl');
|
||||
if (!ID_PATTERN.test(transcriptId)) return null;
|
||||
|
||||
const byPath = new Map<string, EditSourceRequest>();
|
||||
for (const r of requests) {
|
||||
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
|
||||
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
|
||||
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
|
||||
byPath.set(r.relPath, r);
|
||||
}
|
||||
|
||||
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
|
||||
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
|
||||
try {
|
||||
for (const name of (await readdir(subDir)).sort()) {
|
||||
const m = AGENT_FILE_PATTERN.exec(name);
|
||||
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
|
||||
}
|
||||
} catch {
|
||||
// no subagents yet
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, Math.floor(budgetBytes));
|
||||
const files: EditSourceFile[] = [];
|
||||
const metas: Record<string, string> = {};
|
||||
for (const src of sources) {
|
||||
const size = await fileSize(src.path);
|
||||
if (size === null) {
|
||||
if (src.relPath === 'main') return null;
|
||||
continue;
|
||||
}
|
||||
const req = byPath.get(src.relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
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(remaining, size - start);
|
||||
} else {
|
||||
len = Math.min(size, remaining);
|
||||
start = size - len;
|
||||
}
|
||||
const chunk = await readRange(src.path, start, len);
|
||||
remaining -= chunk.length;
|
||||
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
|
||||
if (!req && src.agentId) {
|
||||
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
|
||||
const metaSize = await fileSize(metaPath);
|
||||
if (metaSize !== null) {
|
||||
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { transcriptId, files, metas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
|
||||
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
|
||||
*
|
||||
* The paths come from Bash output, so the agent chose them: each one first goes through the
|
||||
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
|
||||
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
|
||||
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
|
||||
* Sequential, so one sync holds at most one threadpool worker.
|
||||
*/
|
||||
export async function probeLocalPaths(
|
||||
paths: readonly string[],
|
||||
home: string = homedir(),
|
||||
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
|
||||
): Promise<Array<PathProbe | null>> {
|
||||
const out: Array<PathProbe | null> = [];
|
||||
for (const p of paths) {
|
||||
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
||||
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
|
||||
out.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const real = await realpath(path);
|
||||
const s = await stat(real);
|
||||
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
|
||||
} catch {
|
||||
out.push(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
|
||||
* edited (with their diff) and the images its Bash calls may have produced.
|
||||
*
|
||||
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
|
||||
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
|
||||
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
|
||||
*
|
||||
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
|
||||
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
|
||||
* start and end time. Whether a candidate file was actually produced by the call (its
|
||||
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
|
||||
* the remote host — this module does no IO.
|
||||
*
|
||||
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
|
||||
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
|
||||
* into the next call.
|
||||
*
|
||||
* Tests: `test/edit-history-parse.test.ts`.
|
||||
*
|
||||
* @module edit-history-parse
|
||||
*/
|
||||
|
||||
import { posix } from 'node:path';
|
||||
|
||||
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
|
||||
export const MAX_HUNK_BYTES = 64 * 1024;
|
||||
/** Image paths kept per Bash call. */
|
||||
export const MAX_IMAGE_CANDIDATES = 20;
|
||||
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
|
||||
export const MAX_PENDING_BASH = 200;
|
||||
/**
|
||||
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
|
||||
* this bounds it; an image path past this point of a command is still found in the output.
|
||||
*/
|
||||
export const MAX_PENDING_COMMAND_CHARS = 8192;
|
||||
/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
|
||||
export const MAX_PENDING_FIELD_CHARS = 4096;
|
||||
|
||||
export type EditKind = 'code' | 'image';
|
||||
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
|
||||
export type EditTool = 'Edit' | 'Write' | 'edit';
|
||||
|
||||
export interface EditHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface EditRecord {
|
||||
/** The `tool_use_id` of the edit — unique per edit, used to deduplicate. */
|
||||
id: string;
|
||||
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
|
||||
ts: number;
|
||||
path: string;
|
||||
kind: EditKind;
|
||||
tool: EditTool;
|
||||
/** A `Write` that created the file. */
|
||||
created: boolean;
|
||||
/** Counted over the whole patch, also when `hunks` was cut. */
|
||||
added: number;
|
||||
removed: number;
|
||||
hunks: EditHunk[];
|
||||
truncated: boolean;
|
||||
/** Set for lines of a subagent transcript. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PendingBash {
|
||||
id: string;
|
||||
ts: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface ImageCandidate {
|
||||
toolUseId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
paths: string[];
|
||||
agentId?: string;
|
||||
/**
|
||||
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
|
||||
* applies; the file must still exist, and a version already listed is not listed again.
|
||||
*/
|
||||
sent?: boolean;
|
||||
}
|
||||
|
||||
export interface EditParseResult {
|
||||
records: EditRecord[];
|
||||
candidates: ImageCandidate[];
|
||||
pending: PendingBash[];
|
||||
}
|
||||
|
||||
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
|
||||
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
|
||||
// After the extension: no further name character, and no `.ext` (a.png.bak).
|
||||
const EXT_END = '(?![\\w-])(?!\\.\\w)';
|
||||
// Characters that end an unquoted path token.
|
||||
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`) — may contain spaces up to the extension.
|
||||
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
|
||||
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
|
||||
// timestamped name (`plot-12:30.png`) is still found.
|
||||
`file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 2./3. Quoted paths, spaces allowed.
|
||||
`"([^"\\n]*\\.${IMAGE_EXT})"`,
|
||||
`'([^'\\n]*\\.${IMAGE_EXT})'`,
|
||||
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
|
||||
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
|
||||
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
|
||||
// some characters let a match start at every other character of a long token
|
||||
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
|
||||
// length. The name before the extension must not be empty.
|
||||
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
].join('|'),
|
||||
'gi'
|
||||
);
|
||||
|
||||
/** `image` for the image extensions the panel can thumbnail, else `code`. */
|
||||
export function classifyEditPath(path: string): EditKind {
|
||||
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-looking paths in a Bash call's command and output, in order of appearance,
|
||||
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
|
||||
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
|
||||
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
|
||||
* skipped.
|
||||
*/
|
||||
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
|
||||
const text = [command, stdout, stderr].join('\n');
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of text.matchAll(PATH_TOKEN_RE)) {
|
||||
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
|
||||
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
|
||||
let resolved: string;
|
||||
if (raw.startsWith('/')) resolved = posix.normalize(raw);
|
||||
else if (raw.startsWith('~/')) resolved = raw;
|
||||
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
|
||||
else continue;
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
out.push(resolved);
|
||||
if (out.length >= MAX_IMAGE_CANDIDATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
|
||||
* (`truncated`). Also applied to hunks loaded from a store file.
|
||||
*/
|
||||
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
|
||||
const hunks: EditHunk[] = [];
|
||||
let budget = MAX_HUNK_BYTES;
|
||||
let truncated = false;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
if (truncated) break;
|
||||
const kept: string[] = [];
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
const cost = Buffer.byteLength(l) + 3;
|
||||
if (cost > budget) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
budget -= cost;
|
||||
kept.push(l);
|
||||
}
|
||||
hunks.push({
|
||||
oldStart: Number(h.oldStart) || 0,
|
||||
oldLines: Number(h.oldLines) || 0,
|
||||
newStart: Number(h.newStart) || 0,
|
||||
newLines: Number(h.newLines) || 0,
|
||||
lines: kept,
|
||||
});
|
||||
}
|
||||
return { hunks, truncated };
|
||||
}
|
||||
|
||||
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
if (l.startsWith('+')) added++;
|
||||
else if (l.startsWith('-')) removed++;
|
||||
}
|
||||
}
|
||||
return { ...capHunks(patch), added, removed };
|
||||
}
|
||||
|
||||
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
|
||||
function createdFileHunk(content: string): Record<string, unknown> {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
|
||||
}
|
||||
|
||||
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
|
||||
if (!isRecord(result)) return null;
|
||||
const { filePath, structuredPatch } = result;
|
||||
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
|
||||
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
|
||||
const kind = classifyEditPath(filePath);
|
||||
// A Write that created a file reports an empty patch; its diff is the whole content.
|
||||
const patch =
|
||||
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
|
||||
? [createdFileHunk(result.content)]
|
||||
: structuredPatch;
|
||||
const { hunks, added, removed, truncated } = toHunks(patch);
|
||||
const record: EditRecord = {
|
||||
id,
|
||||
ts,
|
||||
path: filePath,
|
||||
kind,
|
||||
tool,
|
||||
created: result.type === 'create',
|
||||
added,
|
||||
removed,
|
||||
hunks,
|
||||
truncated,
|
||||
};
|
||||
if (agentId) record.agentId = agentId;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
|
||||
* files claude delivered to the user, which is how a session shows generated images.
|
||||
*/
|
||||
function sentImagePaths(result: unknown): string[] {
|
||||
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
|
||||
const out: string[] = [];
|
||||
for (const a of result.attachments) {
|
||||
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
|
||||
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
|
||||
function blockText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transcript lines into edit records and Bash image candidates.
|
||||
*
|
||||
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
|
||||
* inside it) and anything else that does not parse is skipped.
|
||||
* @param pending unanswered Bash calls from the previous chunk.
|
||||
*/
|
||||
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
|
||||
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
|
||||
const records: EditRecord[] = [];
|
||||
const candidates: ImageCandidate[] = [];
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(rec)) continue;
|
||||
const message = rec.message;
|
||||
const content = isRecord(message) ? message.content : undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
|
||||
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
|
||||
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
|
||||
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
|
||||
|
||||
if (rec.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
|
||||
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
|
||||
const command = block.input.command;
|
||||
if (typeof command !== 'string') continue;
|
||||
if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
|
||||
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) entry.agentId = agentId;
|
||||
open.delete(block.id);
|
||||
open.set(block.id, entry);
|
||||
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rec.type !== 'user') continue;
|
||||
const results = content.filter(
|
||||
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
|
||||
);
|
||||
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
|
||||
const single = results.length === 1;
|
||||
for (const block of results) {
|
||||
const id = block.tool_use_id as string;
|
||||
if (single) {
|
||||
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
|
||||
if (edit) records.push(edit);
|
||||
}
|
||||
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
|
||||
if (sent.length > 0) {
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
|
||||
if (agentId) candidate.agentId = agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
const call = open.get(id);
|
||||
if (!call) continue;
|
||||
open.delete(id);
|
||||
const r = single ? rec.toolUseResult : undefined;
|
||||
let stdout: string;
|
||||
let stderr = '';
|
||||
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
|
||||
stdout = typeof r.stdout === 'string' ? r.stdout : '';
|
||||
stderr = typeof r.stderr === 'string' ? r.stderr : '';
|
||||
} else {
|
||||
stdout = blockText(block.content);
|
||||
}
|
||||
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
|
||||
if (paths.length === 0) continue;
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
|
||||
if (call.agentId) candidate.agentId = call.agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return { records, candidates, pending: [...open.values()] };
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
|
||||
* session, plus where in each transcript file reading stopped.
|
||||
*
|
||||
* Sources are the main transcript (`main`) and each subagent transcript
|
||||
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
|
||||
* - `offset` — the next byte to read forward; always a line boundary.
|
||||
* - `floor` — the lowest byte already parsed; > 0 while older lines are unread
|
||||
* ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
|
||||
* The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
|
||||
* current conversation, a file without one from `budget` bytes before its end, and hands the
|
||||
* bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so
|
||||
* multibyte characters split by a chunk boundary are never decoded half.
|
||||
*
|
||||
* 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, reading forward (`midLine`) and backward
|
||||
* (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
|
||||
* That needs every chunk to be a full budget unless it reaches the file's end (forward) or
|
||||
* start (older): a chunk with no line end then means one line longer than the budget. The
|
||||
* caller does not hand over a chunk that a budget shared by several files cut short.
|
||||
*
|
||||
* 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. Besides them
|
||||
* the file holds one cursor per transcript file of the current conversation, 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, bounded,
|
||||
* deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
|
||||
* never fed to `/api/search`, and blocked from the file routes by `isSensitivePath`
|
||||
* (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
|
||||
* {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
|
||||
*
|
||||
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
|
||||
*
|
||||
* Tests: `test/edit-history-store.test.ts`.
|
||||
*
|
||||
* @module edit-history-store
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
capHunks,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
type EditRecord,
|
||||
type EditTool,
|
||||
type ImageCandidate,
|
||||
type PendingBash,
|
||||
} from './edit-history-parse.js';
|
||||
|
||||
export const MAX_ENTRIES = 500;
|
||||
/** Diff text (and paths) kept per session; see {@link entryBytes}. */
|
||||
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;
|
||||
/**
|
||||
* A store file larger than this is not parsed (starts empty). One written within the caps
|
||||
* above stays far below it; JSON escaping at most doubles ordinary text.
|
||||
*/
|
||||
export const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
export const MAIN_SOURCE = 'main';
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
const FILE_VERSION = 1;
|
||||
const NL = 0x0a;
|
||||
/** A transcript id as the reader takes it (`<id>.jsonl`): no path separators. */
|
||||
const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/;
|
||||
/**
|
||||
* Names this process's tmp files (`<id>.json.<tag>.tmp`); any other tmp file is left over from
|
||||
* an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1).
|
||||
*/
|
||||
export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
|
||||
/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
|
||||
export type EditHistoryTool = EditTool | 'bash' | 'sent';
|
||||
|
||||
export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> {
|
||||
tool: EditHistoryTool;
|
||||
}
|
||||
|
||||
export interface SourceCursor {
|
||||
offset: number;
|
||||
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 {
|
||||
description: string;
|
||||
agentType: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
}
|
||||
|
||||
export interface ByteRange {
|
||||
offset: number;
|
||||
len: number;
|
||||
}
|
||||
|
||||
export interface ApplyInput {
|
||||
transcriptId: string;
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
source: string;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
/** Size of the file when the chunk was read. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
added: number;
|
||||
candidates: ImageCandidate[];
|
||||
}
|
||||
|
||||
export interface ConfirmedImage {
|
||||
toolUseId: string;
|
||||
path: string;
|
||||
ts: number;
|
||||
agentId?: string;
|
||||
tool?: 'bash' | 'sent';
|
||||
}
|
||||
|
||||
export interface EditHistoryStoreOptions {
|
||||
/** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */
|
||||
dir?: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface StoreFile {
|
||||
version: number;
|
||||
sessionId: string;
|
||||
transcriptId: string | null;
|
||||
cursors: Record<string, SourceCursor>;
|
||||
pendingBash: PendingBash[];
|
||||
agents: Record<string, AgentMeta>;
|
||||
entries: EditHistoryEntry[];
|
||||
}
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
|
||||
export function entryBytes(e: EditHistoryEntry): number {
|
||||
let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
|
||||
for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function metaString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
|
||||
}
|
||||
|
||||
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
|
||||
export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
try {
|
||||
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) ?? '',
|
||||
agentType: metaString(raw.agentType) ?? '',
|
||||
};
|
||||
const worktreePath = metaString(raw.worktreePath);
|
||||
const worktreeBranch = metaString(raw.worktreeBranch);
|
||||
if (worktreePath) meta.worktreePath = worktreePath;
|
||||
if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
|
||||
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;
|
||||
}
|
||||
|
||||
const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS;
|
||||
|
||||
function pendingBashFrom(raw: unknown): PendingBash | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const { id, ts, command, cwd, agentId } = raw;
|
||||
if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
|
||||
if (agentId !== undefined && !loadedField(agentId)) return null;
|
||||
const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) pending.agentId = agentId;
|
||||
return pending;
|
||||
}
|
||||
|
||||
const ENTRY_KINDS = new Set(['code', 'image']);
|
||||
const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
|
||||
const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
|
||||
|
||||
/**
|
||||
* A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
|
||||
* dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
|
||||
*/
|
||||
function entryFrom(raw: unknown): EditHistoryEntry | null {
|
||||
if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
|
||||
if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw.hunks)) return null;
|
||||
const { hunks, truncated } = capHunks(raw.hunks);
|
||||
const entry: EditHistoryEntry = {
|
||||
id: raw.id,
|
||||
ts: raw.ts as number,
|
||||
path: raw.path,
|
||||
kind: raw.kind as EditHistoryEntry['kind'],
|
||||
tool: raw.tool as EditHistoryTool,
|
||||
created: raw.created === true,
|
||||
added: countFrom(raw.added),
|
||||
removed: countFrom(raw.removed),
|
||||
hunks,
|
||||
truncated: truncated || raw.truncated === true,
|
||||
};
|
||||
if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export class EditHistoryStore {
|
||||
readonly sessionId: string;
|
||||
private readonly file: string;
|
||||
private readonly debounceMs: number;
|
||||
private transcriptId: string | null = null;
|
||||
private cursors = new Map<string, SourceCursor>();
|
||||
private pendingBash: PendingBash[] = [];
|
||||
private agents = new Map<string, AgentMeta>();
|
||||
private entries = new Map<string, EditHistoryEntry>();
|
||||
/** 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 = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
|
||||
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store with the persisted state of `sessionId`; empty when the file is missing,
|
||||
* unreadable or larger than {@link MAX_FILE_BYTES}.
|
||||
*/
|
||||
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
|
||||
const store = new EditHistoryStore(sessionId, opts);
|
||||
let data: unknown;
|
||||
try {
|
||||
if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
|
||||
data = JSON.parse(await readFile(store.file, 'utf-8'));
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
if (!isObj(data)) return store;
|
||||
if (data.version !== FILE_VERSION) return store;
|
||||
const f = data as unknown as StoreFile;
|
||||
// The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts.
|
||||
store.transcriptId =
|
||||
typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null;
|
||||
// Cursors are offsets into that conversation's files; without it they mean nothing.
|
||||
const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {};
|
||||
for (const [k, raw] of Object.entries(cursors)) {
|
||||
const cursor = cursorFrom(raw);
|
||||
if (cursor) store.cursors.set(k, cursor);
|
||||
}
|
||||
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.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
|
||||
}
|
||||
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
|
||||
const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
|
||||
if (meta) store.putAgent(k, meta);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of every session `keep` rejects, and tmp files other processes
|
||||
* left behind (startup: a crash between removing a session's record and deleting its file,
|
||||
* or mid-write; an older build).
|
||||
* Returns how many sessions' files were deleted.
|
||||
*/
|
||||
static async sweep(
|
||||
keep: (sessionId: string) => boolean,
|
||||
opts: Pick<EditHistoryStoreOptions, 'dir'> = {}
|
||||
): Promise<number> {
|
||||
const dir = opts.dir ?? dataPath('edit-history');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
const tmp = /\.json\.[\w-]+\.tmp$/.test(name);
|
||||
const stale = tmp
|
||||
? !name.endsWith(`.json.${TMP_TAG}.tmp`)
|
||||
: name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
|
||||
if (!stale) continue;
|
||||
await unlink(join(dir, name)).catch(() => undefined);
|
||||
if (!tmp) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Delete the stored history of `sessionId` without loading it. */
|
||||
static async delete(sessionId: string, opts: Pick<EditHistoryStoreOptions, 'dir'> = {}): Promise<void> {
|
||||
await new EditHistoryStore(sessionId, opts).remove();
|
||||
}
|
||||
|
||||
getTranscriptId(): string | null {
|
||||
return this.transcriptId;
|
||||
}
|
||||
|
||||
getCursor(source: string): SourceCursor | undefined {
|
||||
const c = this.cursors.get(source);
|
||||
return c ? { ...c } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any source has unread older lines that could still be shown. At a cap (no
|
||||
* room left for another entry) an older edit would be dropped the moment it is read, so
|
||||
* there is nothing more to load.
|
||||
*/
|
||||
get backfillTruncated(): boolean {
|
||||
if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
|
||||
for (const c of this.cursors.values()) if (c.floor > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Entries newest first. */
|
||||
list(): EditHistoryEntry[] {
|
||||
return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
|
||||
}
|
||||
|
||||
get(id: string): EditHistoryEntry | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
getAgents(): Record<string, AgentMeta> {
|
||||
return Object.fromEntries(this.agents);
|
||||
}
|
||||
|
||||
hasAgentMeta(agentId: string): boolean {
|
||||
return this.agents.has(agentId);
|
||||
}
|
||||
|
||||
/** Record a subagent's `meta.json`; ignored when it does not parse. */
|
||||
setAgentMeta(agentId: string, metaText: string): void {
|
||||
const meta = this.removed ? null : parseAgentMeta(metaText);
|
||||
if (!meta) return;
|
||||
this.putAgent(agentId, meta);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
|
||||
olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
|
||||
const cursor = this.cursors.get(source);
|
||||
if (!cursor || cursor.floor <= 0) return null;
|
||||
const offset = Math.max(0, cursor.floor - budget);
|
||||
return { offset, len: cursor.floor - offset };
|
||||
}
|
||||
|
||||
/** Every source with a cursor in the current conversation. */
|
||||
knownSources(): string[] {
|
||||
return [...this.cursors.keys()];
|
||||
}
|
||||
|
||||
/** Sources with older lines left. */
|
||||
sourcesWithOlder(): string[] {
|
||||
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
|
||||
}
|
||||
|
||||
/**
|
||||
* A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
|
||||
* longer offers older lines. Its entries stay.
|
||||
*/
|
||||
dropSource(source: string): void {
|
||||
if (this.removed || !this.cursors.delete(source)) return;
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a forward chunk: read from the source's cursor, or, without one, its last `budget`
|
||||
* bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor
|
||||
* (replaced) loses the cursor and is read from its end next time. 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;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
}
|
||||
let cursor = this.cursors.get(source);
|
||||
if (cursor && size < cursor.offset) {
|
||||
this.cursors.delete(source);
|
||||
cursor = undefined;
|
||||
}
|
||||
if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
|
||||
|
||||
const atEof = startOffset + chunk.length >= size;
|
||||
// Bytes before `from` belong to a line that started before this chunk.
|
||||
let from = 0;
|
||||
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. 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;
|
||||
}
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl < from) {
|
||||
if (from > 0) {
|
||||
// The skipped (or cut first) line ended here; the next one is read from its start.
|
||||
this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
|
||||
} else if (!atEof && chunk.length > 0) {
|
||||
// One line longer than the budget: skip it instead of re-reading it forever.
|
||||
this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
|
||||
} else if (!cursor) {
|
||||
this.setCursor(source, undefined, startOffset, startOffset, false);
|
||||
}
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const end = lastNl + 1;
|
||||
this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
|
||||
this.pendingBash = parsed.pending;
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. 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.removed ? undefined : this.cursors.get(source);
|
||||
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const none = { added: 0, candidates: [] };
|
||||
let end = chunk.length;
|
||||
if (cursor.floorMidLine) {
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl === -1 && startOffset > 0) {
|
||||
// Still inside the long line.
|
||||
cursor.floor = startOffset;
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
// The long line starts at `end`: drop its part.
|
||||
end = lastNl + 1;
|
||||
delete cursor.floorMidLine;
|
||||
}
|
||||
let from = 0;
|
||||
if (startOffset > 0) {
|
||||
// The newline at `end - 1` closes a line that may have started before this chunk.
|
||||
const nl = chunk.subarray(0, end).indexOf(NL);
|
||||
if (nl === -1 || nl === end - 1) {
|
||||
if (end < chunk.length) {
|
||||
// It ends where the long line starts: the next block, ending there, holds it whole.
|
||||
cursor.floor = startOffset + end;
|
||||
} else {
|
||||
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
|
||||
cursor.floor = startOffset;
|
||||
cursor.floorMidLine = true;
|
||||
}
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
cursor.floor = startOffset + from;
|
||||
// Calls whose results lie in already-read newer lines cannot be paired here.
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/** 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}`,
|
||||
ts: img.ts,
|
||||
path: img.path,
|
||||
kind: 'image',
|
||||
tool: img.tool ?? 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
};
|
||||
if (img.agentId) entry.agentId = img.agentId;
|
||||
return entry;
|
||||
});
|
||||
const added = this.addRecords(records);
|
||||
if (added) this.scheduleSave();
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write pending changes, if any, and wait for a write under way (the store is dropped from
|
||||
* memory and loaded again later). Unlike `flush()`, a store without changes writes nothing.
|
||||
*/
|
||||
async settle(): Promise<void> {
|
||||
if (this.saveTimer) return this.flush();
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** True once {@link remove} ran: the store ignores every later write. */
|
||||
isRemoved(): boolean {
|
||||
return this.removed;
|
||||
}
|
||||
|
||||
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,
|
||||
sessionId: this.sessionId,
|
||||
transcriptId: this.transcriptId,
|
||||
cursors: Object.fromEntries(this.cursors),
|
||||
pendingBash: this.pendingBash,
|
||||
agents: Object.fromEntries(this.agents),
|
||||
entries: this.list(),
|
||||
};
|
||||
const dir = join(this.file, '..');
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
// `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
|
||||
// makes it the store, so an existing store never keeps wider bits.
|
||||
const tmp = `${this.file}.${TMP_TAG}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
||||
if (this.removed) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private setCursor(
|
||||
source: string,
|
||||
cursor: SourceCursor | undefined,
|
||||
offset: number,
|
||||
firstFloor: number,
|
||||
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);
|
||||
}
|
||||
|
||||
private addRecords(records: readonly EditHistoryEntry[]): number {
|
||||
const fresh: string[] = [];
|
||||
for (const r of records) {
|
||||
if (this.entries.has(r.id)) continue;
|
||||
this.entries.set(r.id, r);
|
||||
this.bytes += entryBytes(r);
|
||||
fresh.push(r.id);
|
||||
}
|
||||
if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
|
||||
for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
|
||||
if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
|
||||
this.entries.delete(e.id);
|
||||
this.bytes -= entryBytes(e);
|
||||
}
|
||||
}
|
||||
// Only what is still there counts: an "older" block past the cap adds nothing.
|
||||
return fresh.filter((id) => this.entries.has(id)).length;
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.removed || this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.flush().catch((err) => {
|
||||
console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
this.saveTimer.unref?.();
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -2830,7 +2830,7 @@ export class Session extends EventEmitter {
|
||||
if (entry?.launch.chain !== 'fallback') return options;
|
||||
|
||||
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||
const configDir = this._claudeConfigDir();
|
||||
const configDir = this.claudeConfigDir();
|
||||
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||
for (const candidate of candidates) {
|
||||
@@ -2852,8 +2852,11 @@ export class Session extends EventEmitter {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
/**
|
||||
* The session's Claude config dir when it has been relocated (#255), else undefined.
|
||||
* Public for the edit history reader, which looks for the transcript there.
|
||||
*/
|
||||
claudeConfigDir(): string | undefined {
|
||||
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||
// otherwise resolve to a relative path and read "no transcript" for everything.
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* @fileoverview Reads a claude session's edit history on request: the new bytes of its
|
||||
* transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
|
||||
* candidates that came with them are confirmed against the files on disk.
|
||||
*
|
||||
* 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; requests during a run are folded into one
|
||||
* more forward read and/or one more older read afterwards.
|
||||
*
|
||||
* HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
|
||||
* the shared budget cut short is not applied (the store would take a chunk with no line end
|
||||
* for a line longer than the budget) but read in the next pass; up to
|
||||
* {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
|
||||
*
|
||||
* NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume),
|
||||
* the previous transcript (same directory) is first read to its end, so edits it got after
|
||||
* the last read are not lost with its cursors; then the new one is read from its end.
|
||||
* "Load older" reaches back within the current conversation only.
|
||||
*
|
||||
* MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently
|
||||
* used one without a run is written (if it changed) and dropped, and loaded again on demand.
|
||||
*
|
||||
* GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file
|
||||
* and then drops the session's slot: a run in flight stops after its current read and its
|
||||
* store no longer writes; a request during the removal reads nothing (`unavailable`, reason
|
||||
* `removed`). A session that comes back with the same id (reboot restore) starts afresh.
|
||||
* `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
|
||||
* subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
|
||||
*
|
||||
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
|
||||
* with older lines per click; with more sources they take turns.
|
||||
*
|
||||
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read.
|
||||
*
|
||||
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
|
||||
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
|
||||
*
|
||||
* Tests: `test/edit-history-sync.test.ts`.
|
||||
*
|
||||
* @module web/edit-history-sync
|
||||
*/
|
||||
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { SessionRemote } from '../types/session.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';
|
||||
|
||||
/** Slack around a Bash call's window for the image mtime check. */
|
||||
export const IMAGE_MTIME_SLACK_MS = 2000;
|
||||
/** Forward passes per sync when files grew by more than one budget. */
|
||||
export const MAX_FORWARD_PASSES = 3;
|
||||
/** Sources read back per "Load older" click, a full budget each. */
|
||||
export const MAX_OLDER_SOURCES = 3;
|
||||
/** Stores kept in memory; the least recently used one past this is dropped. */
|
||||
export const MAX_LOADED_STORES = 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';
|
||||
|
||||
export interface EditHistoryStatus {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The slice of a session this needs. */
|
||||
export interface EditHistorySession {
|
||||
id: string;
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
claudeSessionId: string | null;
|
||||
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
||||
/** Delete a session's stored history without loading it. */
|
||||
deleteStore(sessionId: string): Promise<void>;
|
||||
/** Delete the stored history of every session `keep` rejects; returns how many. */
|
||||
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/** Why a session has no edit history, or undefined when it can have one. */
|
||||
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
||||
if (session.remote) return 'remote';
|
||||
if (session.docker) return 'docker';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One turn of the event loop, between synchronous parses of up to one budget each. */
|
||||
function breathe(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
|
||||
* (files claude sent to the user skip the window: they exist and were shown on purpose).
|
||||
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version — the
|
||||
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
|
||||
* in call order so a batch dedupes itself the same way.
|
||||
*/
|
||||
export function confirmImages(
|
||||
candidates: readonly ImageCandidate[],
|
||||
probe: (path: string) => PathProbe | null | undefined,
|
||||
known: (path: string) => number | undefined = () => undefined
|
||||
): ConfirmedImage[] {
|
||||
const out: ConfirmedImage[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
|
||||
for (const path of c.paths) {
|
||||
const p = probe(path);
|
||||
if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
|
||||
const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
|
||||
if (!c.sent && !inWindow) continue;
|
||||
const entryPath = path.startsWith('~/') ? p.realPath : path;
|
||||
const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
|
||||
if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
|
||||
seen.set(entryPath, c.end);
|
||||
const img: ConfirmedImage = {
|
||||
toolUseId: c.toolUseId,
|
||||
path: entryPath,
|
||||
ts: c.end,
|
||||
tool: c.sent ? 'sent' : 'bash',
|
||||
};
|
||||
if (c.agentId) img.agentId = c.agentId;
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SyncWant {
|
||||
forward: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
interface SessionSlot {
|
||||
store?: Promise<EditHistoryStore>;
|
||||
status: EditHistoryStatus;
|
||||
running?: Promise<void>;
|
||||
again?: SyncWant;
|
||||
/** The history was deleted: nothing reads for this session any more. */
|
||||
gone?: boolean;
|
||||
/** The last source the previous "Load older" read; the next one starts after it. */
|
||||
olderAfter?: string;
|
||||
}
|
||||
|
||||
export class EditHistorySync {
|
||||
/** By session, least recently used first. */
|
||||
private readonly slots = new Map<string, SessionSlot>();
|
||||
/** Writes of dropped stores; loading that session again waits for its write. */
|
||||
private readonly unloading = new Map<string, Promise<void>>();
|
||||
private readonly budget: number;
|
||||
|
||||
constructor(private readonly deps: EditHistorySyncDeps) {
|
||||
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
||||
}
|
||||
|
||||
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
||||
getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
return this.storeOf(sessionId, this.slot(sessionId));
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): EditHistoryStatus {
|
||||
return { ...this.slot(sessionId).status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what is new (or, with `older`, the next older block). Resolves when this
|
||||
* request's run is done; never rejects. A call during a run queues one more run.
|
||||
*/
|
||||
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
|
||||
const why = unsupportedReason(session);
|
||||
const slot = this.slot(session.id);
|
||||
if (slot.gone) return { ...slot.status };
|
||||
if (why) {
|
||||
slot.status = { state: 'unsupported', reason: why };
|
||||
return { ...slot.status };
|
||||
}
|
||||
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
||||
if (slot.running) {
|
||||
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 { ...slot.status };
|
||||
}
|
||||
slot.running = this.runLoop(session, slot, want);
|
||||
try {
|
||||
await slot.running;
|
||||
} finally {
|
||||
slot.running = undefined;
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return { ...slot.status };
|
||||
}
|
||||
|
||||
/** Write every loaded store's pending changes (server shutdown). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
|
||||
...this.unloading.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's stored history (its record is gone). While it runs the session is
|
||||
* marked, so a run in flight or a request meanwhile cannot load and write a new store for
|
||||
* it; then its slot goes, and a session that comes back with this id starts afresh.
|
||||
*/
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const slot = this.slot(sessionId);
|
||||
slot.gone = true;
|
||||
slot.again = undefined;
|
||||
slot.status = { state: 'unavailable', reason: REMOVED };
|
||||
try {
|
||||
const store = await slot.store?.catch(() => undefined);
|
||||
if (store) {
|
||||
await store.remove();
|
||||
} else {
|
||||
await this.unloading.get(sessionId);
|
||||
await this.deps.deleteStore(sessionId);
|
||||
}
|
||||
await slot.running;
|
||||
} finally {
|
||||
if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of sessions that no longer exist (startup: left behind by a
|
||||
* crash during deletion, or by an older build). A session in use here is kept.
|
||||
*/
|
||||
sweep(exists: (sessionId: string) => boolean): Promise<number> {
|
||||
return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
|
||||
}
|
||||
|
||||
/** The session's slot, now the most recently used. */
|
||||
private slot(sessionId: string): SessionSlot {
|
||||
const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
|
||||
this.slots.delete(sessionId);
|
||||
this.slots.set(sessionId, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private storeOf(sessionId: string, slot: SessionSlot): Promise<EditHistoryStore> {
|
||||
if (!slot.store) {
|
||||
const loading = (async () => {
|
||||
await this.unloading.get(sessionId);
|
||||
return this.deps.loadStore(sessionId);
|
||||
})();
|
||||
slot.store = loading;
|
||||
loading.catch(() => {
|
||||
if (slot.store === loading) slot.store = undefined;
|
||||
});
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return slot.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is
|
||||
* written if it changed, and a later request loads it again (after that write).
|
||||
*/
|
||||
private unloadIdle(keep?: SessionSlot): void {
|
||||
let loaded = 0;
|
||||
for (const slot of this.slots.values()) if (slot.store) loaded++;
|
||||
for (const [sessionId, slot] of this.slots) {
|
||||
if (loaded <= MAX_LOADED_STORES) break;
|
||||
if (!slot.store || slot.running || slot.gone || slot === keep) continue;
|
||||
this.slots.delete(sessionId);
|
||||
loaded--;
|
||||
const writing: Promise<void> = slot.store
|
||||
.then((store) => store.settle())
|
||||
.catch((err) => {
|
||||
console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId);
|
||||
});
|
||||
this.unloading.set(sessionId, writing);
|
||||
}
|
||||
}
|
||||
|
||||
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, slot: SessionSlot, older: boolean): Promise<void> {
|
||||
let status: EditHistoryStatus;
|
||||
try {
|
||||
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
||||
const store = await this.storeOf(session.id, slot);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
} else if (older) {
|
||||
found = await this.readOlder(path, store, slot);
|
||||
} else {
|
||||
found = true;
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
||||
const r = await this.readForward(path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
}
|
||||
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
} catch (err) {
|
||||
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!slot.gone) slot.status = status;
|
||||
}
|
||||
|
||||
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
|
||||
const out: EditSourceRequest[] = [];
|
||||
for (const relPath of store.knownSources()) {
|
||||
const c = store.getCursor(relPath);
|
||||
if (c) out.push({ relPath, offset: c.offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forward read of all sources. `more` = some file has unread bytes after it, or was cut
|
||||
* short by the shared budget and left for the next pass. A transcript other than the stored
|
||||
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
|
||||
*/
|
||||
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
|
||||
const own = this.storedTranscript(path, store);
|
||||
if (own !== path && store.knownSources().length > 0) {
|
||||
const previous = await this.readFrom(own, store, this.forwardRequests(store));
|
||||
if (previous.more) return { found: true, more: true };
|
||||
}
|
||||
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path of the conversation the store holds cursors for: `path` itself, or the previous
|
||||
* conversation's transcript next to it (`/clear` and resume keep the project directory).
|
||||
*/
|
||||
private storedTranscript(path: string, store: EditHistoryStore): string {
|
||||
const known = store.getTranscriptId();
|
||||
return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
|
||||
}
|
||||
|
||||
private async readFrom(
|
||||
path: string,
|
||||
store: EditHistoryStore,
|
||||
requests: EditSourceRequest[]
|
||||
): Promise<{ found: boolean; more: boolean }> {
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
if (!res) return { found: false, more: false };
|
||||
this.dropMissing(store, requests, res);
|
||||
for (const [agentId, text] of Object.entries(res.metas)) {
|
||||
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
|
||||
}
|
||||
const requested = new Set(requests.map((r) => r.relPath));
|
||||
let more = false;
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
if (store.isRemoved()) break;
|
||||
// A requested file reads on from its cursor, a new one its tail.
|
||||
const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
|
||||
if (f.chunk.length < Math.min(this.budget, available)) {
|
||||
more = true;
|
||||
continue;
|
||||
}
|
||||
parsed = true;
|
||||
const r = store.apply({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
if (f.startOffset + f.chunk.length < f.size) more = true;
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return { found: true, more };
|
||||
}
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
|
||||
if (!store.backfillTruncated) return true;
|
||||
// Older lines belong to the stored conversation, even before a forward read moved on.
|
||||
path = this.storedTranscript(path, store);
|
||||
const sources = store.sourcesWithOlder().sort();
|
||||
if (sources.length === 0) return true;
|
||||
// Take turns: start after the last source the previous click read.
|
||||
const after = slot.olderAfter;
|
||||
const first =
|
||||
after === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
sources.findIndex((s) => s > after)
|
||||
);
|
||||
const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
|
||||
const requests: EditSourceRequest[] = [];
|
||||
for (const relPath of picked) {
|
||||
const range = store.olderRange(relPath, this.budget);
|
||||
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
||||
}
|
||||
if (requests.length === 0) return true;
|
||||
slot.olderAfter = picked[picked.length - 1];
|
||||
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
this.dropMissing(store, requests, res);
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
if (!wanted.has(f.relPath)) continue;
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
parsed = true;
|
||||
if (slot.gone) return true;
|
||||
const r = store.applyOlder({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Requested files the reader no longer lists are gone: their cursors go too. */
|
||||
private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
|
||||
const listed = new Set(res.files.map((f) => f.relPath));
|
||||
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
|
||||
}
|
||||
|
||||
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
|
||||
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
|
||||
// A removed store keeps nothing: do not probe agent-printed paths for it.
|
||||
if (paths.length === 0 || store.isRemoved()) return;
|
||||
let probes: Array<PathProbe | null>;
|
||||
try {
|
||||
probes = await this.deps.probe(paths);
|
||||
} catch {
|
||||
// The edits of this read are in; only its images are lost. The read itself succeeded.
|
||||
return;
|
||||
}
|
||||
const byPath = new Map(paths.map((p, i) => [p, probes[i]]));
|
||||
const newest = new Map<string, number>();
|
||||
for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts));
|
||||
store.addImageEntries(
|
||||
confirmImages(
|
||||
candidates,
|
||||
(p) => byPath.get(p),
|
||||
(p) => newest.get(p)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,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;
|
||||
|
||||
@@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
|
||||
// Edit history (edit-history-ui.js): an open panel follows its session's tool ends
|
||||
// (and its turn ends, from _onSessionIdle: one handler per event in this map)
|
||||
[SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'],
|
||||
|
||||
// Tunnel
|
||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
|
||||
@@ -3587,6 +3591,9 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js),
|
||||
// not after Codeman's own keep-alive turn, which edits nothing.
|
||||
if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id);
|
||||
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
|
||||
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
|
||||
// turn, whose timer is still running or has already fired.
|
||||
@@ -10008,6 +10015,8 @@ class CodemanApp {
|
||||
// Remote-host reachability banner: only meaningful for a remote session, so this
|
||||
// also clears it when the newly active tab is local.
|
||||
this.refreshHostWakeBanner?.(sessionId);
|
||||
// An open Edit history panel follows the active session (edit-history-ui.js; no poll).
|
||||
this.onEditHistoryActiveSession?.(sessionId);
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens
|
||||
* right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest
|
||||
* first.
|
||||
*
|
||||
* OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default
|
||||
* OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the
|
||||
* server's reading, which never runs in the background: it reads
|
||||
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
|
||||
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
|
||||
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
|
||||
* (the end of a turn). No poll, and nothing at all while the panel is closed.
|
||||
*
|
||||
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
|
||||
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
|
||||
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
|
||||
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
|
||||
* 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
|
||||
* `textContent`, never `innerHTML`.
|
||||
*
|
||||
* @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)
|
||||
* @dependency i18n.js (window.codemanT)
|
||||
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** SSE bursts (one event per tool call) collapse into one read. */
|
||||
const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600;
|
||||
/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */
|
||||
const EDIT_HISTORY_FOLLOW_TOOLS = new Set([
|
||||
'Edit',
|
||||
'Write',
|
||||
'MultiEdit',
|
||||
'NotebookEdit',
|
||||
'Bash',
|
||||
'Agent',
|
||||
'Task',
|
||||
'SendUserFile',
|
||||
]);
|
||||
/** Per-device view toggles (a viewer convenience, so plain localStorage). */
|
||||
const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group';
|
||||
const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only';
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isEditHistoryEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true;
|
||||
},
|
||||
|
||||
/** Translate an English source string (i18n.js), with `{var}` interpolation. */
|
||||
_eht(source, vars) {
|
||||
if (typeof window.codemanT === 'function') return window.codemanT(source, vars);
|
||||
return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source;
|
||||
},
|
||||
|
||||
/** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */
|
||||
_ehBadge(word) {
|
||||
const key = `Edit history badge: ${word}`;
|
||||
const out = this._eht(key);
|
||||
return out === key ? word : out;
|
||||
},
|
||||
|
||||
/** Called from applyHeaderVisibilitySettings() (boot and every settings save). */
|
||||
applyEditHistoryVisibility() {
|
||||
const on = this.isEditHistoryEnabled();
|
||||
// Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md).
|
||||
this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on);
|
||||
if (!on) {
|
||||
this.closeEditHistoryPanel();
|
||||
this._editHistory = null;
|
||||
this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Per-device view toggles ─────────────────────────────────────────────
|
||||
|
||||
_editHistoryPref(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
_setEditHistoryPref(key, on) {
|
||||
try {
|
||||
localStorage.setItem(key, on ? '1' : '0');
|
||||
} catch {
|
||||
/* private window / blocked storage: the toggle still works for this page */
|
||||
}
|
||||
},
|
||||
|
||||
isEditHistoryGrouped() {
|
||||
return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY));
|
||||
},
|
||||
|
||||
isEditHistoryMainOnly() {
|
||||
return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY));
|
||||
},
|
||||
|
||||
setEditHistoryGrouped(on) {
|
||||
this._editHistoryGroupOn = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
setEditHistoryMainOnly(on) {
|
||||
this._editHistoryMainOnly = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The data for the session on screen, or null. */
|
||||
_currentEditHistory() {
|
||||
const h = this._editHistory;
|
||||
return h && h.sessionId === this.activeSessionId ? h : null;
|
||||
},
|
||||
|
||||
_editHistoryBase(sid) {
|
||||
return `/api/sessions/${encodeURIComponent(sid)}/edits`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the active session's history and repaint. `older` asks the server for entries before the
|
||||
* backfill window (POST), otherwise the normal read. Answers for another session are dropped.
|
||||
*/
|
||||
async refreshEditHistory({ older = false } = {}) {
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (!sid) {
|
||||
this._editHistory = null;
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1);
|
||||
const prev = this._currentEditHistory();
|
||||
if (!prev) {
|
||||
// Another session's list must not stay on screen while this one loads.
|
||||
this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() };
|
||||
this._renderEditHistoryPanel();
|
||||
} else if (older) {
|
||||
prev.loadingOlder = true;
|
||||
this._renderEditHistoryPanel();
|
||||
}
|
||||
const base = this._editHistoryBase(sid);
|
||||
const data = older
|
||||
? await this._apiJson(`${base}/older`, { method: 'POST', body: {} })
|
||||
: await this._apiJson(`${base}?sync=1`);
|
||||
if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return;
|
||||
const cur = this._currentEditHistory() || {
|
||||
sessionId: sid,
|
||||
diffs: new Map(),
|
||||
open: new Set(),
|
||||
};
|
||||
cur.loadingOlder = false;
|
||||
if (data && typeof data === 'object') {
|
||||
cur.state = 'ok';
|
||||
cur.data = data;
|
||||
} else if (!cur.data) {
|
||||
cur.state = 'error';
|
||||
}
|
||||
this._editHistory = cur;
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
loadOlderEditHistory() {
|
||||
return this.refreshEditHistory({ older: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE `transcript:tool_end` ({ sessionId, toolName }), and the end of a turn through
|
||||
* `onEditHistorySessionIdle`: while the panel is open on that session, re-read after a tool that
|
||||
* can change files, and when the turn ends (a parallel tool call reports only one tool end; a
|
||||
* background subagent writes after the tool end that started it). Closed: nothing.
|
||||
*/
|
||||
_onEditHistoryTranscriptEvent(data) {
|
||||
if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return;
|
||||
if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return;
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = setTimeout(() => {
|
||||
this._editHistoryDebounce = null;
|
||||
if (this._isEditHistoryPanelOpen()) this.refreshEditHistory();
|
||||
}, EDIT_HISTORY_SSE_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by app.js's `session:idle` handler ({ id }): the end of a turn. Claude's interactive
|
||||
* transcript has no `result` line, so the transcript watcher's `transcript:complete` never fires
|
||||
* for these sessions.
|
||||
*/
|
||||
onEditHistorySessionIdle(sessionId) {
|
||||
this._onEditHistoryTranscriptEvent({ sessionId });
|
||||
},
|
||||
|
||||
/** Called by selectSession(): the open panel follows the active session. */
|
||||
onEditHistoryActiveSession(sessionId) {
|
||||
if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return;
|
||||
if (this._editHistory?.sessionId === sessionId) return;
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isEditHistoryPanelOpen() {
|
||||
return !!this.$('editHistoryPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleEditHistoryPanel() {
|
||||
if (this._isEditHistoryPanelOpen()) {
|
||||
this.closeEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true');
|
||||
const group = this.$('editHistoryGroupToggle');
|
||||
if (group) group.checked = this.isEditHistoryGrouped();
|
||||
const main = this.$('editHistoryMainOnlyToggle');
|
||||
if (main) main.checked = this.isEditHistoryMainOnly();
|
||||
this._ensureEditHistoryPanelDrag();
|
||||
this._renderEditHistoryPanel();
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
closeEditHistoryPanel() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot (a resized size is kept).
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false');
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
},
|
||||
|
||||
/** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */
|
||||
_ensureEditHistoryPanelDrag() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
const handle = panel?.querySelector('.edit-history-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
// Pin the size too: the phone layout stretches between left and right, which a drag gives up.
|
||||
panel.style.width = `${rect.width}px`;
|
||||
panel.style.height = `${rect.height}px`;
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_ehEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
/** "14:03:22", with the date in front when it is not today. */
|
||||
_editHistoryTime(ts) {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`;
|
||||
},
|
||||
|
||||
/** Open `path` in the shared file preview overlay (panels-ui.js). */
|
||||
_openEditHistoryPreview(path) {
|
||||
const sid = this.activeSessionId;
|
||||
if (sid && path) this.openFilePreview?.(path, sid);
|
||||
},
|
||||
|
||||
_editHistoryUrl(url) {
|
||||
return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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() {
|
||||
const body = this.$('editHistoryBody');
|
||||
const count = this.$('editHistoryCount');
|
||||
if (!body || !this._isEditHistoryPanelOpen()) return;
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey
|
||||
: null;
|
||||
const scroll = body.scrollTop;
|
||||
body.replaceChildren();
|
||||
if (count) count.textContent = '';
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.')));
|
||||
return;
|
||||
}
|
||||
const h = this._currentEditHistory();
|
||||
if (!h || h.state === 'loading') {
|
||||
body.append(el('div', 'edit-history-empty', t('Reading the edit history…')));
|
||||
return;
|
||||
}
|
||||
if (h.state === 'error' || !h.data) {
|
||||
body.append(el('div', 'edit-history-empty', t('Could not read the edit history.')));
|
||||
return;
|
||||
}
|
||||
const data = h.data;
|
||||
if (data.state === 'unsupported') {
|
||||
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
|
||||
return;
|
||||
}
|
||||
if (data.state === 'unavailable') {
|
||||
body.append(
|
||||
el(
|
||||
'div',
|
||||
'edit-history-note edit-history-note--warn',
|
||||
data.reason === 'no-transcript'
|
||||
? t(
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
|
||||
)
|
||||
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all;
|
||||
if (count) count.textContent = all.length ? String(entries.length) : '';
|
||||
if (!all.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits yet.')));
|
||||
} else if (!entries.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits by the main agent.')));
|
||||
} else if (this.isEditHistoryGrouped()) {
|
||||
const groups = new Map();
|
||||
for (const e of entries) {
|
||||
const key = e.path || e.relPath;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h));
|
||||
} else {
|
||||
const list = el('div', 'edit-history-list');
|
||||
for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true));
|
||||
body.append(list);
|
||||
}
|
||||
|
||||
if (data.backfillTruncated) {
|
||||
const more = el(
|
||||
'button',
|
||||
'btn-toolbar btn-sm edit-history-older',
|
||||
h.loadingOlder ? t('Loading older edits…') : t('Load older')
|
||||
);
|
||||
more.type = 'button';
|
||||
more.disabled = !!h.loadingOlder;
|
||||
more.addEventListener('click', () => this.loadOlderEditHistory());
|
||||
body.append(more);
|
||||
}
|
||||
|
||||
body.scrollTop = scroll;
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey);
|
||||
again?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
/** One file with all its edits (newest first), for the "group by file" view. */
|
||||
_editHistoryGroup(items, data, h) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const first = items[0];
|
||||
const group = el('section', 'edit-history-group');
|
||||
const head = el('div', 'edit-history-group-head');
|
||||
head.append(this._editHistoryPathButton(first));
|
||||
const code = items.filter((e) => e.kind !== 'image');
|
||||
if (code.length) {
|
||||
const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0);
|
||||
const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0);
|
||||
head.append(this._editHistoryStats(added, removed));
|
||||
}
|
||||
head.append(el('span', 'edit-history-group-count', String(items.length)));
|
||||
group.append(head);
|
||||
for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false));
|
||||
return group;
|
||||
},
|
||||
|
||||
/**
|
||||
* The path as folder + file name: the folder is cut from its start when the row is narrow, so a
|
||||
* long absolute path (an image in claude's scratchpad) still shows which file it is.
|
||||
*/
|
||||
_editHistoryPathButton(e) {
|
||||
const shown = e.relPath || e.path || '';
|
||||
const btn = this._ehEl('button', 'edit-history-path');
|
||||
const cut = shown.lastIndexOf('/') + 1;
|
||||
if (cut > 0) {
|
||||
const dir = this._ehEl('span', 'edit-history-path-dir');
|
||||
const bdi = this._ehEl('bdi', null, shown.slice(0, cut));
|
||||
bdi.dir = 'ltr';
|
||||
dir.append(bdi);
|
||||
btn.append(dir);
|
||||
}
|
||||
btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut)));
|
||||
btn.type = 'button';
|
||||
btn.title = e.path || e.relPath || '';
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this._openEditHistoryPreview(e.path);
|
||||
});
|
||||
return btn;
|
||||
},
|
||||
|
||||
_editHistoryStats(added, removed) {
|
||||
const stats = this._ehEl('span', 'edit-history-stats');
|
||||
stats.append(this._ehEl('span', 'edit-history-add', `+${added}`));
|
||||
stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`));
|
||||
return stats;
|
||||
},
|
||||
|
||||
/** A row, followed by its diff when the row is open. `withPath` false inside a file group. */
|
||||
_editHistoryEntry(e, data, h, withPath) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const isImage = e.kind === 'image';
|
||||
const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`);
|
||||
row.dataset.editKey = e.id;
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts)));
|
||||
|
||||
let badge;
|
||||
if (isImage) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--image');
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
for (const [tag, attrs] of [
|
||||
['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }],
|
||||
['circle', { cx: '9', cy: '10', r: '2' }],
|
||||
['path', { d: 'M21 17l-5-5-9 8' }],
|
||||
]) {
|
||||
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
svg.append(n);
|
||||
}
|
||||
badge.append(svg);
|
||||
badge.title = t('Image');
|
||||
} else if (e.created) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new'));
|
||||
badge.title = t('File created');
|
||||
} else {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit'));
|
||||
badge.title = t('File edited ({tool})', { tool: e.tool || '' });
|
||||
}
|
||||
row.append(badge);
|
||||
|
||||
if (withPath) row.append(this._editHistoryPathButton(e));
|
||||
row.append(el('span', 'edit-history-spacer'));
|
||||
if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0));
|
||||
|
||||
if (e.agentId) {
|
||||
const agent = data.agents?.[e.agentId];
|
||||
const chip = el('span', 'edit-history-agent', agent?.description || e.agentId);
|
||||
const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean);
|
||||
chip.title = bits.join(' · ') || t('Subagent');
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
const out = [row];
|
||||
if (isImage) {
|
||||
row.title = t('Open in the file preview');
|
||||
const open = () => this._openEditHistoryPreview(e.path);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
|
||||
if (src) {
|
||||
const img = el('img', 'edit-history-thumb');
|
||||
img.loading = 'lazy';
|
||||
img.alt = e.relPath || '';
|
||||
img.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
open();
|
||||
});
|
||||
img.addEventListener('error', () => img.remove());
|
||||
img.src = src;
|
||||
out.push(img);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isOpen = h.open.has(e.id);
|
||||
row.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
row.title = isOpen ? t('Hide the diff') : t('Show what changed');
|
||||
const toggle = () => this.toggleEditHistoryDiff(e.id);
|
||||
row.addEventListener('click', toggle);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
if (isOpen) {
|
||||
row.classList.add('edit-history-entry--open');
|
||||
out.push(this._editHistoryDiffView(h.diffs.get(e.id)));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Open or close one entry's diff; the first open loads it, later ones reuse it. */
|
||||
async toggleEditHistoryDiff(entryId) {
|
||||
const h = this._currentEditHistory();
|
||||
if (!h) return;
|
||||
if (h.open.has(entryId)) {
|
||||
h.open.delete(entryId);
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
h.open.add(entryId);
|
||||
const cached = h.diffs.get(entryId);
|
||||
if (cached && cached.state !== 'error') {
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const view = { state: 'loading' };
|
||||
h.diffs.set(entryId, view);
|
||||
this._renderEditHistoryPanel();
|
||||
const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`);
|
||||
let body = null;
|
||||
try {
|
||||
body = res ? await res.json() : null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (h.diffs.get(entryId) !== view) return;
|
||||
if (res && res.ok && body?.success && typeof body.data?.diff === 'string') {
|
||||
view.state = 'ok';
|
||||
view.diff = body.data.diff;
|
||||
view.truncated = !!body.data.truncated;
|
||||
} else {
|
||||
view.state = 'error';
|
||||
view.error = body?.error || this._eht('Could not read the diff.');
|
||||
}
|
||||
if (this._editHistory === h) this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
/** The diff block under an open row, drawn with the Git panel's classes. */
|
||||
_editHistoryDiffView(view) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const wrap = el('div', 'edit-history-diff');
|
||||
if (!view || view.state === 'loading') {
|
||||
wrap.append(el('div', 'edit-history-empty', t('Reading the diff…')));
|
||||
return wrap;
|
||||
}
|
||||
if (view.state === 'error') {
|
||||
wrap.append(el('div', 'edit-history-empty', view.error));
|
||||
return wrap;
|
||||
}
|
||||
if (!view.diff.trim()) {
|
||||
wrap.append(el('div', 'edit-history-empty', t('No textual changes.')));
|
||||
} else {
|
||||
const pre = el('pre', 'git-diff');
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of view.diff.replace(/\n$/, '').split('\n')) {
|
||||
let cls = 'git-diff-line';
|
||||
// The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line.
|
||||
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||
frag.append(el('span', cls, line + '\n'));
|
||||
}
|
||||
pre.append(frag);
|
||||
wrap.append(pre);
|
||||
}
|
||||
if (view.truncated)
|
||||
wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.')));
|
||||
return wrap;
|
||||
},
|
||||
});
|
||||
@@ -553,6 +553,39 @@
|
||||
'Fetch git status': '拉取 Git 状态',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
// Edit history (edit-history-ui.js): settings chip, header button and panel.
|
||||
// The two row badges use scoped keys: a bare "new"/"edit" key would also
|
||||
// translate unrelated words elsewhere (the lookup is case-insensitive).
|
||||
'Edit history': '编辑历史',
|
||||
'Open edit history': '打开编辑历史',
|
||||
'Refresh edit history': '刷新编辑历史',
|
||||
'Close edit history': '关闭编辑历史',
|
||||
'Group by file': '按文件分组',
|
||||
'Main agent only': '仅主智能体',
|
||||
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
|
||||
'Reading the edit history…': '正在读取编辑历史…',
|
||||
'Could not read the edit history.': '无法读取编辑历史。',
|
||||
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
|
||||
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
|
||||
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
'Loading older edits…': '正在加载更早的编辑…',
|
||||
'Edit history badge: new': '新建',
|
||||
'Edit history badge: edit': '编辑',
|
||||
'File created': '已创建文件',
|
||||
'File edited ({tool})': '已编辑文件({tool})',
|
||||
Image: '图片',
|
||||
Subagent: '子智能体',
|
||||
'Open in the file preview': '在文件预览中打开',
|
||||
'Show what changed': '显示更改内容',
|
||||
'Hide the diff': '隐藏差异',
|
||||
'Reading the diff…': '正在读取差异…',
|
||||
'Could not read the diff.': '无法读取差异。',
|
||||
'No textual changes.': '没有文本更改。',
|
||||
'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
|
||||
@@ -216,6 +216,13 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show
|
||||
what the session produced, and the panel opens right below. Optional and per-device
|
||||
(App Settings → Header & Panels → Header, default OFF): hidden by its marker class,
|
||||
never on phones (mobile.css). -->
|
||||
<button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
@@ -633,6 +640,22 @@
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. -->
|
||||
<div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history">
|
||||
<div class="edit-history-header">
|
||||
<span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span>
|
||||
<div class="edit-history-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-history-controls">
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label>
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label>
|
||||
</div>
|
||||
<div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -2205,6 +2228,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
@@ -4641,6 +4665,7 @@
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -586,6 +586,8 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer,
|
||||
/* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */
|
||||
.btn-icon-header.btn-edit-history,
|
||||
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
|
||||
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||
header it was designed to stay out of. */
|
||||
|
||||
@@ -610,6 +610,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// below and are not stripped from a save: a stripped key would never reach the scheduler.
|
||||
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
|
||||
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
|
||||
document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -3158,6 +3159,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
3600,
|
||||
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
|
||||
),
|
||||
showEditHistory: document.getElementById('appSettingsShowEditHistory').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -3465,6 +3467,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusTree: _gst,
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
// Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema.
|
||||
showEditHistory: _seh,
|
||||
showTabDetachButton: _tdb,
|
||||
showTabNewButton: _tnb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
@@ -4826,6 +4830,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
// Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden.
|
||||
this.applyEditHistoryVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
@@ -5189,7 +5195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
|
||||
'showTabDetachButton',
|
||||
'showTabNewButton',
|
||||
'mobileOverviewEnabled',
|
||||
|
||||
@@ -22679,6 +22679,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
|
||||
|
||||
/* "Edit history" header button — opt-in (App Settings → Header & Panels), hidden by default.
|
||||
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
|
||||
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
|
||||
(mobile.css). */
|
||||
.btn-edit-history {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-edit-history.btn-edit-history--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-edit-history[aria-expanded='true'] {
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */
|
||||
.edit-history-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
/* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */
|
||||
right: clamp(8px, calc(100vw - 428px), 410px);
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 16px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 640px;
|
||||
min-width: 260px;
|
||||
min-height: 220px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.edit-history-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.edit-history-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-count {
|
||||
margin-left: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-history-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-empty,
|
||||
.edit-history-note,
|
||||
.edit-history-more {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.edit-history-note--warn {
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-entry:hover,
|
||||
.edit-history-entry:focus-visible,
|
||||
.edit-history-entry--open {
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-history-time {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-badge--new {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-badge--image {
|
||||
color: var(--text-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.edit-history-badge--image svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */
|
||||
/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */
|
||||
.edit-history-path {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-path-dir {
|
||||
/* Shrinks long before the name does; the name only gives way once the folder is at min-width. */
|
||||
flex: 0 10000 auto;
|
||||
min-width: 1.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.edit-history-path-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-history-path:hover,
|
||||
.edit-history-path:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-history-spacer {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
min-width: 0.5rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.edit-history-stats {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.edit-history-add {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-del {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.edit-history-agent {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-thumb {
|
||||
display: block;
|
||||
max-width: 160px;
|
||||
max-height: 90px;
|
||||
margin: 0.15rem 0 0.35rem 3.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.edit-history-diff {
|
||||
margin: 0.2rem 0 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-group {
|
||||
margin-bottom: 0.45rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.edit-history-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.edit-history-group-head .edit-history-path {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-history-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-older {
|
||||
margin: 0.5rem auto 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||
Discussion #426 option C)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API: which files a claude session edited (main agent
|
||||
* and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts).
|
||||
*
|
||||
* GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first
|
||||
* GET /api/sessions/:id/edits/:entryId one edit's diff as unified text
|
||||
* POST /api/sessions/:id/edits/older read the next older block, then list
|
||||
*
|
||||
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
|
||||
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
|
||||
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
|
||||
* session-scoped route. No route here serves a file: an image entry is shown through the
|
||||
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js';
|
||||
import type { EditHunk } from '../../edit-history-parse.js';
|
||||
import {
|
||||
unsupportedReason,
|
||||
type EditHistorySession,
|
||||
type EditHistoryState,
|
||||
type EditHistoryStatus,
|
||||
type EditHistorySync,
|
||||
} from '../edit-history-sync.js';
|
||||
|
||||
export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> {
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
export interface EditHistoryList {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
backfillTruncated: boolean;
|
||||
agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>;
|
||||
entries: EditHistoryListEntry[];
|
||||
}
|
||||
|
||||
export interface EditHistoryDiff {
|
||||
diff: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** `path` relative to `base` when it lies below it, else unchanged. */
|
||||
export function relativeTo(path: string, base: string | undefined): string {
|
||||
if (!base) return path;
|
||||
const root = base.endsWith('/') ? base : `${base}/`;
|
||||
return path.startsWith(root) ? path.slice(root.length) : path;
|
||||
}
|
||||
|
||||
/** Hunks as unified diff text, the form the Git panel's renderer reads. */
|
||||
export function hunksToUnified(hunks: readonly EditHunk[]): string {
|
||||
const out: string[] = [];
|
||||
for (const h of hunks) {
|
||||
out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`);
|
||||
out.push(...h.lines);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function listOf(
|
||||
store: EditHistoryStore,
|
||||
workingDir: string,
|
||||
status: { state: EditHistoryState; reason?: string }
|
||||
): EditHistoryList {
|
||||
const agents = store.getAgents();
|
||||
const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({
|
||||
...rest,
|
||||
relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir),
|
||||
}));
|
||||
const publicAgents: EditHistoryList['agents'] = {};
|
||||
for (const [id, a] of Object.entries(agents)) {
|
||||
publicAgents[id] = { description: a.description, agentType: a.agentType };
|
||||
if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch;
|
||||
}
|
||||
const list: EditHistoryList = {
|
||||
state: status.state,
|
||||
backfillTruncated: store.backfillTruncated,
|
||||
agents: publicAgents,
|
||||
entries,
|
||||
};
|
||||
if (status.reason) list.reason = status.reason;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
|
||||
export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void {
|
||||
const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => {
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const view: EditHistorySession = {
|
||||
id: session.id,
|
||||
mode: session.mode,
|
||||
remote: session.remote,
|
||||
docker: session.docker,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
claudeConfigDir: session.claudeConfigDir(),
|
||||
};
|
||||
return { session, view };
|
||||
};
|
||||
|
||||
app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { sync: doSync } = req.query as { sync?: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
// Never read: answer an empty list without loading (or creating) a store for it.
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
// The sync starts in the same tick as the session check: a deletion right after it marks
|
||||
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
|
||||
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
const status = await sync.sync(view, { older: true });
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => {
|
||||
const { id, entryId } = req.params as { id: string; entryId: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const entry = unsupportedReason(view) ? undefined : (await sync.getStore(session.id)).get(entryId);
|
||||
if (!entry) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session');
|
||||
}
|
||||
return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } };
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export { registerInboxRoutes } from './inbox-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerEditHistoryRoutes } from './edit-history-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerWorktreeRoutes } from './worktree-routes.js';
|
||||
|
||||
@@ -1729,7 +1729,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);
|
||||
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
|
||||
// only on killMux); retiring the row is the delete, so the mail goes now.
|
||||
agentInbox.drop(id);
|
||||
|
||||
@@ -856,6 +856,7 @@ export function registerSystemRoutes(
|
||||
// Keeping a pane that died while detached errs toward the 12 h TTL and the boot
|
||||
// prune, the same trade the boot keep set makes.
|
||||
if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id);
|
||||
ctx.forgetEditHistory(s.id);
|
||||
}
|
||||
return { cleanedSessions: result.count };
|
||||
});
|
||||
|
||||
@@ -102,6 +102,10 @@ const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
/\/\.codeman[^/]*\/intents\.json$/,
|
||||
// Pending agent-inbox messages (opt-in snapshot): same text as prompts, same 0600.
|
||||
/\/\.codeman[^/]*\/agent-inbox\.json$/,
|
||||
// The edit history panel's stores (edit-history-store.ts) hold every diff an
|
||||
// agent wrote, keys and .env contents included; written 0600 and kept out of
|
||||
// /api/search for the same reason as intents.json.
|
||||
/\/\.codeman[^/]*\/edit-history\//,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+46
-1
@@ -137,6 +137,9 @@ import { probePathKind } from '../utils/bounded-path-probe.js';
|
||||
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
|
||||
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { EditHistorySync } from './edit-history-sync.js';
|
||||
import { EditHistoryStore } from '../edit-history-store.js';
|
||||
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -222,6 +225,7 @@ import {
|
||||
registerRebootRestoreRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerGitStatusRoutes,
|
||||
registerEditHistoryRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -351,6 +355,24 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||||
export class WebServer extends EventEmitter {
|
||||
private app: FastifyInstance;
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
/**
|
||||
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
|
||||
* the main transcript is the one the transcript watcher follows (hook-reported path), or,
|
||||
* before the first hook after a restart, a lookup under the session's claude config dir.
|
||||
*/
|
||||
private readonly editHistorySync = new EditHistorySync({
|
||||
transcriptPath: (session) =>
|
||||
resolveClaudeTranscript(
|
||||
this.transcriptWatchers.get(session.id)?.getPath(),
|
||||
[session.claudeSessionId || session.id, session.id],
|
||||
session.claudeConfigDir
|
||||
),
|
||||
readSources: readClaudeEditSources,
|
||||
probe: probeLocalPaths,
|
||||
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
|
||||
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
|
||||
sweepStores: (keep) => EditHistoryStore.sweep(keep),
|
||||
});
|
||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
|
||||
private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map();
|
||||
@@ -902,6 +924,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),
|
||||
@@ -1319,6 +1342,7 @@ export class WebServer extends EventEmitter {
|
||||
registerRebootRestoreRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerGitStatusRoutes(this.app, ctx);
|
||||
registerEditHistoryRoutes(this.app, ctx, this.editHistorySync);
|
||||
// Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in
|
||||
// tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on
|
||||
// a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning
|
||||
@@ -1831,7 +1855,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2673,10 +2698,27 @@ 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);
|
||||
}
|
||||
// History files no session owns (a crash between removing a record and deleting its file).
|
||||
const exists = (id: string) => this.sessions.has(id) || !!this.store.getSession(id);
|
||||
await this.editHistorySync.sweep(exists).catch((err) => {
|
||||
console.warn('[EditHistory] Sweeping orphaned histories failed:', getErrorMessage(err));
|
||||
});
|
||||
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
|
||||
* (startup, `POST /api/cleanup-state`).
|
||||
*/
|
||||
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.
|
||||
@@ -4622,6 +4664,9 @@ export class WebServer extends EventEmitter {
|
||||
this.respawnTimers.clear();
|
||||
this.runSummaryTrackers.clear();
|
||||
this.transcriptWatchers.clear();
|
||||
await this.editHistorySync.flushAll().catch((err) => {
|
||||
console.warn('[EditHistory] Saving on shutdown failed:', getErrorMessage(err));
|
||||
});
|
||||
this.sessionListenerRefs.clear();
|
||||
this.scheduledRuns.clear();
|
||||
// Dispose StaleExpirationMaps (stops internal cleanup timers)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
|
||||
* reads of the main transcript and its subagents, and the image probe.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findClaudeTranscript,
|
||||
probeLocalPaths,
|
||||
readClaudeEditSources,
|
||||
resolveClaudeTranscript,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
|
||||
const path = join(root, rel);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('findClaudeTranscript', () => {
|
||||
it('returns null when no transcript exists under any id', async () => {
|
||||
put('p/other.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
|
||||
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
|
||||
put('a/sid.jsonl', 'old\n', 1000);
|
||||
const newest = put('b/sid.jsonl', 'new\n', 2000);
|
||||
put('c/second.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeTranscript', () => {
|
||||
it('takes the watcher path (what the hooks reported) as is', async () => {
|
||||
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
|
||||
});
|
||||
|
||||
it("without one, looks under the session's own claude config dir", async () => {
|
||||
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
|
||||
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
|
||||
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaudeEditSources', () => {
|
||||
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
|
||||
const path = put('p/sid.jsonl', 'x\n');
|
||||
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the conversation after the file', async () => {
|
||||
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
|
||||
expect(res?.transcriptId).toBe('conv-1');
|
||||
});
|
||||
|
||||
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const fresh = await readClaudeEditSources(path, [], 4);
|
||||
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
|
||||
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
|
||||
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
|
||||
});
|
||||
|
||||
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
|
||||
const path = put('p/sid.jsonl', 'abc');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
|
||||
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
|
||||
});
|
||||
|
||||
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
|
||||
put('p/sid/subagents/notes.txt', 'ignored');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['main', 0, 'MAIN'],
|
||||
['subagents/agent-a1.jsonl', 2, 'AA'],
|
||||
['subagents/agent-b2.jsonl', 0, ''],
|
||||
]);
|
||||
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
|
||||
});
|
||||
|
||||
it('ignores invalid requests', async () => {
|
||||
const path = put('p/sid.jsonl', 'abcdef');
|
||||
const res = await readClaudeEditSources(
|
||||
path,
|
||||
[
|
||||
{ relPath: '../etc/passwd', offset: 0 },
|
||||
{ relPath: 'main', offset: -1 },
|
||||
],
|
||||
3
|
||||
);
|
||||
// The invalid main request is dropped, so main is read like a new file.
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
|
||||
});
|
||||
|
||||
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
|
||||
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
|
||||
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', () => {
|
||||
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
|
||||
put('home/img.png', 'PNG!', 1_700_000_000);
|
||||
mkdirSync(join(root, 'dir'));
|
||||
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
|
||||
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
|
||||
expect(res[1]).toBeNull();
|
||||
expect(res[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('touches a path only after the bounded probe saw a file there', async () => {
|
||||
put('a.png', 'PNG!', 1_700_000_000);
|
||||
put('b.png', 'PNG!', 1_700_000_000);
|
||||
const asked: string[] = [];
|
||||
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
|
||||
asked.push(p);
|
||||
return p.endsWith('a.png') ? 'unknown' : 'file';
|
||||
});
|
||||
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
|
||||
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
|
||||
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
|
||||
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
|
||||
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyEditPath,
|
||||
extractImagePathCandidates,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_IMAGE_CANDIDATES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
} from '../src/edit-history-parse.js';
|
||||
|
||||
const CWD = '/work/repo';
|
||||
|
||||
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
|
||||
|
||||
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
|
||||
filePath: '/work/repo/src/app.ts',
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'x'.repeat(5000),
|
||||
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: edits', () => {
|
||||
it('turns an Edit result into a record without the whole-file fields', () => {
|
||||
const { records } = parseClaudeEditLines(editLine + '\n');
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: 'toolu_edit',
|
||||
ts: Date.parse('2026-10-15T19:01:28.880Z'),
|
||||
path: '/work/repo/src/app.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 2,
|
||||
removed: 1,
|
||||
hunks: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(records)).not.toContain('xxxxx');
|
||||
});
|
||||
|
||||
it('reads replaceAll edits like any other Edit', () => {
|
||||
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
|
||||
filePath: '/work/repo/a.ts',
|
||||
oldString: 'x',
|
||||
newString: 'y',
|
||||
originalFile: '',
|
||||
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
|
||||
userModified: false,
|
||||
replaceAll: true,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
|
||||
expect(rec.hunks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks a Write that created the file, and one that replaced it', () => {
|
||||
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hello\nworld',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
|
||||
type: 'update',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hi',
|
||||
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
|
||||
originalFile: 'hello\nworld',
|
||||
});
|
||||
const { records } = parseClaudeEditLines([create, update].join('\n'));
|
||||
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
|
||||
['Write', true, 2, 0],
|
||||
['Write', false, 1, 2],
|
||||
]);
|
||||
expect(records[0].hunks).toEqual([
|
||||
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
|
||||
const big = 'x'.repeat(1000) + '\n';
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.txt',
|
||||
content: big.repeat(100),
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.added).toBe(100);
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(100);
|
||||
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an image written by Write as an image', () => {
|
||||
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/icon.SVG',
|
||||
content: '<svg/>',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
|
||||
});
|
||||
|
||||
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
|
||||
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
|
||||
filePath: '/work/repo/n.ipynb',
|
||||
structuredPatch: [hunk(['+cell'])],
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
|
||||
});
|
||||
|
||||
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
|
||||
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
|
||||
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
|
||||
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
|
||||
expect(parseClaudeEditLines(text).records).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
|
||||
const cut = editLine.slice(40);
|
||||
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('carries the subagent id of a sidechain line', () => {
|
||||
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
|
||||
isSidechain: true,
|
||||
agentId: 'a0038561ea4d57a42',
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
|
||||
});
|
||||
|
||||
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
|
||||
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.ts',
|
||||
content: '',
|
||||
structuredPatch: [hunk(big)],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.added).toBe(4000);
|
||||
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: Bash image candidates', () => {
|
||||
const start = '2026-10-15T20:00:00.000Z';
|
||||
const end = '2026-10-15T20:00:05.000Z';
|
||||
|
||||
it('pairs a Bash call with its result and collects image paths from command and output', () => {
|
||||
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
|
||||
const res = toolResultLine('toolu_b', end, {
|
||||
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
});
|
||||
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
|
||||
expect(pending).toEqual([]);
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_b',
|
||||
start: Date.parse(start),
|
||||
end: Date.parse(end),
|
||||
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields no candidate for a Bash call without image paths', () => {
|
||||
const use = bashUseLine('toolu_n', start, 'git status');
|
||||
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
|
||||
const use = bashUseLine('toolu_f', start, 'make render');
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: end,
|
||||
cwd: CWD,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
|
||||
},
|
||||
toolUseResult: 'Error: Exit 2',
|
||||
});
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
|
||||
});
|
||||
|
||||
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
|
||||
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
|
||||
expect(first.candidates).toEqual([]);
|
||||
expect(first.pending).toEqual([
|
||||
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
|
||||
]);
|
||||
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
const second = parseClaudeEditLines(res, first.pending);
|
||||
expect(second.pending).toEqual([]);
|
||||
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
|
||||
});
|
||||
|
||||
it('carries the subagent id onto the candidate', () => {
|
||||
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
|
||||
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
|
||||
});
|
||||
|
||||
it('bounds the pending list, dropping the oldest', () => {
|
||||
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
|
||||
bashUseLine(`toolu_${i}`, start, 'sleep 1')
|
||||
).join('\n');
|
||||
const { pending } = parseClaudeEditLines(lines);
|
||||
expect(pending).toHaveLength(MAX_PENDING_BASH);
|
||||
expect(pending[0].id).toBe('toolu_5');
|
||||
});
|
||||
|
||||
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
|
||||
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
|
||||
const lines = [
|
||||
bashUseLine(long, start, 'render > a.png'),
|
||||
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
|
||||
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
|
||||
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
|
||||
];
|
||||
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
|
||||
});
|
||||
|
||||
it('keeps only the head of a long command (the pending list is persisted)', () => {
|
||||
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
|
||||
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
|
||||
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: images sent to the user', () => {
|
||||
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
|
||||
const line = toolResultLine(
|
||||
'toolu_send',
|
||||
'2026-10-15T20:50:12.538Z',
|
||||
{
|
||||
caption: 'Vier Alternativen',
|
||||
attachments: [
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
|
||||
{ path: '/tmp/s/report.pdf' },
|
||||
{ path: 'relative.png' },
|
||||
{ nope: 1 },
|
||||
],
|
||||
},
|
||||
{ agentId: 'a1' }
|
||||
);
|
||||
const { candidates, records } = parseClaudeEditLines(line);
|
||||
expect(records).toEqual([]);
|
||||
const at = Date.parse('2026-10-15T20:50:12.538Z');
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_send',
|
||||
start: at,
|
||||
end: at,
|
||||
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
|
||||
sent: true,
|
||||
agentId: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates', () => {
|
||||
it('keeps a colon inside a file:// path (timestamped names)', () => {
|
||||
expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([
|
||||
'/tmp/plot-12:30.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
|
||||
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
|
||||
'/work/repo/a.png',
|
||||
'/work/b.jpeg',
|
||||
'~/shots/c.gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads quoted paths with spaces', () => {
|
||||
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
|
||||
'/work/repo/in dir/x.png',
|
||||
'/work/repo/out dir/y.avif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores URLs, globs and names that only contain an image extension', () => {
|
||||
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
|
||||
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
|
||||
});
|
||||
|
||||
it('splits key=value and trailing punctuation off', () => {
|
||||
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
|
||||
'/work/repo/out/a.png',
|
||||
'/work/repo/out/b.png',
|
||||
'/work/repo/out/c.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates and caps the list', () => {
|
||||
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
|
||||
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
|
||||
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
|
||||
expect(out[0]).toBe('/work/repo/a.png');
|
||||
expect(out[1]).toBe('/work/repo/f0.png');
|
||||
});
|
||||
|
||||
it('drops relative paths when cwd is unknown', () => {
|
||||
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
|
||||
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
|
||||
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
|
||||
const N = 30_000;
|
||||
// Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms.
|
||||
const BUDGET_MS = 1000;
|
||||
const timed = (text: string) => {
|
||||
const t = performance.now();
|
||||
const out = extractImagePathCandidates('render', text, '', CWD);
|
||||
return { out, ms: performance.now() - t };
|
||||
};
|
||||
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
|
||||
|
||||
it.each([
|
||||
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
|
||||
['a base64 blob', base64] as const,
|
||||
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
|
||||
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
|
||||
['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const,
|
||||
['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const,
|
||||
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
|
||||
['repeated =a', '=a'.repeat(N / 2)] as const,
|
||||
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
|
||||
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
|
||||
expect(ms).toBeLessThan(BUDGET_MS);
|
||||
expect(out).toContain('/work/repo/out/plot.png');
|
||||
});
|
||||
|
||||
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 139; i++) {
|
||||
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
|
||||
lines.push(
|
||||
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
|
||||
stdout: base64,
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
const t = performance.now();
|
||||
parseClaudeEditLines(lines.join('\n'));
|
||||
expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEditPath', () => {
|
||||
it.each([
|
||||
['/a/b.png', 'image'],
|
||||
['/a/b.JPG', 'image'],
|
||||
['/a/b.jpeg', 'image'],
|
||||
['/a/b.webp', 'image'],
|
||||
['/a/b.gif', 'image'],
|
||||
['/a/b.avif', 'image'],
|
||||
['/a/b.svg', 'image'],
|
||||
['/a/b.ts', 'code'],
|
||||
['/a/png', 'code'],
|
||||
['/a/b.png.orig', 'code'],
|
||||
])('%s → %s', (path, kind) => {
|
||||
expect(classifyEditPath(path)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
|
||||
* temp transcripts — what the fakes in edit-history-sync.test.ts cannot show (file order,
|
||||
* a new conversation next to the old one, every line read exactly once).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
||||
|
||||
let root: string;
|
||||
const opened: EditHistoryStore[] = [];
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
/** One edit result line of `pad` bytes or more, newline included. */
|
||||
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
|
||||
const rec: Record<string, unknown> = {
|
||||
type: 'user',
|
||||
timestamp: new Date(T0 + i * 1000).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: `/w/${id}.ts`,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
pad: '',
|
||||
};
|
||||
if (agentId) rec.agentId = agentId;
|
||||
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
|
||||
return JSON.stringify(rec) + '\n';
|
||||
}
|
||||
|
||||
function setup(budget?: number, base = root) {
|
||||
const project = join(base, 'projects', 'p');
|
||||
mkdirSync(project, { recursive: true });
|
||||
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
|
||||
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
|
||||
let current = at('convA');
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => (existsSync(current) ? current : null),
|
||||
readSources: readClaudeEditSources,
|
||||
probe: async (paths) => paths.map(() => null),
|
||||
loadStore: async (id) => {
|
||||
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
|
||||
opened.push(store);
|
||||
return store;
|
||||
},
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
|
||||
sweepStores: async () => 0,
|
||||
budget,
|
||||
});
|
||||
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
|
||||
const ids = async () =>
|
||||
(await sync.getStore('s1'))
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort();
|
||||
return {
|
||||
project,
|
||||
at,
|
||||
sync,
|
||||
session,
|
||||
ids,
|
||||
switchTo: (transcriptId: string) => (current = at(transcriptId)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('a new conversation (/clear, resume)', () => {
|
||||
it('reads the previous transcript to its end before the new one', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup();
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
await sync.sync(session);
|
||||
// The turn's last edits land, then /clear before the panel's debounced re-read.
|
||||
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 4));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('convB');
|
||||
expect(store.knownSources()).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup(600);
|
||||
appendFileSync(at('convA'), editLine('a0', 0));
|
||||
await sync.sync(session);
|
||||
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
|
||||
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 99));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).not.toContain('b1');
|
||||
for (let i = 0; i < 5; i++) await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
|
||||
});
|
||||
|
||||
it('subagents of the previous conversation are read to their end too', async () => {
|
||||
const { project, at, sync, session, ids, switchTo } = setup();
|
||||
const sub = join(project, 'convA', 'subagents');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
|
||||
await sync.sync(session);
|
||||
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 4));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
|
||||
});
|
||||
|
||||
it('a previous transcript that is gone does not hold up the new one', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup();
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
await sync.sync(session);
|
||||
rmSync(at('convA'));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 2));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'b1']);
|
||||
});
|
||||
|
||||
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup(600);
|
||||
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
|
||||
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a3']);
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 99));
|
||||
await sync.sync(session, { older: true });
|
||||
expect(await ids()).toEqual(['a2', 'a3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every line exactly once (seeded random transcripts)', () => {
|
||||
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
|
||||
async function run(seed: number): Promise<string | null> {
|
||||
let state = seed;
|
||||
const rnd = () => {
|
||||
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
||||
return state / 0x7fffffff;
|
||||
};
|
||||
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
|
||||
const budget = ri(400, 1500);
|
||||
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
|
||||
const main = at('convA');
|
||||
const subDir = join(project, 'convA', 'subagents');
|
||||
const expected = new Set<string>();
|
||||
const tooLong = new Set<string>();
|
||||
const agents: string[] = [];
|
||||
const tails = new Map<string, string>();
|
||||
let n = 0;
|
||||
const write = () => {
|
||||
if (rnd() < 0.15 && agents.length < 6) {
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
agents.push(`a${agents.length}`);
|
||||
}
|
||||
const k = ri(0, agents.length);
|
||||
const agent = k === 0 ? undefined : agents[k - 1];
|
||||
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
|
||||
const id = `e${n++}`;
|
||||
// Some lines are longer than the budget: skipped, never shown, never blocking.
|
||||
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
|
||||
if (Buffer.byteLength(line) < budget) expected.add(id);
|
||||
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
|
||||
// Sometimes half a line: claude is still writing it.
|
||||
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
|
||||
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
|
||||
if (cut < line.length) tails.set(file, line.slice(cut));
|
||||
else tails.delete(file);
|
||||
};
|
||||
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
|
||||
if (!existsSync(main)) appendFileSync(main, '');
|
||||
for (let r = ri(5, 25); r > 0; r--) {
|
||||
for (let i = ri(0, 6); i > 0; i--) write();
|
||||
await sync.sync(session);
|
||||
}
|
||||
for (const [file, rest] of tails) appendFileSync(file, rest);
|
||||
for (let i = 0; i < 40; i++) await sync.sync(session);
|
||||
const store = await sync.getStore('s1');
|
||||
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
|
||||
const got = new Set(store.list().map((e) => e.id));
|
||||
const missing = [...expected].filter((id) => !got.has(id));
|
||||
const extra = [...got].filter((id) => tooLong.has(id));
|
||||
if (missing.length || extra.length || store.backfillTruncated) {
|
||||
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
|
||||
const failures: string[] = [];
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
const r = await run(seed);
|
||||
if (r) failures.push(r);
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
|
||||
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
|
||||
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
|
||||
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
|
||||
* process-wide for the rest of the run.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DEAD = '/mnt/dead-nas';
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
|
||||
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
|
||||
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
|
||||
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
|
||||
hangs(p)
|
||||
? new Promise(() => {})
|
||||
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
|
||||
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
|
||||
});
|
||||
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
|
||||
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
|
||||
function bashPair(id: string, start: number, end: number, stdout: string): string {
|
||||
const use = {
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
|
||||
};
|
||||
const res = {
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
|
||||
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
|
||||
};
|
||||
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
|
||||
}
|
||||
|
||||
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
|
||||
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let stores: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
|
||||
stores = join(dir, 'stores');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('edit history image probe on an unresponsive mount', () => {
|
||||
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
|
||||
const ok = join(dir, 'ok.png');
|
||||
writeFileSync(ok, 'PNG!');
|
||||
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
|
||||
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
|
||||
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: async (_path, requests, budget): Promise<EditSources> => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = Math.min(req?.offset ?? 0, main.length);
|
||||
const len = Math.min(budget, main.length - start);
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
|
||||
metas: {},
|
||||
};
|
||||
},
|
||||
probe: (paths) => probeLocalPaths(paths),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
|
||||
sweepStores: async () => 0,
|
||||
});
|
||||
|
||||
const first = sync.sync(session);
|
||||
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
|
||||
const second = sync.sync(session);
|
||||
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
|
||||
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
|
||||
await within(sync.remove('s1'), 1500, 'remove()');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* 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, 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';
|
||||
import {
|
||||
EditHistoryStore,
|
||||
entryBytes,
|
||||
MAIN_SOURCE,
|
||||
MAX_AGENT_META_CHARS,
|
||||
MAX_AGENTS,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_STORE_BYTES,
|
||||
parseAgentMeta,
|
||||
READ_BUDGET_BYTES,
|
||||
TMP_TAG,
|
||||
} from '../src/edit-history-store.js';
|
||||
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
|
||||
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
|
||||
|
||||
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'a',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
/** An Edit whose diff carries `size` bytes of added text. */
|
||||
function bigEditLine(id: string, sec: number, size: number): string {
|
||||
const line = JSON.parse(editLine(id, sec));
|
||||
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
|
||||
return JSON.stringify(line);
|
||||
}
|
||||
|
||||
function bashUse(id: string, sec: number, command: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
}
|
||||
|
||||
function bashResult(id: string, sec: number): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
}
|
||||
|
||||
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
|
||||
|
||||
/**
|
||||
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
|
||||
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
|
||||
* Null when nothing is new.
|
||||
*/
|
||||
function syncForward(
|
||||
store: EditHistoryStore,
|
||||
file: Buffer,
|
||||
budget = READ_BUDGET_BYTES,
|
||||
source = MAIN_SOURCE,
|
||||
tid = 'conv-1'
|
||||
) {
|
||||
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
|
||||
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
|
||||
const len = Math.min(budget, file.length - offset);
|
||||
if (cursor && offset === cursor.offset && len === 0) return null;
|
||||
const chunk = file.subarray(offset, offset + len);
|
||||
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.olderRange(source, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
|
||||
|
||||
describe('EditHistoryStore: forward reads', () => {
|
||||
it('reads a small file whole, then only what was appended', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
expect(syncForward(store, file)?.added).toBe(2);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
|
||||
expect(syncForward(store, file)).toBeNull();
|
||||
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a partial last line (still being written) for the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
const firstLineEnd = full.indexOf(0x0a) + 1;
|
||||
const partial = full.subarray(0, firstLineEnd + 30);
|
||||
expect(syncForward(store, partial)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
|
||||
expect(syncForward(store, full)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e2', 'e1']);
|
||||
});
|
||||
|
||||
it('deduplicates by tool_use_id', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const budget = Math.floor(file.length / 3);
|
||||
syncForward(store, file, budget);
|
||||
const cursor = store.getCursor(MAIN_SOURCE)!;
|
||||
expect(cursor.offset).toBe(file.length);
|
||||
expect(cursor.floor).toBeGreaterThan(file.length - budget);
|
||||
expect(file[cursor.floor - 1]).toBe(0x0a);
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
expect(ids(store)[0]).toBe('e19');
|
||||
expect(ids(store)).not.toContain('e0');
|
||||
});
|
||||
|
||||
it('reads a backlog in budget-sized forward steps without losing a line', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
|
||||
const file = Buffer.concat([first, jsonl(more)]);
|
||||
const budget = 700;
|
||||
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
|
||||
expect(store.list()).toHaveLength(31);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget instead of re-reading it forever', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
|
||||
expect(ids(store)).toEqual(['e2', 'e0']);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => {
|
||||
const budget = 1000;
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
const lost: number[] = [];
|
||||
for (let pad = 0; pad < budget; pad++) {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, first);
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } });
|
||||
const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget
|
||||
const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, budget); i++);
|
||||
if (!store.get('next') || !store.get('e3')) lost.push(pad);
|
||||
await store.remove();
|
||||
}
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a multibyte character split by the chunk boundary intact', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const path = '/work/Größe – Ümlaut 😀.ts';
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
|
||||
const emoji = file.indexOf(Buffer.from('😀'));
|
||||
// Cut inside the 4-byte emoji, then continue.
|
||||
const cut = file.subarray(0, emoji + 2);
|
||||
syncForward(store, cut);
|
||||
syncForward(store, file);
|
||||
expect(store.get('e2')?.path).toBe(path);
|
||||
});
|
||||
|
||||
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
const replaced = jsonl([editLine('n1', 3)]);
|
||||
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
|
||||
expect(syncForward(store, replaced)?.added).toBe(0);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
|
||||
expect(syncForward(store, replaced)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
|
||||
});
|
||||
|
||||
it('ignores a chunk that does not start at the cursor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
syncForward(store, file);
|
||||
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: conversations and sources', () => {
|
||||
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const second = jsonl([editLine('f1', 5)]);
|
||||
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
|
||||
expect(store.getTranscriptId()).toBe('conv-2');
|
||||
expect(ids(store)).toEqual(['f1', 'e1']);
|
||||
});
|
||||
|
||||
it('keeps one cursor per source and the subagent id on its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const main = jsonl([editLine('m1', 1)]);
|
||||
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
|
||||
const subSource = 'subagents/agent-a1.jsonl';
|
||||
syncForward(store, main);
|
||||
syncForward(store, sub, undefined, subSource);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
|
||||
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
|
||||
expect(store.get('s1')?.agentId).toBe('a1');
|
||||
});
|
||||
|
||||
it('records agent meta from meta.json text', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
store.setAgentMeta(
|
||||
'a1',
|
||||
JSON.stringify({
|
||||
agentType: 'coding',
|
||||
description: 'Impl: fix',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
toolUseId: 'toolu_x',
|
||||
})
|
||||
);
|
||||
store.setAgentMeta('a2', 'not json');
|
||||
expect(store.getAgents()).toEqual({
|
||||
a1: {
|
||||
description: 'Impl: fix',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
},
|
||||
});
|
||||
expect(store.hasAgentMeta('a2')).toBe(false);
|
||||
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: older blocks', () => {
|
||||
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const budget = Math.floor(file.length / 4);
|
||||
syncForward(store, file, budget);
|
||||
const before = store.list().length;
|
||||
expect(before).toBeLessThan(30);
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
|
||||
expect(rounds).toBeGreaterThan(1);
|
||||
expect(store.list()).toHaveLength(30);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
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('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => {
|
||||
const budget = 1000;
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } });
|
||||
const lost: number[] = [];
|
||||
for (let pad = 0; pad < budget; pad++) {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge];
|
||||
for (let i = 0; i < 6; i++)
|
||||
lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {}));
|
||||
const file = jsonl(lines);
|
||||
syncForward(store, file, budget);
|
||||
for (let i = 0; i < 50 && syncOlder(store, file, budget); i++);
|
||||
if (!store.get('before') || !store.get('p0')) lost.push(pad);
|
||||
await store.remove();
|
||||
}
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
|
||||
it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' })));
|
||||
syncForward(store, file, 400, sub);
|
||||
expect(store.sourcesWithOlder()).toEqual([sub]);
|
||||
const kept = ids(store);
|
||||
store.dropSource(sub);
|
||||
expect(store.getCursor(sub)).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(ids(store)).toEqual(kept);
|
||||
});
|
||||
|
||||
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)));
|
||||
syncForward(store, file, 300);
|
||||
const res = store.applyOlder({
|
||||
transcriptId: 'conv-1',
|
||||
source: MAIN_SOURCE,
|
||||
startOffset: 0,
|
||||
chunk: file.subarray(0, 10),
|
||||
size: file.length,
|
||||
});
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: Bash images', () => {
|
||||
it('pairs a Bash call and its result across a chunk boundary', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
|
||||
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
|
||||
expect(syncForward(store, part1)?.candidates).toEqual([]);
|
||||
const res = syncForward(store, file)!;
|
||||
expect(res.candidates).toEqual([
|
||||
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds confirmed images as entries without a diff, once', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
|
||||
expect(store.addImageEntries([img, img])).toBe(1);
|
||||
expect(store.addImageEntries([img])).toBe(0);
|
||||
expect(store.list()[0]).toEqual({
|
||||
id: 'b1:/work/out/frame.png',
|
||||
ts: T0 + 4000,
|
||||
path: '/work/out/frame.png',
|
||||
kind: 'image',
|
||||
tool: 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
agentId: 'a1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: cap', () => {
|
||||
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const res = syncForward(store, file, file.length);
|
||||
expect(res?.added).toBe(MAX_ENTRIES);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.get('e19')).toBeUndefined();
|
||||
expect(store.get('e20')).toBeDefined();
|
||||
// Older than everything kept: counts as nothing added.
|
||||
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
|
||||
});
|
||||
|
||||
it('offers no older block once the cap is full (it would be dropped on read)', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
|
||||
syncForward(store, file, tail + 10);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const n = 300;
|
||||
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, file.length);
|
||||
const kept = store.list();
|
||||
expect(kept.length).toBeLessThan(n);
|
||||
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
|
||||
expect(store.get(`e${n - 1}`)).toBeDefined();
|
||||
expect(store.get('e0')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers no older block once the byte cap is full', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, 5_000_000);
|
||||
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the agent meta strings', () => {
|
||||
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
|
||||
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', () => {
|
||||
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
|
||||
syncForward(store, file);
|
||||
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
|
||||
await store.flush();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(ids(loaded)).toEqual(['e1']);
|
||||
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
|
||||
expect(loaded.getTranscriptId()).toBe('conv-1');
|
||||
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
|
||||
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
|
||||
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
|
||||
const sub = join(dir, 'edit-history');
|
||||
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
expect(statSync(sub).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
|
||||
await store.flush();
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('applies the caps and drops malformed entries when loading', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
|
||||
await store.flush();
|
||||
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
|
||||
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
|
||||
data.entries.push(
|
||||
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
|
||||
{ id: 'broken', path: 7, hunks: [] },
|
||||
{ id: 'nohunks', path: '/a.ts' },
|
||||
extra
|
||||
);
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(loaded.get('newest')).toBeDefined();
|
||||
expect(loaded.get('e0')).toBeUndefined();
|
||||
expect(loaded.get('broken')).toBeUndefined();
|
||||
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('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => {
|
||||
const good = {
|
||||
ts: 1,
|
||||
path: '/a.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 0,
|
||||
truncated: false,
|
||||
};
|
||||
writeFileSync(
|
||||
join(dir, 's1.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sessionId: 's1',
|
||||
transcriptId: 'conv-1',
|
||||
cursors: {},
|
||||
pendingBash: [
|
||||
{ id: 'b1', ts: 1, command: 'ls', cwd: '/work' },
|
||||
{ id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) },
|
||||
{ id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' },
|
||||
],
|
||||
agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } },
|
||||
entries: [
|
||||
{ ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) },
|
||||
{ ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] },
|
||||
{ ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] },
|
||||
{ ...good, id: 'badtool', tool: 'Rm', hunks: [] },
|
||||
],
|
||||
})
|
||||
);
|
||||
const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 });
|
||||
expect(
|
||||
loaded
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
).toEqual(['bighunk', 'extra', 'objlines']);
|
||||
expect(loaded.get('extra')).not.toHaveProperty('blob');
|
||||
expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']);
|
||||
expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] });
|
||||
expect(loaded.getAgents()).toEqual({});
|
||||
await loaded.flush();
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id)
|
||||
).toEqual(['b1']);
|
||||
});
|
||||
|
||||
it("counts an entry's id against the byte cap", () => {
|
||||
const e = {
|
||||
id: 'i'.repeat(1000),
|
||||
ts: 1,
|
||||
path: '/a.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
} as const;
|
||||
expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999);
|
||||
});
|
||||
|
||||
it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
const text = readFileSync(join(dir, 's1.json'), 'utf-8');
|
||||
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
|
||||
writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON
|
||||
expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => {
|
||||
for (const name of [
|
||||
'live.json',
|
||||
'gone.json',
|
||||
`gone.json.${process.pid + 1}.tmp`,
|
||||
// An earlier run that had this process's pid (a container's pid 1): stale as well.
|
||||
`live.json.${process.pid}.tmp`,
|
||||
`live.json.${process.pid}-0a1b2c3d.tmp`,
|
||||
`live.json.${TMP_TAG}.tmp`,
|
||||
'notes.txt',
|
||||
]) {
|
||||
writeFileSync(join(dir, name), '{}');
|
||||
}
|
||||
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
|
||||
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
|
||||
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
|
||||
});
|
||||
|
||||
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
|
||||
const cursors = { main: { offset: 10, floor: 0 } };
|
||||
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
|
||||
const store = await EditHistoryStore.load('s1', { dir });
|
||||
expect(store.getTranscriptId()).toBeNull();
|
||||
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
|
||||
}
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
|
||||
const store = await EditHistoryStore.load('s1', { dir });
|
||||
expect(store.getTranscriptId()).toBe('conv-1.a_b');
|
||||
expect(store.knownSources()).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('settle() writes pending changes and nothing for a store without any', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.settle();
|
||||
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
rmSync(join(dir, 's1.json'));
|
||||
await loaded.settle();
|
||||
await new EditHistoryStore('empty', { dir }).settle();
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('delete() removes a stored history without parsing it', async () => {
|
||||
writeFileSync(join(dir, 's1.json'), 'not json');
|
||||
await EditHistoryStore.delete('s1', { dir });
|
||||
await EditHistoryStore.delete('missing', { dir });
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('writes debounced', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const path = join(dir, 's1.json');
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts empty from a missing or corrupt file', async () => {
|
||||
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
|
||||
const store = new EditHistoryStore('bad', { dir });
|
||||
await store.flush();
|
||||
writeFileSync(join(dir, 'bad.json'), '{broken');
|
||||
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('remove() deletes the file and stops later writes', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
await store.remove();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
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();
|
||||
// Let the write get past its first check, so remove() meets it under way.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await Promise.all([writing, store.remove()]);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults to the data directory', () => {
|
||||
const store = new EditHistoryStore('s1');
|
||||
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
|
||||
* "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 { existsSync, 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 {
|
||||
EditSourceReadOptions,
|
||||
EditSourceRequest,
|
||||
EditSources,
|
||||
PathProbe,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
import {
|
||||
confirmImages,
|
||||
EditHistorySync,
|
||||
MAX_LOADED_STORES,
|
||||
MAX_OLDER_SOURCES,
|
||||
NO_TRANSCRIPT,
|
||||
REMOVED,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
unsupportedReason,
|
||||
} from '../src/web/edit-history-sync.js';
|
||||
|
||||
let dir: string;
|
||||
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
|
||||
const opened: EditHistoryStore[] = [];
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function bashPair(id: string, start: number, end: number, command: string): string {
|
||||
const use = JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
return use + '\n' + res + '\n';
|
||||
}
|
||||
|
||||
/** An in-memory transcript: files by relPath, served with the reader contract. */
|
||||
class FakeHost {
|
||||
transcriptId = 'conv1';
|
||||
files = new Map<string, Buffer>();
|
||||
metas: Record<string, string> = {};
|
||||
calls: EditSourceRequest[][] = [];
|
||||
paths: string[] = [];
|
||||
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
|
||||
get path(): string | null {
|
||||
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
|
||||
}
|
||||
read = async (
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> => {
|
||||
this.calls.push([...requests]);
|
||||
this.paths.push(mainPath);
|
||||
if (this.files.size === 0) return null;
|
||||
let remaining = budget;
|
||||
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?.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(remaining, buf.length - start);
|
||||
} else {
|
||||
len = Math.min(buf.length, remaining);
|
||||
start = buf.length - len;
|
||||
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
|
||||
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
|
||||
}
|
||||
remaining -= len;
|
||||
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
append(relPath: string, text: string): void {
|
||||
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
|
||||
}
|
||||
}
|
||||
|
||||
function setup(over: Partial<EditHistorySyncDeps> = {}) {
|
||||
const host = new FakeHost();
|
||||
const probes = new Map<string, PathProbe>();
|
||||
const lookups: string[] = [];
|
||||
/** Session ids in the order their stores were loaded. */
|
||||
const loads: string[] = [];
|
||||
const deps: EditHistorySyncDeps = {
|
||||
transcriptPath: async (session) => {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
|
||||
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
|
||||
sweepStores: async () => 0,
|
||||
...over,
|
||||
};
|
||||
const load = deps.loadStore;
|
||||
deps.loadStore = async (id) => {
|
||||
loads.push(id);
|
||||
const store = await load(id);
|
||||
opened.push(store);
|
||||
return store;
|
||||
};
|
||||
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
|
||||
}
|
||||
|
||||
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
const remote: EditHistorySession = {
|
||||
id: 's2',
|
||||
mode: 'claude',
|
||||
remote: { host: 'huff' } as EditHistorySession['remote'],
|
||||
claudeSessionId: 'conv1',
|
||||
};
|
||||
|
||||
describe('unsupportedReason', () => {
|
||||
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBe('remote');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistorySync', () => {
|
||||
it('reads new edits forward from the stored offset', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
|
||||
]);
|
||||
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
|
||||
});
|
||||
|
||||
it('reads subagent transcripts and records their meta', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
|
||||
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('t9')?.agentId).toBe('a1');
|
||||
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
|
||||
});
|
||||
|
||||
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(local);
|
||||
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
|
||||
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('conv2');
|
||||
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
|
||||
});
|
||||
|
||||
it('keeps reading in passes when a file grew by more than one budget', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
host.append('main', editLine('t0', T0, '/w/z.ts'));
|
||||
await sync.sync(local);
|
||||
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
|
||||
const { host, probes, sync } = setup();
|
||||
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
|
||||
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
|
||||
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
|
||||
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
|
||||
['/w/out/new.png', 'bash', 'image'],
|
||||
['/home/u/x.png', 'bash', 'image'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
|
||||
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
|
||||
let removal: Promise<void> | undefined;
|
||||
const ctx = setup({
|
||||
probe,
|
||||
readSources: async (path, req, budget, opts) => {
|
||||
const res = await ctx.host.read(path, req, budget, opts);
|
||||
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
|
||||
return res;
|
||||
},
|
||||
});
|
||||
ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png'));
|
||||
ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png'));
|
||||
await ctx.sync.sync(local);
|
||||
await removal;
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the edits when only the image probe fails', async () => {
|
||||
const { host, sync } = setup({
|
||||
probe: async () => {
|
||||
throw new Error('probe failed');
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('reports a session without a transcript yet, then reads it once there is one', async () => {
|
||||
const { host, sync } = setup();
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.calls).toEqual([]);
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
|
||||
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.paths).toEqual(['/p/gone.jsonl']);
|
||||
});
|
||||
|
||||
it('reports a read error as unavailable and keeps the stored entries', async () => {
|
||||
const { host, sync, deps } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
deps.readSources = async () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
};
|
||||
const status = await sync.sync(local);
|
||||
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
|
||||
const { host, lookups, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
|
||||
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
|
||||
expect(lookups).toEqual([]);
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs one sync per session at a time and folds occasions into one more run', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
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), sync.sync(local)];
|
||||
release();
|
||||
await Promise.all(runs);
|
||||
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
|
||||
});
|
||||
|
||||
it('loads older blocks on request', async () => {
|
||||
const { host, sync } = setup({ budget: 400 });
|
||||
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
const before = store.list().length;
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.list().length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
|
||||
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await sync.flushAll();
|
||||
const reloaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes the stored history', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await (await sync.getStore('s1')).flush();
|
||||
await sync.remove('s1');
|
||||
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
|
||||
});
|
||||
|
||||
it('neither a run in flight nor a request during the removal writes the deleted history 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));
|
||||
let entered!: () => void;
|
||||
const reading = new Promise<void>((r) => (entered = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
entered();
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
|
||||
await reading;
|
||||
const removing = sync.remove('s1');
|
||||
// The removal waits for the read under way; a request meanwhile reads nothing.
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
|
||||
release();
|
||||
await Promise.all([...runs, removing]);
|
||||
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
|
||||
});
|
||||
|
||||
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
|
||||
const path = '/p/./conv1.jsonl';
|
||||
const { host, sync } = setup({ transcriptPath: async () => path });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
expect(host.paths).toEqual([path, path]);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
|
||||
await sync.remove('s1');
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes a history it has not loaded without loading it', async () => {
|
||||
const { sync, loads } = setup();
|
||||
const other = new EditHistoryStore('s1', { dir });
|
||||
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
|
||||
await other.flush();
|
||||
await sync.remove('s1');
|
||||
expect(loads).toEqual([]);
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
|
||||
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
|
||||
for (const id of ids) await sync.sync({ ...local, id });
|
||||
expect(loads).toEqual(ids);
|
||||
// The two least recently used went: written (no debounced save ran), not kept in memory.
|
||||
await sync.flushAll();
|
||||
expect(existsSync(join(dir, 's0.json'))).toBe(true);
|
||||
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
expect(loads.slice(ids.length)).toEqual(['s0']);
|
||||
// The most recently used ones stay loaded.
|
||||
await sync.getStore(ids[ids.length - 1]);
|
||||
expect(loads.slice(ids.length)).toEqual(['s0']);
|
||||
});
|
||||
|
||||
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
|
||||
const { sync } = setup(); // no transcript: nothing to read
|
||||
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
|
||||
await sync.flushAll();
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a store whose run is in flight is not dropped, and loads once', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const { host, sync, loads } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
const orig = host.read;
|
||||
let first = true;
|
||||
host.read = async (...a) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
await gate;
|
||||
}
|
||||
return orig(...a);
|
||||
};
|
||||
const slow = sync.sync(local);
|
||||
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
|
||||
release();
|
||||
expect(await slow).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => {
|
||||
const budget = 4000;
|
||||
const { host, sync } = setup({ budget });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
// main grows by almost a whole budget, leaving the subagent 50 bytes of this pass
|
||||
const filler = JSON.stringify({ type: 'user', message: { content: '' } });
|
||||
host.append(
|
||||
'main',
|
||||
JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n'
|
||||
);
|
||||
host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) }));
|
||||
host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(
|
||||
store
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
).toEqual(['m0', 's0', 's1', 's2']);
|
||||
expect(store.getCursor(sub)?.midLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a first read reaches the subagents in a further pass when main fills the budget', async () => {
|
||||
const { host, sync } = setup({ budget: 3000 });
|
||||
for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('s0')?.agentId).toBe('a1');
|
||||
expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined();
|
||||
});
|
||||
|
||||
it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => {
|
||||
const budget = 2000;
|
||||
const { host, sync } = setup({ budget });
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3
|
||||
for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
for (const a of ['a1', 'a2']) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
host.append(
|
||||
`subagents/agent-${a}.jsonl`,
|
||||
editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a })
|
||||
);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 3; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.sourcesWithOlder()).toHaveLength(3);
|
||||
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.get('big')).toBeDefined();
|
||||
expect(store.list()).toHaveLength(36);
|
||||
});
|
||||
|
||||
it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`);
|
||||
for (const relPath of ['main', ...subs]) {
|
||||
for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`));
|
||||
}
|
||||
for (let i = 0; i < 3; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.sourcesWithOlder()).toHaveLength(5);
|
||||
const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(olderCall()).toEqual(['main', subs[0], subs[1]]);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(olderCall()).toEqual([subs[2], subs[3], 'main']);
|
||||
for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.list()).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' }));
|
||||
for (let i = 0; i < 2; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
const kept = store.list().length;
|
||||
host.files.delete(sub);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.getCursor(sub)).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(store.list()).toHaveLength(kept);
|
||||
});
|
||||
|
||||
it('sweep keeps the sessions in use here and those that exist', async () => {
|
||||
const asked: Record<string, boolean> = {};
|
||||
const { sync } = setup({
|
||||
sweepStores: async (keep) => {
|
||||
for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id);
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
await sync.getStore('s1');
|
||||
expect(await sync.sweep((id) => id === 'kept')).toBe(1);
|
||||
expect(asked).toEqual({ s1: true, kept: true, gone: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmImages', () => {
|
||||
it('ignores directories and missing files', () => {
|
||||
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
|
||||
const out = confirmImages(c, (p) =>
|
||||
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists a file sent to the user outside any window, once per file version', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
|
||||
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
|
||||
expect(confirmImages([sent], () => png)).toEqual([
|
||||
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
|
||||
]);
|
||||
// The Bash call that wrote it was already listed: same version, not again.
|
||||
expect(
|
||||
confirmImages(
|
||||
[sent],
|
||||
() => png,
|
||||
() => T0 + 500
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(confirmImages([sent], () => null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
|
||||
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
|
||||
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
|
||||
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
|
||||
// A Write entry for the same path at T0 + 950 already covers it.
|
||||
expect(
|
||||
confirmImages(
|
||||
[ls],
|
||||
() => png,
|
||||
() => T0 + 950
|
||||
)
|
||||
).toEqual([]);
|
||||
// A later re-render (newer mtime, outside the slack) is a new version.
|
||||
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
|
||||
const newer = { ...png, mtimeMs: T0 + 10_500 };
|
||||
expect(
|
||||
confirmImages(
|
||||
[again],
|
||||
() => newer,
|
||||
() => T0 + 1000
|
||||
).map((i) => i.toolUseId)
|
||||
).toEqual(['r2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads only on request (wiring guard)', () => {
|
||||
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
|
||||
|
||||
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
|
||||
expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']);
|
||||
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
|
||||
});
|
||||
|
||||
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
|
||||
const root = join(__dirname, '..');
|
||||
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
|
||||
.filter((f) => /\.(ts|js)$/.test(f))
|
||||
.map((f) => join('src', f).split('\\').join('/'))
|
||||
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
|
||||
expect(holders.sort()).toEqual([
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
'src/web/server.ts',
|
||||
]);
|
||||
for (const file of [
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/edit-history-store.ts',
|
||||
'src/edit-history-local-source.ts',
|
||||
'src/edit-history-parse.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
]) {
|
||||
const src = read(file);
|
||||
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
|
||||
}
|
||||
// The store's only timer is its debounced save.
|
||||
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
|
||||
});
|
||||
});
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
|
||||
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
|
||||
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
|
||||
* events the open panel follows go through the real server.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page, type Route } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
ts: number;
|
||||
path: string;
|
||||
relPath: string;
|
||||
kind: 'code' | 'image';
|
||||
tool: string;
|
||||
created: boolean;
|
||||
added: number;
|
||||
removed: number;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
describe('Edit history panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
|
||||
let ext: string;
|
||||
let sessionA: string;
|
||||
let sessionB: string;
|
||||
const settingsPutStatuses: number[] = [];
|
||||
/** 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[] = [];
|
||||
/** The mocked server state per session id. */
|
||||
const state = new Map<string, Record<string, unknown>>();
|
||||
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
|
||||
let olderEntries: Entry[] = [];
|
||||
|
||||
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
|
||||
ts: Date.now(),
|
||||
path: join(root, over.relPath),
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const json = (route: Route, data: unknown) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
|
||||
|
||||
const buttonVisible = () =>
|
||||
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
|
||||
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
|
||||
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
|
||||
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
|
||||
const toolEnd = (sessionId: string, toolName: string) =>
|
||||
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
|
||||
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
|
||||
writeFileSync(join(root, 'shot.png'), PNG);
|
||||
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
|
||||
writeFileSync(join(ext, 'render.png'), PNG);
|
||||
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
|
||||
attachmentPosts.push(r.postDataJSON());
|
||||
});
|
||||
await page.route('**/api/sessions/*/edits**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
calls.push(`${req.method()} ${url.pathname}${url.search}`);
|
||||
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
|
||||
const sid = decodeURIComponent(m[1]);
|
||||
const rest = m[2] ? decodeURIComponent(m[2]) : '';
|
||||
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
|
||||
if (req.method() === 'POST' && rest === 'older') {
|
||||
const entries = [...(s.entries as Entry[]), ...olderEntries];
|
||||
state.set(sid, { ...s, entries, backfillTruncated: false });
|
||||
return json(route, state.get(sid));
|
||||
}
|
||||
if (req.method() === 'GET' && rest) {
|
||||
const d = diffs[rest];
|
||||
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
|
||||
return json(route, d);
|
||||
}
|
||||
return json(route, s);
|
||||
});
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
sessionA = await createSession(root);
|
||||
sessionB = await createSession(root);
|
||||
await select(sessionA);
|
||||
|
||||
const t0 = Date.now();
|
||||
state.set(sessionA, {
|
||||
state: 'ok',
|
||||
backfillTruncated: true,
|
||||
agents: {
|
||||
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
|
||||
},
|
||||
entries: [
|
||||
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
|
||||
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
|
||||
entry({
|
||||
id: 'e2',
|
||||
ts: t0 - 2000,
|
||||
relPath: 'src/b.ts',
|
||||
created: true,
|
||||
tool: 'Write',
|
||||
added: 10,
|
||||
removed: 0,
|
||||
agentId: 'agent1',
|
||||
}),
|
||||
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
|
||||
],
|
||||
});
|
||||
diffs = {
|
||||
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
|
||||
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
|
||||
};
|
||||
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(ext, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button, and nothing asks the edits API', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
expect(await buttonVisible()).toBe(true);
|
||||
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
|
||||
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
|
||||
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
|
||||
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
|
||||
});
|
||||
|
||||
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
|
||||
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
|
||||
expect(await rows().count()).toBe(4);
|
||||
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
|
||||
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
|
||||
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
|
||||
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
|
||||
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
|
||||
// The image row: a symbol instead of a word, no +/− counts.
|
||||
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
|
||||
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
|
||||
// The subagent chip: its description, and type + branch on hover.
|
||||
const chip = rows().nth(2).locator('.edit-history-agent');
|
||||
expect(await chip.textContent()).toBe('Refactor the parser');
|
||||
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
|
||||
// The thumbnail is served by the real file-raw route and decodes.
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
|
||||
expect(await thumb.count()).toBe(1);
|
||||
expect(await thumb.getAttribute('src')).toBe(
|
||||
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
expect(await page.textContent('#editHistoryCount')).toBe('4');
|
||||
});
|
||||
|
||||
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
|
||||
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
|
||||
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
|
||||
['', ' context\n'],
|
||||
['git-diff-line--del', '-old line\n'],
|
||||
['git-diff-line--add', '+new line\n'],
|
||||
['git-diff-line--add', '+another\n'],
|
||||
]);
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
|
||||
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
|
||||
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
|
||||
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
|
||||
await reload();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
|
||||
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||||
await page.evaluate(() => {
|
||||
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();
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
|
||||
[join(root, 'src/b.ts'), sessionA],
|
||||
[join(root, 'shot.png'), sessionA],
|
||||
]);
|
||||
// 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 () => {
|
||||
await page.check('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(3);
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
|
||||
expect(await bodyText()).not.toContain('src/b.ts');
|
||||
await page.uncheck('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(4);
|
||||
|
||||
await page.check('#editHistoryGroupToggle');
|
||||
const groups = page.locator('#editHistoryBody .edit-history-group');
|
||||
expect(await groups.count()).toBe(3);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
|
||||
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
|
||||
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
|
||||
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
|
||||
await page.uncheck('#editHistoryGroupToggle');
|
||||
});
|
||||
|
||||
it('"Load older" asks the server with a POST and shows what it returns', async () => {
|
||||
const older = page.locator('#editHistoryBody button.edit-history-older');
|
||||
expect(await older.count()).toBe(1);
|
||||
await older.click();
|
||||
await page.waitForFunction(() =>
|
||||
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
|
||||
expect(await rows().count()).toBe(5);
|
||||
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
|
||||
const before = syncReads(sessionA);
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
|
||||
});
|
||||
toolEnd(sessionA, 'Read');
|
||||
toolEnd(sessionA, 'Grep');
|
||||
toolEnd('someone-else', 'Edit');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
toolEnd(sessionA, 'Bash');
|
||||
await page.waitForFunction(() =>
|
||||
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
|
||||
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
|
||||
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
|
||||
(server as any).broadcast('session:idle', { id: 'someone-else' });
|
||||
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
(server as any).broadcast('session:idle', { id: sessionA });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 2);
|
||||
});
|
||||
|
||||
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
|
||||
const before = syncReads(sessionA);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
(server as any).broadcast('session:idle', { id: sessionA });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
});
|
||||
|
||||
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, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
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).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);
|
||||
});
|
||||
|
||||
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const long =
|
||||
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const row = rows().nth(0);
|
||||
await row.waitFor();
|
||||
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
|
||||
const name = row.locator('.edit-history-path-name');
|
||||
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
|
||||
// The name is shown whole; only the folder part is shortened.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('renders a hostile path as text, never as markup', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
|
||||
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
|
||||
});
|
||||
await reload();
|
||||
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
|
||||
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
|
||||
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
|
||||
expect(await page.$('#editHistoryBody script')).toBeNull();
|
||||
expect(await page.$('#editHistoryBody b')).toBeNull();
|
||||
expect(
|
||||
await page.$$eval(
|
||||
'#editHistoryBody img',
|
||||
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
|
||||
)
|
||||
).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unavailable: the saved entries stay, with the reason on top', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
|
||||
'Showing saved entries only: EACCES: permission denied'
|
||||
);
|
||||
expect(await rows().count()).toBeGreaterThan(0);
|
||||
state.set(sessionA, { ...s, state: 'ok' });
|
||||
});
|
||||
|
||||
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||||
expect(note).toContain('No transcript found for this session yet');
|
||||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('follows the active session, and says when a session is not a Claude session', async () => {
|
||||
await select(sessionB);
|
||||
await page.waitForFunction(() =>
|
||||
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
|
||||
expect(await rows().count()).toBe(0);
|
||||
await select(sessionA);
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.edit-history-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
|
||||
const before = calls.length;
|
||||
toolEnd(sessionA, 'Edit');
|
||||
await page.waitForTimeout(1200);
|
||||
expect(calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||
// The edit history button is opt-in the same way, but it IS here: its panel is
|
||||
// desktop-sized, so it stays off phones even when the setting is on.
|
||||
const KNOWN_PHONE_HIDDEN = [
|
||||
'btn-settings',
|
||||
'btn-lifecycle-log',
|
||||
'btn-session-manager',
|
||||
'btn-file-viewer',
|
||||
'btn-edit-history',
|
||||
];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -98,6 +98,13 @@ export class MockSession extends EventEmitter {
|
||||
claudeSessionIdIsFirstHand: boolean = false;
|
||||
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
|
||||
claudeSessionChain: string[] = [];
|
||||
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
|
||||
claudeConfigDirOverride: string | undefined = undefined;
|
||||
|
||||
/** Mirrors Session.claudeConfigDir — the override, trimmed; undefined when unset. */
|
||||
claudeConfigDir(): string | undefined {
|
||||
return this.claudeConfigDirOverride?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
|
||||
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
|
||||
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
|
||||
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
|
||||
import { EditHistoryStore } from '../../src/edit-history-store.js';
|
||||
import type { EditSources } from '../../src/edit-history-local-source.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
let dir: string;
|
||||
let session: Record<string, unknown>;
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function sources(main: string, sub?: string): EditSources {
|
||||
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
|
||||
const metas: Record<string, string> = {};
|
||||
if (sub) {
|
||||
files.push({
|
||||
relPath: 'subagents/agent-a1.jsonl',
|
||||
size: Buffer.byteLength(sub),
|
||||
startOffset: 0,
|
||||
chunk: Buffer.from(sub),
|
||||
});
|
||||
metas.a1 = JSON.stringify({
|
||||
description: 'Fix it',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
});
|
||||
}
|
||||
return { transcriptId: 'conv1', files, metas };
|
||||
}
|
||||
|
||||
async function setup(
|
||||
deps: Partial<EditHistorySyncDeps> = {},
|
||||
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
|
||||
) {
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: read,
|
||||
probe: async (p) => p.map(() => null),
|
||||
loadStore: async (id) => {
|
||||
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
|
||||
opened.push(store);
|
||||
return store;
|
||||
},
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
|
||||
sweepStores: async () => 0,
|
||||
...deps,
|
||||
});
|
||||
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
|
||||
authUser: opts.authUser,
|
||||
});
|
||||
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||
session.workingDir = '/w';
|
||||
session.mode = 'claude';
|
||||
session.claudeSessionId = 'conv1';
|
||||
return { ...h, read, sync };
|
||||
}
|
||||
|
||||
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
|
||||
const opened: EditHistoryStore[] = [];
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const URL = '/api/sessions/test-session-1/edits';
|
||||
|
||||
describe('GET /api/sessions/:id/edits', () => {
|
||||
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
|
||||
expect(data.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 't1',
|
||||
path: '/w/src/a.ts',
|
||||
relPath: 'src/a.ts',
|
||||
tool: 'Edit',
|
||||
added: 2,
|
||||
removed: 1,
|
||||
}),
|
||||
]);
|
||||
expect(data.entries[0]).not.toHaveProperty('hunks');
|
||||
});
|
||||
|
||||
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
|
||||
const { app, sync } = await setup();
|
||||
const calls: string[] = [];
|
||||
const syncFn = sync.sync.bind(sync);
|
||||
const getStore = sync.getStore.bind(sync);
|
||||
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
|
||||
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
await app.inject({ method: 'POST', url: `${URL}/older` });
|
||||
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
|
||||
});
|
||||
|
||||
it('without sync=1 answers from the store and does not read', async () => {
|
||||
const { app, read } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: URL });
|
||||
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('labels subagent entries and shows their paths relative to the worktree', async () => {
|
||||
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
|
||||
const { app } = await setup({ readSources: read });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data.agents).toEqual({
|
||||
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
|
||||
});
|
||||
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
|
||||
agentId: 'a1',
|
||||
relPath: 'lib/x.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('a read error answers unavailable with the stored entries', async () => {
|
||||
let fail = false;
|
||||
const read = vi.fn(async () => {
|
||||
if (fail) throw new Error('EACCES: permission denied');
|
||||
return sources(editLine('t1', T0, '/w/src/a.ts'));
|
||||
});
|
||||
const { app } = await setup({ readSources: read });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
fail = true;
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect(data.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
|
||||
const seen: unknown[] = [];
|
||||
const { app } = await setup({
|
||||
transcriptPath: async (s) => {
|
||||
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
|
||||
return '/p/conv1.jsonl';
|
||||
},
|
||||
});
|
||||
session.claudeConfigDirOverride = '/cfg/alt ';
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
|
||||
});
|
||||
|
||||
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
|
||||
const { app, read } = await setup({ transcriptPath: async () => null });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { remote: { host: 'huff' } }],
|
||||
['docker', { docker: { image: 'x' } }],
|
||||
['mode', { mode: 'shell' }],
|
||||
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
|
||||
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
|
||||
const { app, read } = await setup({ loadStore });
|
||||
Object.assign(session, patch);
|
||||
const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
for (const url of [URL, `${URL}?sync=1`]) {
|
||||
expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty);
|
||||
}
|
||||
expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty);
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(loadStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found and nothing is read', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/edits/:entryId', () => {
|
||||
it('returns the diff as unified text', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
|
||||
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
|
||||
});
|
||||
|
||||
it('404s an edit the session does not have', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s diff is not found', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/edits/older', () => {
|
||||
it('reads the next older block and lists the result', async () => {
|
||||
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
|
||||
const full = Buffer.from(lines);
|
||||
const read = vi.fn(
|
||||
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = req ? req.offset : full.length - budget;
|
||||
const len = req?.len ?? full.length - start;
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
metas: {},
|
||||
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
const { app } = await setup({ readSources: read, budget: 400 });
|
||||
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(first.backfillTruncated).toBe(true);
|
||||
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(older.entries.length).toBeGreaterThan(first.entries.length);
|
||||
});
|
||||
|
||||
it('answers unsupported for a remote session without reading', async () => {
|
||||
const { app, read } = await setup();
|
||||
session.remote = { host: 'huff' };
|
||||
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('relativeTo strips only a real directory prefix', () => {
|
||||
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
|
||||
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
|
||||
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
|
||||
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
|
||||
});
|
||||
|
||||
it('hunksToUnified writes one header per hunk', () => {
|
||||
expect(
|
||||
hunksToUnified([
|
||||
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
|
||||
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
|
||||
])
|
||||
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
|
||||
});
|
||||
});
|
||||
@@ -1011,6 +1011,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('a persisted-only delete drops the agent inbox the detach kept', async () => {
|
||||
|
||||
@@ -420,6 +420,19 @@ describe('system-routes', () => {
|
||||
agentInbox.resetForTests();
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes the edit history of every session it cleaned (diffs can hold secrets)', async () => {
|
||||
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(async () => ({
|
||||
count: 2,
|
||||
cleaned: [
|
||||
{ id: 'stale-1', name: 'a' },
|
||||
{ id: 'stale-2', name: 'b' },
|
||||
],
|
||||
})) as never;
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
|
||||
expect(JSON.parse(res.body).cleanedSessions).toBe(2);
|
||||
expect(vi.mocked(harness.ctx.forgetEditHistory).mock.calls).toEqual([['stale-1'], ['stale-2']]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagents ==========
|
||||
|
||||
@@ -89,6 +89,10 @@ describe('isSensitivePath', () => {
|
||||
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
|
||||
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
|
||||
['codeman agent inbox snapshot', `${HOME}/.codeman/agent-inbox.json`],
|
||||
// The edit history stores hold every diff an agent wrote (keys and .env
|
||||
// contents included): 0600 and kept out of /api/search like intents.json.
|
||||
['codeman edit history store', `${HOME}/.codeman/edit-history/s1.json`],
|
||||
['codeman edit history on a named instance', `${HOME}/.codeman-beta/edit-history/s1.json`],
|
||||
];
|
||||
|
||||
it.each(blocked)('blocks the %s', (_label, path) => {
|
||||
|
||||
Reference in new issue
Block a user