fix(edit-history): remote reads get time for their size, follow CLAUDE_CONFIG_DIR, keep the conversation after /clear

Review findings on the remote edit history:

- A forward read may carry 13 MiB and "Load older" 29 MiB; the flat 30 s
  read timeout cut those off below ~1 MiB/s. The timeout now adds the
  transfer time at 256 KiB/s (remoteEditSourcesTimeoutMs). It ends a hung
  read, it does not cut a slow one.
- The transcript lookup looked only under $HOME/.claude. It now uses an
  absolute CLAUDE_CONFIG_DIR of the ssh command's environment, else
  $HOME/.claude, for both the projects and the live sessions/<pid>.json.
  A value set only in .zshrc stays invisible (the lookup is a plain sh);
  documented, and the panel's remote no-transcript note says so instead
  of promising the next prompt (a remote host sends no hooks).
- After a remote /clear claudeSessionId stays the launch id, so once the
  remote claude exited the lookup fell back to the cleared conversation
  and the panel flipped back. The sync now hands the lookup the stored
  conversation, tried right after the live one (remoteTranscriptIds).

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:52 +01:00
1 parent c4ba14235f
commit 0a6a877415
10 files changed
+169 -36

No files matched your search

+27
View File
@@ -22,6 +22,7 @@ import {
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
remoteTranscriptIds,
unsupportedReason,
} from '../src/web/edit-history-sync.js';
@@ -177,6 +178,15 @@ describe('unsupportedReason', () => {
});
});
describe('remoteTranscriptIds', () => {
it('tries the stored conversation before the launch id: no hook tells Codeman of a remote /clear', () => {
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds({ ...remote, claudeSessionId: null }, null)).toEqual(['s2']);
});
});
describe('EditHistorySync', () => {
it('reads new edits forward from the stored offset', async () => {
const { host, sync } = setup();
@@ -313,6 +323,23 @@ describe('EditHistorySync', () => {
expect((await sync.getStore('s2')).list().map((e) => e.path)).toEqual(['/w/out.png', '/w/a.ts']);
});
it('hands the transcript lookup the conversation the stored history holds', async () => {
const stored: Array<string | null> = [];
const { host, sync } = setup({
transcriptPath: async (_session, storedTranscriptId) => {
stored.push(storedTranscriptId);
return host.path;
},
});
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(remote);
await sync.sync(remote);
expect(stored).toEqual([null, 'conv1', 'conv2']);
});
it('an unreachable remote host is unavailable with the stored entries kept, and the next sync reads on', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
+11
View File
@@ -469,6 +469,17 @@ describe('Edit history panel in a real browser', () => {
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
expect(note).toContain('No transcript found for this session yet');
expect(note).toContain('CLAUDE_CONFIG_DIR');
// A remote host sends no hooks: no promise that the next prompt finds it.
await page.evaluate((sid) => {
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
}, sessionA);
await reload();
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
hasText: 'No transcript found on the remote host yet',
});
await remoteNote.waitFor();
expect(await remoteNote.textContent()).not.toContain('next prompt');
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
state.set(sessionA, s);
await reload();
});
+37
View File
@@ -33,6 +33,7 @@ import {
isRemoteClaudeTranscriptPath,
parseRemoteClaudeEditSources,
parseRemoteClaudeTranscriptLocate,
remoteEditSourcesTimeoutMs,
remoteLocateClaudeTranscript,
remoteReadClaudeEditSources,
} from '../src/remote-files.js';
@@ -117,6 +118,19 @@ describe('the transcript lookup on a real shell', () => {
expect(command).not.toContain('pwned');
});
it('looks in an absolute CLAUDE_CONFIG_DIR instead of ~/.claude, as claude does; a relative one is ignored', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
expect(locate(['conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(3);
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: 'alt-config' })).toBe(
join(projects(), 'new', 'conv-1.jsonl')
);
});
it('parses only an absolute transcript path after the first NUL (a login banner comes before it)', () => {
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('motd\n\0/p/conv-1.jsonl\0'))).toBe('/p/conv-1.jsonl');
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('no nul'))).toBeNull();
@@ -158,6 +172,19 @@ describe('the transcript lookup on a real shell', () => {
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
});
it('reads the live id from the sessions dir under CLAUDE_CONFIG_DIR', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'sessions'), { recursive: true });
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
writeFileSync(join(cfg, 'projects', 'w', 'conv-1.jsonl'), 'c\n');
writeFileSync(join(cfg, 'sessions', '300.json'), '{"pid":300,"sessionId":"alt-1"}');
session('{"pid":300,"sessionId":"solo"}'); // ~/.claude's file is not claude's here
expect(locate(['conv-1'], pane, { PATH: `${bin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
});
it('takes only an id-shaped value from the sessions file, and only plain socket/session names', () => {
session('{"pid":300,"sessionId":"x$(touch pwned)"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
@@ -253,6 +280,16 @@ describe('parseRemoteClaudeEditSources', () => {
});
});
describe('remoteEditSourcesTimeoutMs', () => {
it('gives a read of megabytes its transfer time at 256 KiB/s on top of the flat 30 s', () => {
expect(remoteEditSourcesTimeoutMs(0)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(-5)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(256 * 1024)).toBe(31_000);
// a forward pass: 8 MiB budget + 4 MiB metas + 1 MiB header slack
expect(remoteEditSourcesTimeoutMs(13 * 1024 * 1024)).toBe(82_000);
});
});
describe('the transcript read on a real shell', () => {
const project = () => join(home, '.claude', 'projects', 'read');
const main = () => join(project(), 'conv-r.jsonl');