mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +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
@@ -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