mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #766 feat: edit history panel for remote (SSH) claude sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
76e40668fc
21 files changed
+1670
-97
No files matched your search
+551
-1
@@ -30,8 +30,9 @@ import { exec, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } 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,555 @@ 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 (the local reader has none). A meta comes only with a
|
||||
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
|
||||
* as usual, and the panel names it by its id instead of its description.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Subagent requests per ssh round trip of an edit-history read. Like
|
||||
* {@link REMOTE_PROBE_CHUNK_SIZE}: the script is ONE argument of the ssh line, Linux caps a
|
||||
* single argv string at 128 KiB, and each request adds a `case` line of 125 bytes once
|
||||
* shellescaped twice (about 370 with the longest name a filesystem allows; the test pins that
|
||||
* worst case). See {@link planRemoteEditSourceRounds}.
|
||||
*/
|
||||
const EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP = 300;
|
||||
|
||||
/**
|
||||
* The slowest link an edit-history read is given time for. Such a read can carry megabytes
|
||||
* (a forward pass its budget plus the metas, "Load older" up to three budgets), which a flat
|
||||
* {@link REMOTE_READ_TIMEOUT_MS} would cut off below ~1 MiB/s. 256 KiB/s ≈ 2 Mbit/s.
|
||||
*/
|
||||
const EDIT_SOURCE_MIN_BYTES_PER_SEC = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Timeout of an edit-history read that may return up to `maxBytes`: the flat read timeout plus
|
||||
* the transfer time at {@link EDIT_SOURCE_MIN_BYTES_PER_SEC}. It ends a hung read (a host that
|
||||
* fell asleep mid-transfer; no keepalives, see the wake invariants), it does not cut a slow one.
|
||||
*/
|
||||
export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
|
||||
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
|
||||
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
|
||||
* plain command, which would leave ssh running and the remote read holding its connection.
|
||||
*/
|
||||
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
|
||||
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
|
||||
}
|
||||
|
||||
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 `cfg` to claude's config dir on the remote host: an absolute
|
||||
* `CLAUDE_CONFIG_DIR` from the ssh command's environment, else `$HOME/.claude` — claude's own
|
||||
* rule. ⚠️ The remote claude runs under `$SHELL -i -l -c` (`remoteLoginShellCommand`), this
|
||||
* script under a plain non-interactive `sh` from sshd's `$SHELL -c`: a `CLAUDE_CONFIG_DIR` set
|
||||
* only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen
|
||||
* here; one from `.zshenv`, sshd `SetEnv` or the system environment is.
|
||||
*/
|
||||
function claudeConfigDirLines(): string[] {
|
||||
return ['cfg=$HOME/.claude', 'case ${CLAUDE_CONFIG_DIR:-} in /*) cfg=$CLAUDE_CONFIG_DIR ;; esac'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
|
||||
* or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). 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 `<config dir>/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`).
|
||||
*
|
||||
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
|
||||
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
|
||||
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
|
||||
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
|
||||
*
|
||||
* ⚠️ `=name:` is an EXACT session match. Without the colon tmux reads a window target, so a
|
||||
* session that is gone resolves to a window of that name or a session the name is a prefix of,
|
||||
* and that session's conversation would land in this one's edit history.
|
||||
*/
|
||||
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
|
||||
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
|
||||
return [
|
||||
'live=',
|
||||
`pp=$(${withRemoteTmuxPath(`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="$cfg/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}); without one, the most recently
|
||||
* written transcript of ALL `ids`, not the first id that has one: the conversation claude
|
||||
* wrote last is the current one whether a `/clear` moved on from the launch id or a respawn
|
||||
* resumed it again, and no order of the ids gets both right. Each id is looked up as
|
||||
* `<config dir>/projects/*\/<id>.jsonl` ({@link claudeConfigDirLines}). `$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=',
|
||||
...claudeConfigDirLines(),
|
||||
// newest <id>: `best` becomes that id's transcript if it is newer than the current best.
|
||||
// BusyBox's (and bash 3.2's) `-nt` compares whole seconds: a tie keeps the earlier id, the
|
||||
// stored conversation, until the next write; only without a live id.
|
||||
'newest() {',
|
||||
' for f in "$cfg"/projects/*/"$1".jsonl; do',
|
||||
' [ -f "$f" ] || continue',
|
||||
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
|
||||
' done',
|
||||
'}',
|
||||
...liveLines,
|
||||
...(liveLines.length ? ['[ -z "$live" ] || newest "$live"'] : []),
|
||||
'if [ -z "$best" ]; then',
|
||||
` for id in ${safeIds.map((id) => shellescape(id)).join(' ')}; do newest "$id"; done`,
|
||||
'fi',
|
||||
`[ -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 = buildRemoteEditHistoryCommand(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;
|
||||
}
|
||||
|
||||
/** The requests a read script may take: the first per relPath, valid ones only. */
|
||||
function validEditSourceRequests(requests: readonly EditSourceRequest[]): Map<string, EditSourceRequest> {
|
||||
const byRelPath = new Map<string, EditSourceRequest>();
|
||||
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, request);
|
||||
}
|
||||
return byRelPath;
|
||||
}
|
||||
|
||||
/** One ssh round trip of an edit-history read ({@link planRemoteEditSourceRounds}). */
|
||||
export interface RemoteEditSourceRound {
|
||||
requests: EditSourceRequest[];
|
||||
/** It reads the main transcript: the first round trip of a read does. */
|
||||
main: boolean;
|
||||
/** It takes the subagent files after this name ('' = from the first), in byte order. */
|
||||
after: string;
|
||||
/** It takes them up to and including this name ('' = to the last). */
|
||||
upTo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ssh round trips a read takes. Up to `perRound` subagent requests fit one; more are sorted
|
||||
* and cut into groups of `perRound`, or the ssh line would hit `E2BIG` and the session would
|
||||
* stay `unavailable`: the cursors have no cap and the stored conversation is read first, so
|
||||
* not even a `/clear` would get past it.
|
||||
*
|
||||
* ⚠️ The cuts are NAME RANGES, not only groups of requests. The script takes a file that no
|
||||
* `case` line names for a new one (its tail, its meta), so a plain group would read every
|
||||
* file of the other groups as new. Each round trip walks only the names between its cuts; the
|
||||
* ranges cover every possible name exactly once, so a new file lands in one of them and the
|
||||
* merged result is the one a single script would give (the fuzz test compares it with the
|
||||
* local reader).
|
||||
*/
|
||||
export function planRemoteEditSourceRounds(
|
||||
requests: readonly EditSourceRequest[],
|
||||
perRound: number = EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP
|
||||
): RemoteEditSourceRound[] {
|
||||
const valid = validEditSourceRequests(requests);
|
||||
const subagents = [...valid.values()].filter((request) => request.relPath !== 'main');
|
||||
if (subagents.length <= perRound) return [{ requests: [...valid.values()], main: true, after: '', upTo: '' }];
|
||||
|
||||
const main = valid.get('main');
|
||||
const fileName = (request: EditSourceRequest): string => request.relPath.slice('subagents/'.length);
|
||||
subagents.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
|
||||
const rounds: RemoteEditSourceRound[] = [];
|
||||
for (let i = 0; i < subagents.length; i += perRound) {
|
||||
const group = subagents.slice(i, i + perRound);
|
||||
rounds.push({
|
||||
requests: i === 0 && main ? [main, ...group] : group,
|
||||
main: i === 0,
|
||||
after: i === 0 ? '' : fileName(subagents[i - 1]),
|
||||
upTo: i + perRound < subagents.length ? fileName(group[group.length - 1]) : '',
|
||||
});
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
/** What {@link buildRemoteClaudeEditSourcesCommand} takes beyond the local reader's options. */
|
||||
export interface RemoteEditSourceCommandOptions extends EditSourceReadOptions {
|
||||
/** Which part of a split read this command is; without it, all of it. */
|
||||
round?: Pick<RemoteEditSourceRound, 'main' | 'after' | 'upTo'>;
|
||||
/** Meta bytes this command may send; without it, {@link EDIT_SOURCE_META_BUDGET_BYTES}. */
|
||||
metaBudget?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Script for one round trip of a read of a remote claude session's main transcript at
|
||||
* `mainPath` and its subagent transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`),
|
||||
* 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. A read
|
||||
* with more requests than fit one script is several of these ({@link planRemoteEditSourceRounds}).
|
||||
*
|
||||
* 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: RemoteEditSourceCommandOptions = {}
|
||||
): string {
|
||||
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
|
||||
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
|
||||
|
||||
const byRelPath = validEditSourceRequests(requests);
|
||||
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
||||
const requestArgs = (request: EditSourceRequest): string =>
|
||||
`r ${request.offset} ${request.len === undefined ? "''" : String(request.len)}`;
|
||||
const { main: readsMain, after, upTo } = opts.round ?? { main: true, after: '', upTo: '' };
|
||||
|
||||
const main = byRelPath.get('main');
|
||||
const mainLine = !readsMain
|
||||
? ''
|
||||
: main
|
||||
? `emit main "$best" ${requestArgs(main)}`
|
||||
: opts.onlyRequested
|
||||
? ''
|
||||
: 'emit main "$best" t 0 \'\'';
|
||||
// The names come from validated relPaths, and `$0` stays a string: every name starts `agent-`.
|
||||
const rangeFilter =
|
||||
after || upTo
|
||||
? ` | awk -v after=${shellescape(after)} -v upto=${shellescape(upTo)} '$0 > after && (upto == "" || $0 <= upto)'`
|
||||
: '';
|
||||
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=${opts.metaBudget ?? 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"',
|
||||
'}',
|
||||
// The valid names, one per line in byte order (the glob's order under `C`). Nothing but
|
||||
// `[A-Za-z0-9_.-]` is left, so the unquoted `$(…)` below splits on them and globs nothing.
|
||||
// A function, because bash 3.2 (macOS `sh`) cannot parse a `case` inside `$(…)`.
|
||||
'subagent_names() {',
|
||||
' for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
|
||||
' b=${f##*/}',
|
||||
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
|
||||
' printf \'%s\\n\' "$b"',
|
||||
' done',
|
||||
'}',
|
||||
mainLine,
|
||||
`for b in $(subagent_names${rangeFilter}); do`,
|
||||
' f=${best%.jsonl}/subagents/$b',
|
||||
' [ -f "$f" ] || continue',
|
||||
' 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one read command on the host: its stdout, at most `maxBytes`, or null for "no such
|
||||
* transcript" ({@link TRANSCRIPT_NOT_FOUND_EXIT}).
|
||||
*/
|
||||
export type RemoteEditSourceRunner = (command: string, maxBytes: number) => Promise<Buffer | null>;
|
||||
|
||||
/**
|
||||
* Run the round trips of a read ({@link planRemoteEditSourceRounds}) one after the other and
|
||||
* merge them into what one read returns. Budget and meta budget carry over: a later round trip
|
||||
* gets what the earlier ones left, and still lists its files, because a requested file missing
|
||||
* from the result counts as deleted and loses its cursor. So a read is all or nothing: a failed
|
||||
* round trip fails it, and nothing of it reaches the store.
|
||||
*/
|
||||
export async function readRemoteEditSourceRounds(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions,
|
||||
run: RemoteEditSourceRunner,
|
||||
perRound?: number
|
||||
): Promise<EditSources | null> {
|
||||
let budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
||||
let metaBudget = EDIT_SOURCE_META_BUDGET_BYTES;
|
||||
const merged: EditSources = { transcriptId: '', files: [], metas: {} };
|
||||
for (const round of planRemoteEditSourceRounds(requests, perRound)) {
|
||||
const stdout = await run(
|
||||
buildRemoteClaudeEditSourcesCommand(mainPath, round.requests, budget, { ...opts, round, metaBudget }),
|
||||
budget + metaBudget + EDIT_SOURCE_HEADER_SLACK_BYTES
|
||||
);
|
||||
if (!stdout) return null;
|
||||
const parsed = parseRemoteClaudeEditSources(stdout);
|
||||
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
|
||||
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
|
||||
}
|
||||
merged.transcriptId = parsed.transcriptId;
|
||||
merged.files.push(...parsed.files);
|
||||
Object.assign(merged.metas, parsed.metas);
|
||||
for (const file of parsed.files) budget -= file.chunk.length;
|
||||
for (const text of Object.values(parsed.metas)) metaBudget = Math.max(0, metaBudget - Buffer.byteLength(text));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a remote claude session's transcripts (see {@link readRemoteEditSourceRounds}), the
|
||||
* remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an unreachable
|
||||
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Each round trip takes its
|
||||
* own slot of the ssh limiter. Never wakes.
|
||||
*/
|
||||
export async function remoteReadClaudeEditSources(
|
||||
remote: SessionRemote,
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
assertNotUnderTest();
|
||||
return readRemoteEditSourceRounds(mainPath, requests, budgetBytes, opts, async (command, maxBytes) => {
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(buildRemoteEditHistoryCommand(remote, command), {
|
||||
timeout: remoteEditSourcesTimeoutMs(maxBytes),
|
||||
maxBuffer: maxBytes,
|
||||
encoding: 'buffer',
|
||||
})
|
||||
);
|
||||
return 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)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Command that writes a remote file's bytes to stdout.
|
||||
*
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
* 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 (more for a
|
||||
* conversation with hundreds of subagents). 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`.
|
||||
*
|
||||
@@ -81,15 +85,22 @@ 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 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>;
|
||||
/** 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>;
|
||||
@@ -102,9 +113,21 @@ export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -324,16 +347,16 @@ export class EditHistorySync {
|
||||
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);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session, store.getTranscriptId());
|
||||
let found: boolean;
|
||||
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 +382,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 +405,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 +441,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 +473,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 +497,7 @@ export class EditHistorySync {
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
await this.addImages(session, store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -472,13 +507,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;
|
||||
|
||||
@@ -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,21 +357,24 @@ 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') {
|
||||
body.append(
|
||||
el(
|
||||
'div',
|
||||
'edit-history-note edit-history-note--warn',
|
||||
data.reason === 'no-transcript'
|
||||
// A remote host sends no hooks: its transcript is only ever found by the lookup over ssh.
|
||||
const note =
|
||||
data.reason !== 'no-transcript'
|
||||
? t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
: this.sessions?.get?.(h.sessionId)?.remote
|
||||
? 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.'
|
||||
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.'
|
||||
)
|
||||
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
)
|
||||
);
|
||||
: 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.'
|
||||
);
|
||||
body.append(el('div', 'edit-history-note edit-history-note--warn', note));
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
|
||||
@@ -565,10 +565,13 @@
|
||||
'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 无法被找到。',
|
||||
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.':
|
||||
'远程主机上尚未找到记录(Claude 会在第一次提示时写入)。仅在 shell 配置文件(.profile、.bash_profile、.zshrc)中设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* 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
|
||||
* background). A remote session is read over ssh and never woken; 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).
|
||||
*/
|
||||
@@ -86,7 +86,7 @@ function listOf(
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
/** The answer for a session that is never read (Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
|
||||
+26
-12
@@ -140,9 +140,11 @@ import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
|
||||
import { guardSessionConfigDirHome } from './routes/session-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 { EditHistorySync, remoteTranscriptIds } 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,
|
||||
@@ -359,19 +361,31 @@ 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, else the stored conversation's (`remoteTranscriptIds`), and is
|
||||
* never woken for it (remote-files.ts).
|
||||
*/
|
||||
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, storedTranscriptId) =>
|
||||
session.remote
|
||||
? remoteLocateClaudeTranscript(
|
||||
session.remote,
|
||||
remoteTranscriptIds(session, storedTranscriptId),
|
||||
remoteTmuxLocation(session.remote, session.id)
|
||||
)
|
||||
: resolveClaudeTranscript(
|
||||
this.transcriptWatchers.get(session.id)?.getPath(),
|
||||
[session.claudeSessionId || session.id, session.id],
|
||||
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),
|
||||
|
||||
Reference in new issue
Block a user