2026-10-23 14:00:48 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @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
|
2026-10-23 14:30:05 +02:00
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-10-23 15:00:43 +02:00
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-10-23 15:35:12 +02:00
|
|
|
|
* 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.
|
2026-10-23 15:00:43 +02:00
|
|
|
|
* `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.
|
2026-10-23 14:30:05 +02:00
|
|
|
|
*
|
2026-10-23 15:00:43 +02:00
|
|
|
|
* 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.
|
2026-10-23 14:00:48 +02:00
|
|
|
|
*
|
2026-10-23 16:29:01 +02:00
|
|
|
|
* WHICH SESSIONS. Claude sessions, local and remote. Docker and non-claude sessions answer
|
|
|
|
|
|
* `unsupported` and are never read. The deps get the session and read a remote one over ssh
|
|
|
|
|
|
* (`remote-files.ts`): one round trip to find its transcript, one per read. A remote read never
|
|
|
|
|
|
* wakes its host; a sleeping one is a connect timeout (`unavailable`, stored entries kept).
|
2026-10-23 14:00:48 +02:00
|
|
|
|
*
|
|
|
|
|
|
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
|
2026-10-23 16:29:01 +02:00
|
|
|
|
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing. Transcript times and mtimes come
|
|
|
|
|
|
* from the same host, so a remote host's clock skew to this server does not matter.
|
2026-10-23 14:00:48 +02:00
|
|
|
|
*
|
|
|
|
|
|
* Tests: `test/edit-history-sync.test.ts`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @module web/edit-history-sync
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-23 15:35:12 +02:00
|
|
|
|
import { basename, dirname, join } from 'node:path';
|
2026-10-23 14:00:48 +02:00
|
|
|
|
import type { SessionRemote } from '../types/session.js';
|
2026-10-23 14:30:05 +02:00
|
|
|
|
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
2026-10-23 14:00:48 +02:00
|
|
|
|
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;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/** Forward passes per sync when files grew by more than one budget. */
|
2026-10-23 14:00:48 +02:00
|
|
|
|
export const MAX_FORWARD_PASSES = 3;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/** Sources read back per "Load older" click, a full budget each. */
|
|
|
|
|
|
export const MAX_OLDER_SOURCES = 3;
|
2026-10-23 15:35:12 +02:00
|
|
|
|
/** Stores kept in memory; the least recently used one past this is dropped. */
|
|
|
|
|
|
export const MAX_LOADED_STORES = 3;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
|
|
|
|
|
|
export const NO_TRANSCRIPT = 'no-transcript';
|
2026-10-23 14:30:05 +02:00
|
|
|
|
/** `reason` once the session's history was deleted. */
|
|
|
|
|
|
export const REMOVED = 'removed';
|
2026-10-23 14:00:48 +02:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-23 16:52:09 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* The session's main transcript path, or null when it has none yet. `storedTranscriptId` is
|
|
|
|
|
|
* the conversation the stored history holds (null before the first read); see
|
|
|
|
|
|
* {@link remoteTranscriptIds}.
|
|
|
|
|
|
*/
|
|
|
|
|
|
transcriptPath(session: EditHistorySession, storedTranscriptId: string | null): Promise<string | null>;
|
2026-10-23 16:29:01 +02:00
|
|
|
|
/** The session's transcripts (a remote session's on its host). */
|
2026-10-23 14:30:05 +02:00
|
|
|
|
readSources(
|
2026-10-23 16:29:01 +02:00
|
|
|
|
session: EditHistorySession,
|
2026-10-23 14:30:05 +02:00
|
|
|
|
mainPath: string,
|
|
|
|
|
|
requests: readonly EditSourceRequest[],
|
|
|
|
|
|
budget: number,
|
|
|
|
|
|
opts?: EditSourceReadOptions
|
|
|
|
|
|
): Promise<EditSources | null>;
|
2026-10-23 16:29:01 +02:00
|
|
|
|
/** Bash image candidates of the session, on the host it runs on. */
|
|
|
|
|
|
probe(session: EditHistorySession, paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
2026-10-23 15:35:12 +02:00
|
|
|
|
/** Delete a session's stored history without loading it. */
|
|
|
|
|
|
deleteStore(sessionId: string): Promise<void>;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/** Delete the stored history of every session `keep` rejects; returns how many. */
|
|
|
|
|
|
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
budget?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface SyncOptions {
|
|
|
|
|
|
older?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 16:52:09 +02:00
|
|
|
|
/**
|
2026-10-23 16:59:14 +02:00
|
|
|
|
* The conversation ids a REMOTE session's transcript lookup considers when its claude writes
|
|
|
|
|
|
* none now; the most recently written of them wins (`buildRemoteClaudeTranscriptLocateCommand`).
|
|
|
|
|
|
* The stored history's conversation must be among them: no hook tells Codeman of a remote
|
|
|
|
|
|
* `/clear`, so `claudeSessionId` stays the launch id, and without the stored one the panel
|
|
|
|
|
|
* would flip back to the cleared conversation as soon as the remote claude exits.
|
2026-10-23 16:52:09 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export function remoteTranscriptIds(session: EditHistorySession, storedTranscriptId: string | null): string[] {
|
|
|
|
|
|
return [...new Set([storedTranscriptId, session.claudeSessionId || session.id, session.id])].filter(
|
|
|
|
|
|
(id): id is string => !!id
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:00:48 +02:00
|
|
|
|
/** Why a session has no edit history, or undefined when it can have one. */
|
|
|
|
|
|
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
|
|
|
|
|
if (session.docker) return 'docker';
|
|
|
|
|
|
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 09:50:21 +01:00
|
|
|
|
/** 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:00:48 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-11-04 17:31:48 +01:00
|
|
|
|
* entry time for that path, and an entry at or after `mtime − slack` saw this version: the
|
2026-10-23 14:00:48 +02:00
|
|
|
|
* `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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:30:05 +02:00
|
|
|
|
interface SyncWant {
|
|
|
|
|
|
forward: boolean;
|
|
|
|
|
|
older: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:00:48 +02:00
|
|
|
|
interface SessionSlot {
|
2026-10-23 14:30:05 +02:00
|
|
|
|
store?: Promise<EditHistoryStore>;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
status: EditHistoryStatus;
|
|
|
|
|
|
running?: Promise<void>;
|
2026-10-23 14:30:05 +02:00
|
|
|
|
again?: SyncWant;
|
|
|
|
|
|
/** The history was deleted: nothing reads for this session any more. */
|
|
|
|
|
|
gone?: boolean;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/** The last source the previous "Load older" read; the next one starts after it. */
|
|
|
|
|
|
olderAfter?: string;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class EditHistorySync {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
/** By session, least recently used first. */
|
2026-10-23 14:00:48 +02:00
|
|
|
|
private readonly slots = new Map<string, SessionSlot>();
|
2026-10-23 15:35:12 +02:00
|
|
|
|
/** Writes of dropped stores; loading that session again waits for its write. */
|
|
|
|
|
|
private readonly unloading = new Map<string, Promise<void>>();
|
2026-10-23 14:00:48 +02:00
|
|
|
|
private readonly budget: number;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(private readonly deps: EditHistorySyncDeps) {
|
|
|
|
|
|
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:30:05 +02:00
|
|
|
|
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
|
|
|
|
|
getStore(sessionId: string): Promise<EditHistoryStore> {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
return this.storeOf(sessionId, this.slot(sessionId));
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-23 15:35:12 +02:00
|
|
|
|
if (slot.gone) return { ...slot.status };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (why) {
|
|
|
|
|
|
slot.status = { state: 'unsupported', reason: why };
|
2026-10-23 15:35:12 +02:00
|
|
|
|
return { ...slot.status };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
2026-10-23 14:30:05 +02:00
|
|
|
|
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (slot.running) {
|
2026-10-23 14:30:05 +02:00
|
|
|
|
slot.again = {
|
|
|
|
|
|
forward: (slot.again?.forward ?? false) || want.forward,
|
|
|
|
|
|
older: (slot.again?.older ?? false) || want.older,
|
|
|
|
|
|
};
|
2026-10-23 14:00:48 +02:00
|
|
|
|
await slot.running;
|
|
|
|
|
|
// The queued run starts when the current one ends; wait for it too.
|
|
|
|
|
|
if (slot.running) await slot.running;
|
2026-10-23 15:35:12 +02:00
|
|
|
|
return { ...slot.status };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
2026-10-23 14:30:05 +02:00
|
|
|
|
slot.running = this.runLoop(session, slot, want);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
try {
|
|
|
|
|
|
await slot.running;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
slot.running = undefined;
|
2026-10-23 15:35:12 +02:00
|
|
|
|
this.unloadIdle(slot);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
2026-10-23 15:35:12 +02:00
|
|
|
|
return { ...slot.status };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Write every loaded store's pending changes (server shutdown). */
|
|
|
|
|
|
async flushAll(): Promise<void> {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
await Promise.all([
|
|
|
|
|
|
...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
|
|
|
|
|
|
...this.unloading.values(),
|
|
|
|
|
|
]);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:30:05 +02:00
|
|
|
|
/**
|
2026-10-23 15:35:12 +02:00
|
|
|
|
* 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.
|
2026-10-23 14:30:05 +02:00
|
|
|
|
*/
|
2026-10-23 14:00:48 +02:00
|
|
|
|
async remove(sessionId: string): Promise<void> {
|
2026-10-23 14:30:05 +02:00
|
|
|
|
const slot = this.slot(sessionId);
|
|
|
|
|
|
slot.gone = true;
|
|
|
|
|
|
slot.again = undefined;
|
|
|
|
|
|
slot.status = { state: 'unavailable', reason: REMOVED };
|
2026-10-23 15:35:12 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 15:35:12 +02:00
|
|
|
|
/** The session's slot, now the most recently used. */
|
2026-10-23 14:00:48 +02:00
|
|
|
|
private slot(sessionId: string): SessionSlot {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
|
|
|
|
|
|
this.slots.delete(sessionId);
|
|
|
|
|
|
this.slots.set(sessionId, slot);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
return slot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 15:35:12 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:30:05 +02:00
|
|
|
|
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;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
slot.again = undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 14:30:05 +02:00
|
|
|
|
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
|
|
|
|
|
|
let status: EditHistoryStatus;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
try {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
|
|
|
|
|
const store = await this.storeOf(session.id, slot);
|
2026-10-23 16:52:09 +02:00
|
|
|
|
const path = slot.gone ? null : await this.deps.transcriptPath(session, store.getTranscriptId());
|
2026-10-23 14:00:48 +02:00
|
|
|
|
let found: boolean;
|
|
|
|
|
|
if (!path) {
|
|
|
|
|
|
found = false;
|
|
|
|
|
|
} else if (older) {
|
2026-10-23 16:29:01 +02:00
|
|
|
|
found = await this.readOlder(session, path, store, slot);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
found = true;
|
2026-10-23 14:30:05 +02:00
|
|
|
|
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
2026-10-23 16:29:01 +02:00
|
|
|
|
const r = await this.readForward(session, path, store);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
found = r.found;
|
|
|
|
|
|
if (!r.more) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-23 14:30:05 +02:00
|
|
|
|
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
} catch (err) {
|
2026-10-23 14:30:05 +02:00
|
|
|
|
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
2026-10-23 14:30:05 +02:00
|
|
|
|
if (!slot.gone) slot.status = status;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-23 15:00:43 +02:00
|
|
|
|
* 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
|
2026-10-23 15:35:12 +02:00
|
|
|
|
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
|
2026-10-23 14:00:48 +02:00
|
|
|
|
*/
|
2026-10-23 16:29:01 +02:00
|
|
|
|
private async readForward(
|
|
|
|
|
|
session: EditHistorySession,
|
|
|
|
|
|
path: string,
|
|
|
|
|
|
store: EditHistoryStore
|
|
|
|
|
|
): Promise<{ found: boolean; more: boolean }> {
|
2026-10-23 15:35:12 +02:00
|
|
|
|
const own = this.storedTranscript(path, store);
|
|
|
|
|
|
if (own !== path && store.knownSources().length > 0) {
|
2026-10-23 16:29:01 +02:00
|
|
|
|
const previous = await this.readFrom(session, own, store, this.forwardRequests(store));
|
2026-10-23 15:35:12 +02:00
|
|
|
|
if (previous.more) return { found: true, more: true };
|
|
|
|
|
|
}
|
2026-10-23 16:29:01 +02:00
|
|
|
|
return this.readFrom(session, path, store, own === path ? this.forwardRequests(store) : []);
|
2026-10-23 15:35:12 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 {
|
2026-10-23 14:00:48 +02:00
|
|
|
|
const known = store.getTranscriptId();
|
2026-10-23 15:35:12 +02:00
|
|
|
|
return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async readFrom(
|
2026-10-23 16:29:01 +02:00
|
|
|
|
session: EditHistorySession,
|
2026-10-23 15:35:12 +02:00
|
|
|
|
path: string,
|
|
|
|
|
|
store: EditHistoryStore,
|
|
|
|
|
|
requests: EditSourceRequest[]
|
|
|
|
|
|
): Promise<{ found: boolean; more: boolean }> {
|
2026-10-23 16:29:01 +02:00
|
|
|
|
const res = await this.deps.readSources(session, path, requests, this.budget);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (!res) return { found: false, more: false };
|
2026-10-23 15:00:43 +02:00
|
|
|
|
this.dropMissing(store, requests, res);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
for (const [agentId, text] of Object.entries(res.metas)) {
|
|
|
|
|
|
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
|
|
|
|
|
|
}
|
2026-10-23 15:00:43 +02:00
|
|
|
|
const requested = new Set(requests.map((r) => r.relPath));
|
2026-10-23 14:00:48 +02:00
|
|
|
|
let more = false;
|
|
|
|
|
|
const candidates: ImageCandidate[] = [];
|
2026-11-03 09:50:21 +01:00
|
|
|
|
let parsed = false;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
for (const f of res.files) {
|
2026-11-03 09:50:21 +01:00
|
|
|
|
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
|
|
|
|
|
if (parsed) await breathe();
|
|
|
|
|
|
if (store.isRemoved()) break;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-11-03 09:50:21 +01:00
|
|
|
|
parsed = true;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-23 16:29:01 +02:00
|
|
|
|
await this.addImages(session, store, candidates);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
return { found: true, more };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
private async readOlder(
|
|
|
|
|
|
session: EditHistorySession,
|
|
|
|
|
|
path: string,
|
|
|
|
|
|
store: EditHistoryStore,
|
|
|
|
|
|
slot: SessionSlot
|
|
|
|
|
|
): Promise<boolean> {
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (!store.backfillTruncated) return true;
|
2026-10-23 15:35:12 +02:00
|
|
|
|
// Older lines belong to the stored conversation, even before a forward read moved on.
|
|
|
|
|
|
path = this.storedTranscript(path, store);
|
2026-10-23 15:00:43 +02:00
|
|
|
|
const sources = store.sourcesWithOlder().sort();
|
2026-10-23 14:30:05 +02:00
|
|
|
|
if (sources.length === 0) return true;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
// 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);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
const requests: EditSourceRequest[] = [];
|
2026-10-23 15:00:43 +02:00
|
|
|
|
for (const relPath of picked) {
|
|
|
|
|
|
const range = store.olderRange(relPath, this.budget);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (requests.length === 0) return true;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
slot.olderAfter = picked[picked.length - 1];
|
2026-10-23 16:29:01 +02:00
|
|
|
|
const res = await this.deps.readSources(session, path, requests, this.budget * requests.length, {
|
|
|
|
|
|
onlyRequested: true,
|
|
|
|
|
|
});
|
2026-10-23 14:00:48 +02:00
|
|
|
|
if (!res) return false;
|
|
|
|
|
|
if (res.transcriptId !== store.getTranscriptId()) return true;
|
2026-10-23 15:00:43 +02:00
|
|
|
|
this.dropMissing(store, requests, res);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
const wanted = new Set(requests.map((r) => r.relPath));
|
|
|
|
|
|
const candidates: ImageCandidate[] = [];
|
2026-11-03 09:46:23 +01:00
|
|
|
|
let parsed = false;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
for (const f of res.files) {
|
|
|
|
|
|
if (!wanted.has(f.relPath)) continue;
|
2026-11-03 09:50:21 +01:00
|
|
|
|
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
|
|
|
|
|
if (parsed) await breathe();
|
2026-11-03 09:46:23 +01:00
|
|
|
|
parsed = true;
|
2026-11-03 09:50:21 +01:00
|
|
|
|
if (slot.gone) return true;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
const r = store.applyOlder({
|
|
|
|
|
|
transcriptId: res.transcriptId,
|
|
|
|
|
|
source: f.relPath,
|
|
|
|
|
|
startOffset: f.startOffset,
|
|
|
|
|
|
chunk: f.chunk,
|
|
|
|
|
|
size: f.size,
|
|
|
|
|
|
});
|
|
|
|
|
|
candidates.push(...r.candidates);
|
|
|
|
|
|
}
|
2026-10-23 16:29:01 +02:00
|
|
|
|
await this.addImages(session, store, candidates);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 15:00:43 +02:00
|
|
|
|
/** 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
private async addImages(
|
|
|
|
|
|
session: EditHistorySession,
|
|
|
|
|
|
store: EditHistoryStore,
|
|
|
|
|
|
candidates: readonly ImageCandidate[]
|
|
|
|
|
|
): Promise<void> {
|
2026-10-23 14:00:48 +02:00
|
|
|
|
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
|
2026-11-03 09:50:21 +01:00
|
|
|
|
// A removed store keeps nothing: do not probe agent-printed paths for it.
|
|
|
|
|
|
if (paths.length === 0 || store.isRemoved()) return;
|
2026-10-23 14:00:48 +02:00
|
|
|
|
let probes: Array<PathProbe | null>;
|
|
|
|
|
|
try {
|
2026-10-23 16:29:01 +02:00
|
|
|
|
probes = await this.deps.probe(session, paths);
|
2026-10-23 14:00:48 +02:00
|
|
|
|
} 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)
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|