feat(edit-history): read remote (SSH) claude sessions, never waking the host

The edit history panel now covers remote claude sessions. The sync stays
path-based; its deps get the session and server.ts routes them to the local
reader or to two new ssh reads in remote-files.ts:

- remoteLocateClaudeTranscript: one round trip; the conversation the remote
  pane's claude writes now (~/.claude/sessions/<pid>.json via the pane's
  process tree, since remote hosts send no hooks), else claudeSessionId,
  else the session id, newest $HOME/.claude/projects/*/<id>.jsonl.
- remoteReadClaudeEditSources: one round trip per read pass, with the local
  reader's semantics (offset clamp, block end at offset+len, new files read
  their tail, shared main-first budget, onlyRequested, 8 KiB meta cap).

So /clear catch-up, "Load older", cursors and the store LRU work remotely
unchanged. Reads use buildRemoteFileCommand + runWithRemoteSshLimit only:
a sleeping host is a connect timeout -> unavailable, stored entries kept.

Tests: both scripts run on a real sh against a temp HOME; the read is
compared byte for byte with readClaudeEditSources on 60 seeded random
transcripts. A transitive import guard pins that the edit-history read path
reaches neither remote-wake.ts nor session-routes.ts. Browser: a remote
session lists images without thumbnails, a click previews. Docs updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-11-05 11:19:46 +01:00
1 parent 9eac5ecf01
commit c4ba14235f
15 files changed
+968 -62

No files matched your search

+362
View File
@@ -32,6 +32,7 @@ import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
const execAsync = promisify(exec);
@@ -413,6 +414,367 @@ export async function remoteReadFile(remote: SessionRemote, remotePath: string,
}
}
/** Conversation ids are uuids; anything else never reaches the remote shell. */
const CLAUDE_TRANSCRIPT_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Exit status the edit-history scripts use for "no such transcript on that host". */
const TRANSCRIPT_NOT_FOUND_EXIT = 3;
/** Transcript files an edit-source request may name; nothing else reaches the shell. */
const EDIT_SOURCE_REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
/** The meta record's relPath; group 1 is the agentId. */
const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.meta\.json$/;
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
const EDIT_SOURCE_META_MAX_BYTES = 8192;
/** Cap on all meta records of one read; a meta past it is sent with a later read. */
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
/** Room for the record headers (one per transcript file) on top of budget and metas. */
const EDIT_SOURCE_HEADER_SLACK_BYTES = 1024 * 1024;
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
export interface RemoteClaudePane {
socket: string;
sessionName: string;
}
function isByteCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
/** De-duplicated ids that match {@link CLAUDE_TRANSCRIPT_ID_PATTERN}, in order. */
function safeClaudeTranscriptIds(ids: readonly string[]): string[] {
return [...new Set(ids)].filter((id) => CLAUDE_TRANSCRIPT_ID_PATTERN.test(id));
}
/** A main transcript path the read script takes: absolute, `<id>.jsonl`, no NUL or newline. */
export function isRemoteClaudeTranscriptPath(path: string): boolean {
if (!path.startsWith('/') || !path.endsWith('.jsonl') || /[\0\n]/.test(path)) return false;
return CLAUDE_TRANSCRIPT_ID_PATTERN.test(path.slice(path.lastIndexOf('/') + 1, -'.jsonl'.length));
}
/**
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
* or leave it empty. Codeman launches a remote claude with `--session-id <id>`, but a
* `/clear` (or a resume picked inside claude) starts a new conversation, and with no
* hooks from a remote host Codeman never hears of it. claude itself keeps
* `~/.claude/sessions/<pid>.json` with the current `sessionId`, so: the pane's pid, its
* children and grandchildren (a wrapper shell may sit in between), the first of those
* with such a file. Only `[A-Za-z0-9._-]` survives the `sed`, the id pattern, so `$live`
* is safe unquoted. `ps -A -o pid= -o ppid=` is POSIX (no GNU `--ppid`).
*/
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
return [
'live=',
`pp=$(tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}`)} -F '#{pane_pid}' 2>/dev/null | head -n 1)`,
"case $pp in ''|*[!0-9]*) pp= ;; esac",
'if [ -n "$pp" ]; then',
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
' kids=$(printf \'%s\\n\' "$tree" | awk -v p="$pp" \'$2 == p { print $1 }\')',
' grand=$(for k in $kids; do printf \'%s\\n\' "$tree" | awk -v p="$k" \'$2 == p { print $1 }\'; done)',
' for c in $pp $kids $grand; do',
' f="$HOME/.claude/sessions/$c.json"',
' [ -f "$f" ] || continue',
' live=$(sed -n \'s/.*"sessionId":"\\([A-Za-z0-9._-]*\\)".*/\\1/p\' "$f" | head -n 1)',
' [ -n "$live" ] && break',
' done',
'fi',
];
}
/**
* Script that finds a remote claude session's main transcript and prints `\0<path>\0`;
* exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when there is none. First the conversation the
* claude in `pane` writes now ({@link liveClaudeIdLines}), then each of `ids`; per id the
* newest `$HOME/.claude/projects/*\/<id>.jsonl`. `$HOME` is expanded by the REMOTE shell:
* the transcript lives in the remote user's home, which the server cannot know.
*
* ⚠️ Runs under an explicit `sh` with `LC_ALL=C`: sshd runs a command in the remote user's
* LOGIN shell, and on a zsh host `ls` can be an alias and an unmatched glob aborts the whole
* script ("no matches found"). So: no `ls` at all, the newest file wins via `-nt`, and an
* `[ -f ]` guard on every glob. `C` makes bracket ranges and glob order plain bytes.
*/
export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], pane?: RemoteClaudePane): string {
const safeIds = safeClaudeTranscriptIds(ids);
if (safeIds.length === 0) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const liveLines = liveClaudeIdLines(pane);
const script = [
'LC_ALL=C',
'export LC_ALL',
'best=',
...liveLines,
`for id in ${liveLines.length ? '$live ' : ''}${safeIds.map((id) => shellescape(id)).join(' ')}; do`,
' for f in "$HOME"/.claude/projects/*/"$id".jsonl; do',
' [ -f "$f" ] || continue',
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
' done',
' [ -n "$best" ] && break',
'done',
`[ -n "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
'printf \'\\0%s\\0\' "$best"',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/** The path from {@link buildRemoteClaudeTranscriptLocateCommand} output; null when there is none. */
export function parseRemoteClaudeTranscriptLocate(stdout: Buffer): string | null {
const start = stdout.indexOf(0);
const end = start === -1 ? -1 : stdout.indexOf(0, start + 1);
if (end === -1) return null;
const path = stdout.toString('utf8', start + 1, end);
return isRemoteClaudeTranscriptPath(path) ? path : null;
}
/**
* Find a remote claude session's main transcript in ONE ssh round trip (see
* {@link buildRemoteClaudeTranscriptLocateCommand}). `null` when it has none; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes a host: a
* sleeping one is a connect timeout, like every other read in this module.
*/
export async function remoteLocateClaudeTranscript(
remote: SessionRemote,
ids: readonly string[],
pane?: RemoteClaudePane
): Promise<string | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_READ_TIMEOUT_MS, maxBuffer: READ_BUFFER_SLACK_BYTES, encoding: 'buffer' })
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const path = parseRemoteClaudeTranscriptLocate(stdout);
if (!path) throw new RemoteFileAccessError('unexpected output from the remote transcript lookup');
return path;
}
/**
* Script that reads a remote claude session's main transcript at `mainPath` and its subagent
* transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`) in ONE ssh round trip,
* with exactly the local reader's rules (`readClaudeEditSources`):
*
* - a request without `len` reads from `offset` (clamped to the size);
* - a request with `len` is a block that ends at `offset + len`: short of budget, its end;
* - a file without a request (one the caller has not seen) reads its last bytes, and a new
* subagent's `.meta.json` (≤ {@link EDIT_SOURCE_META_MAX_BYTES}, outside the budget) comes
* along as its own record; with `onlyRequested` such files are left out.
*
* `budgetBytes` is shared by all transcript files, main first, then the subagents in byte
* order. A file that gets none of it still produces a zero-length record, because the caller
* needs its size. Exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when `mainPath` is not a file.
*
* Output: `\0<transcriptId>\0`, then per record `<relPath>\0<size>\0<start>\0<len>\0`
* followed by exactly `<len>` raw bytes. Should a file shrink between `wc -c` and the read,
* the slice is NUL-padded to `<len>` so the framing never shifts.
*
* Inputs never reach the script unchecked: the path via {@link isRemoteClaudeTranscriptPath}
* (and shellescaped), relPaths via {@link EDIT_SOURCE_REL_PATH_PATTERN}, offsets and lengths as
* plain digits; anything invalid is dropped. A subagent file whose name the pattern would
* reject is skipped on the remote side too. Same `sh` + `LC_ALL=C` rules as the lookup.
*/
export function buildRemoteClaudeEditSourcesCommand(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): string {
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
const byRelPath = new Map<string, { offset: number; len: number | null }>();
for (const request of requests) {
if (!request || typeof request.relPath !== 'string') continue;
if (!EDIT_SOURCE_REL_PATH_PATTERN.test(request.relPath)) continue;
if (!isByteCount(request.offset)) continue;
if (request.len !== undefined && !isByteCount(request.len)) continue;
if (byRelPath.has(request.relPath)) continue;
byRelPath.set(request.relPath, { offset: request.offset, len: request.len ?? null });
}
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const requestArgs = (request: { offset: number; len: number | null }): string =>
`r ${request.offset} ${request.len === null ? "''" : String(request.len)}`;
const main = byRelPath.get('main');
const mainLine = main
? `emit main "$best" ${requestArgs(main)}`
: opts.onlyRequested
? ''
: 'emit main "$best" t 0 \'\'';
const subagentCases = [...byRelPath.entries()]
.filter(([relPath]) => relPath !== 'main')
.map(([relPath, request]) => {
const name = relPath.slice('subagents/'.length);
return ` ${shellescape(name)}) emit "subagents/$b" "$f" ${requestArgs(request)} ;;`;
});
const newSubagent = opts.onlyRequested
? [' *) ;;']
: [
' *) emit "subagents/$b" "$f" t 0 \'\'',
' m=${f%.jsonl}.meta.json',
' if [ -f "$m" ]; then',
' fsize "$m"',
` mn=$s; [ "$mn" -le ${EDIT_SOURCE_META_MAX_BYTES} ] || mn=${EDIT_SOURCE_META_MAX_BYTES}`,
' if [ "$mn" -le "$mbudget" ]; then',
' mbudget=$((mbudget - mn))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "subagents/${b%.jsonl}.meta.json" "$s" 0 "$mn"',
' [ "$mn" -eq 0 ] || bytes "$m" 0 "$mn"',
' fi',
' fi ;;',
];
const script = [
'LC_ALL=C',
'export LC_ALL',
`best=${shellescape(mainPath)}`,
`[ -f "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
`printf '\\0%s\\0' ${shellescape(transcriptId)}`,
`budget=${budget}`,
`mbudget=${EDIT_SOURCE_META_BUDGET_BYTES}`,
// Size via `wc -c <` (POSIX; BSD pads it with spaces, hence the tr).
'fsize() {',
' s=$(wc -c < "$1" 2>/dev/null | tr -d \' \\t\\n\')',
" case $s in ''|*[!0-9]*) s=0 ;; esac",
'}',
// Exactly $3 bytes of $1 from offset $2, NUL-padded if the file shrank meanwhile.
'bytes() {',
' { tail -c +$(($2 + 1)) "$1" 2>/dev/null | head -c "$3"; head -c "$3" /dev/zero; } | head -c "$3"',
'}',
// emit <relPath> <file> <r|t> <offset> <len|''>: r = a request, t = the tail of a new file.
'emit() {',
' fsize "$2"',
' if [ "$3" = r ] && [ -n "$5" ]; then',
' bs=$4; [ "$bs" -le "$s" ] || bs=$s',
' be=$(($4 + $5)); [ "$be" -le "$s" ] || be=$s',
' n=$((be - bs)); [ "$n" -le "$budget" ] || n=$budget',
' start=$((be - n))',
' elif [ "$3" = r ]; then',
' start=$4; [ "$start" -le "$s" ] || start=$s',
' n=$((s - start)); [ "$n" -le "$budget" ] || n=$budget',
' else',
' n=$s; [ "$n" -le "$budget" ] || n=$budget',
' start=$((s - n))',
' fi',
' budget=$((budget - n))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "$1" "$s" "$start" "$n"',
' [ "$n" -eq 0 ] || bytes "$2" "$start" "$n"',
'}',
mainLine,
'for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
' [ -f "$f" ] || continue',
' b=${f##*/}',
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
' case $b in',
...subagentCases,
...newSubagent,
' esac',
'done',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteClaudeEditSourcesCommand} output, byte-exact on the Buffer (a chunk
* may end mid UTF-8 sequence, so nothing here measures decoded strings). Everything before
* the first NUL is rc-file chatter. `null` = protocol broken: a missing or malformed header
* field, an unknown relPath, a slice outside the file or a body shorter than its length.
*/
export function parseRemoteClaudeEditSources(stdout: Buffer): EditSources | null {
let pos = stdout.indexOf(0);
if (pos === -1) return null;
pos += 1;
const readField = (): string | null => {
const end = stdout.indexOf(0, pos);
if (end === -1) return null;
const field = stdout.toString('utf8', pos, end);
pos = end + 1;
return field;
};
const readNumber = (): number | null => {
const field = readField();
if (field === null || !/^\d+$/.test(field)) return null;
const value = Number(field);
return Number.isSafeInteger(value) ? value : null;
};
const transcriptId = readField();
if (!transcriptId || !CLAUDE_TRANSCRIPT_ID_PATTERN.test(transcriptId)) return null;
const files: EditSources['files'] = [];
const metas: Record<string, string> = {};
while (pos < stdout.length) {
const relPath = readField();
const size = readNumber();
const startOffset = readNumber();
const len = readNumber();
if (relPath === null || size === null || startOffset === null || len === null) return null;
if (startOffset + len > size || pos + len > stdout.length) return null;
const chunk = stdout.subarray(pos, pos + len);
pos += len;
const meta = EDIT_SOURCE_META_REL_PATH_PATTERN.exec(relPath);
if (meta) {
metas[meta[1]] = chunk.toString('utf8');
} else if (EDIT_SOURCE_REL_PATH_PATTERN.test(relPath)) {
files.push({ relPath, size, startOffset, chunk });
} else {
return null;
}
}
return { transcriptId, files, metas };
}
/**
* Read a remote claude session's transcripts (see {@link buildRemoteClaudeEditSourcesCommand}),
* the remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an
* unreachable host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes.
*/
export async function remoteReadClaudeEditSources(
remote: SessionRemote,
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
assertNotUnderTest();
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const command = buildRemoteFileCommand(remote, buildRemoteClaudeEditSourcesCommand(mainPath, requests, budget, opts));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, {
timeout: REMOTE_READ_TIMEOUT_MS,
maxBuffer: budget + EDIT_SOURCE_META_BUDGET_BYTES + EDIT_SOURCE_HEADER_SLACK_BYTES,
encoding: 'buffer',
})
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const parsed = parseRemoteClaudeEditSources(stdout);
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
}
return parsed;
}
/**
* Command that writes a remote file's bytes to stdout.
*
+38 -17
View File
@@ -31,11 +31,14 @@
* 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.
* 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).
*
* 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.
* (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.
*
* Tests: `test/edit-history-sync.test.ts`.
*
@@ -83,13 +86,16 @@ export interface EditHistorySession {
export interface EditHistorySyncDeps {
/** The session's main transcript path, or null when it has none yet. */
transcriptPath(session: EditHistorySession): Promise<string | null>;
/** The session's transcripts (a remote session's on its host). */
readSources(
session: EditHistorySession,
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts?: EditSourceReadOptions
): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
/** Bash image candidates of the session, on the host it runs on. */
probe(session: EditHistorySession, 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>;
@@ -104,7 +110,6 @@ export interface SyncOptions {
/** 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;
@@ -329,11 +334,11 @@ export class EditHistorySync {
if (!path) {
found = false;
} else if (older) {
found = await this.readOlder(path, store, slot);
found = await this.readOlder(session, 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);
const r = await this.readForward(session, path, store);
found = r.found;
if (!r.more) break;
}
@@ -359,13 +364,17 @@ export class EditHistorySync {
* 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 }> {
private async readForward(
session: EditHistorySession,
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));
const previous = await this.readFrom(session, own, store, this.forwardRequests(store));
if (previous.more) return { found: true, more: true };
}
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
return this.readFrom(session, path, store, own === path ? this.forwardRequests(store) : []);
}
/**
@@ -378,11 +387,12 @@ export class EditHistorySync {
}
private async readFrom(
session: EditHistorySession,
path: string,
store: EditHistoryStore,
requests: EditSourceRequest[]
): Promise<{ found: boolean; more: boolean }> {
const res = await this.deps.readSources(path, requests, this.budget);
const res = await this.deps.readSources(session, 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)) {
@@ -413,11 +423,16 @@ export class EditHistorySync {
candidates.push(...r.candidates);
if (f.startOffset + f.chunk.length < f.size) more = true;
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return { found: true, more };
}
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
private async readOlder(
session: EditHistorySession,
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);
@@ -440,7 +455,9 @@ export class EditHistorySync {
}
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 });
const res = await this.deps.readSources(session, path, requests, this.budget * requests.length, {
onlyRequested: true,
});
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
this.dropMissing(store, requests, res);
@@ -462,7 +479,7 @@ export class EditHistorySync {
});
candidates.push(...r.candidates);
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return true;
}
@@ -472,13 +489,17 @@ export class EditHistorySync {
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
}
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
private async addImages(
session: EditHistorySession,
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);
probes = await this.deps.probe(session, paths);
} catch {
// The edits of this read are in; only its images are lost. The read itself succeeded.
return;
+11 -5
View File
@@ -9,12 +9,14 @@
* `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.
* (the end of a turn). No poll, and nothing at all while the panel is closed. A remote session has
* no transcript watcher (no hooks reach back from its host), so it follows turn ends only.
*
* - 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
* on any path; inside a local session's workspace it also shows a thumbnail (a remote one would
* stream every listed image over ssh). 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
@@ -318,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
* (`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).
* them as a download), nor does a remote session's image: its `file-raw` streams over ssh, and
* a list of generated images would open one ssh read per row; a click still previews it.
*/
_editHistoryThumbSrc(path, sid) {
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
if (!path || /\.svg$/i.test(path) || this.sessions?.get?.(sid)?.remote) return null;
if (this._isExternalPreviewPath?.(path, sid) !== false) return null;
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
},
@@ -353,7 +357,9 @@ Object.assign(CodemanApp.prototype, {
}
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.')));
body.append(
el('div', 'edit-history-empty', t('Edit history is only available for Claude sessions outside Docker.'))
);
return;
}
if (data.state === 'unavailable') {
+2 -1
View File
@@ -565,7 +565,8 @@
'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 会话。',
'Edit history is only available for Claude sessions outside Docker.':
'编辑历史仅适用于 Docker 之外的 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 无法被找到。',
+18 -11
View File
@@ -140,6 +140,8 @@ 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 { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js';
import { remoteTmuxLocation } from '../tmux-manager.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -356,19 +358,24 @@ 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.
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks.
* A local session's 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. A remote session sends no hooks: its host is asked over ssh for the transcript
* the pane's claude writes (remote-files.ts), and is never woken for it.
*/
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,
transcriptPath: (session) => {
const ids = [session.claudeSessionId || session.id, session.id];
return session.remote
? remoteLocateClaudeTranscript(session.remote, ids, remoteTmuxLocation(session.remote, session.id))
: resolveClaudeTranscript(this.transcriptWatchers.get(session.id)?.getPath(), ids, session.claudeConfigDir);
},
readSources: (session, mainPath, requests, budget, opts) =>
session.remote
? remoteReadClaudeEditSources(session.remote, mainPath, requests, budget, opts)
: readClaudeEditSources(mainPath, requests, budget, opts),
probe: (session, paths) => (session.remote ? remoteProbePaths(session.remote, paths) : probeLocalPaths(paths)),
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
sweepStores: (keep) => EditHistoryStore.sweep(keep),