From 0a6a87741567e3c38f085ac1e18bb83ba9e2fbcd Mon Sep 17 00:00:00 2001 From: Randalix Date: Fri, 23 Oct 2026 16:52:09 +0200 Subject: [PATCH] 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/.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) --- docs/architecture-invariants.md | 2 +- docs/wiki/Working-With-Files.md | 5 +-- src/remote-files.ts | 57 ++++++++++++++++++++++++------- src/web/edit-history-sync.ts | 22 ++++++++++-- src/web/public/edit-history-ui.js | 19 ++++++----- src/web/public/i18n.js | 2 ++ src/web/server.ts | 23 ++++++++----- test/edit-history-sync.test.ts | 27 +++++++++++++++ test/edit-history.browser.test.ts | 11 ++++++ test/remote-edit-sources.test.ts | 37 ++++++++++++++++++++ 10 files changed, 169 insertions(+), 36 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ba75735c..726c6bfe 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -768,7 +768,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker and non-claude sessions answer `unsupported` and are never read. -⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `~/.claude/sessions/.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else `` then ``, each the newest `$HOME/.claude/projects/*/.jsonl`; the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`) with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. Only the host's default `$HOME/.claude` is searched. ⚠️ **Never wakes:** the reads go through `buildRemoteFileCommand` + `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only. +⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `/sessions/.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the stored history's conversation, then ``, then `` (`remoteTranscriptIds`: nothing moves `claudeSessionId` after a remote `/clear`, so tried first it would flip the panel back to the cleared conversation once the remote claude exits), each the newest `/projects/*/.jsonl`; the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`) with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in `.zshrc` is not seen. A read's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the reads go through `buildRemoteFileCommand` + `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only. ### Config backups diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index d246ba88..5867ed5f 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -324,8 +324,9 @@ How it reads: directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script) is only known after the next prompt; until then the window says "No transcript found for this session yet". A remote session's transcript is looked up on its host over SSH: the - conversation its Claude writes now, else the session's own, under the remote user's - `~/.claude` (a `CLAUDE_CONFIG_DIR` on the remote host is not followed). + conversation its Claude writes now, else the one the window showed last, else the session's + own, under the remote user's `~/.claude`, or under `CLAUDE_CONFIG_DIR` when the host sets it + for every SSH command (`.zshenv`, `/etc/environment`); one set only in `.zshrc` is not seen. - **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to `~/.codeman/edit-history/.json`, including API keys or `.env` contents it edited. The file is readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest diff --git a/src/remote-files.ts b/src/remote-files.ts index 79eb025e..637bb62f 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -435,6 +435,22 @@ 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; +/** + * 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); +} + const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; /** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */ @@ -458,15 +474,27 @@ export function isRemoteClaudeTranscriptPath(path: string): boolean { 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`: a `CLAUDE_CONFIG_DIR` set only in an interactive + * rc file (`.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. Codeman launches a remote claude with `--session-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/.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`). + * or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). Codeman launches a + * remote claude with `--session-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 `/sessions/.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 []; @@ -479,7 +507,7 @@ function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] { ' 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="$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', @@ -492,8 +520,9 @@ function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] { * Script that finds a remote claude session's main transcript and prints `\0\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/*\/.jsonl`. `$HOME` is expanded by the REMOTE shell: - * the transcript lives in the remote user's home, which the server cannot know. + * newest `/projects/*\/.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 @@ -508,9 +537,10 @@ export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], 'LC_ALL=C', 'export LC_ALL', 'best=', + ...claudeConfigDirLines(), ...liveLines, `for id in ${liveLines.length ? '$live ' : ''}${safeIds.map((id) => shellescape(id)).join(' ')}; do`, - ' for f in "$HOME"/.claude/projects/*/"$id".jsonl; do', + ' for f in "$cfg"/projects/*/"$id".jsonl; do', ' [ -f "$f" ] || continue', ' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi', ' done', @@ -752,12 +782,13 @@ export async function remoteReadClaudeEditSources( assertNotUnderTest(); const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0; const command = buildRemoteFileCommand(remote, buildRemoteClaudeEditSourcesCommand(mainPath, requests, budget, opts)); + const maxBytes = budget + EDIT_SOURCE_META_BUDGET_BYTES + EDIT_SOURCE_HEADER_SLACK_BYTES; 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, + timeout: remoteEditSourcesTimeoutMs(maxBytes), + maxBuffer: maxBytes, encoding: 'buffer', }) ); diff --git a/src/web/edit-history-sync.ts b/src/web/edit-history-sync.ts index 956f9abe..a252d747 100644 --- a/src/web/edit-history-sync.ts +++ b/src/web/edit-history-sync.ts @@ -84,8 +84,12 @@ export interface EditHistorySession { } export interface EditHistorySyncDeps { - /** The session's main transcript path, or null when it has none yet. */ - transcriptPath(session: EditHistorySession): Promise; + /** + * 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; /** The session's transcripts (a remote session's on its host). */ readSources( session: EditHistorySession, @@ -108,6 +112,18 @@ export interface SyncOptions { older?: boolean; } +/** + * The conversation ids a REMOTE session's transcript lookup tries, in order, after the one its + * claude writes now. No hook tells Codeman of a remote `/clear`, so `claudeSessionId` stays the + * launch id; tried first, it would flip the panel back to the cleared conversation as soon as + * the remote claude exits. The stored history's conversation is the newest the panel has read. + */ +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.docker) return 'docker'; @@ -329,7 +345,7 @@ 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; diff --git a/src/web/public/edit-history-ui.js b/src/web/public/edit-history-ui.js index 91ea7dba..99191696 100644 --- a/src/web/public/edit-history-ui.js +++ b/src/web/public/edit-history-ui.js @@ -363,17 +363,18 @@ Object.assign(CodemanApp.prototype, { 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 an interactive shell 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 : []; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 79b18046..1d6f9ea7 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -570,6 +570,8 @@ '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 an interactive shell profile (.zshrc) cannot be found.': + '远程主机上尚未找到记录(Claude 会在第一次提示时写入)。仅在交互式 shell 配置文件(.zshrc)中设置的 CLAUDE_CONFIG_DIR 无法被找到。', 'No edits yet.': '暂无编辑。', 'No edits by the main agent.': '主智能体没有编辑。', 'Load older': '加载更早的编辑', diff --git a/src/web/server.ts b/src/web/server.ts index 4b01184d..3bfeeceb 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -137,7 +137,7 @@ import { probePathKind } from '../utils/bounded-path-probe.js'; import { defaultDockerWorkspaces } from './routes/git-status-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'; @@ -362,15 +362,22 @@ export class WebServer extends EventEmitter { * 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. + * 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) => { - 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); - }, + 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) diff --git a/test/edit-history-sync.test.ts b/test/edit-history-sync.test.ts index 8d3be94a..443aaa7f 100644 --- a/test/edit-history-sync.test.ts +++ b/test/edit-history-sync.test.ts @@ -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 = []; + 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')); diff --git a/test/edit-history.browser.test.ts b/test/edit-history.browser.test.ts index b0ce0603..5aef59bd 100644 --- a/test/edit-history.browser.test.ts +++ b/test/edit-history.browser.test.ts @@ -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(); }); diff --git a/test/remote-edit-sources.test.ts b/test/remote-edit-sources.test.ts index 325360f3..ed1d130b 100644 --- a/test/remote-edit-sources.test.ts +++ b/test/remote-edit-sources.test.ts @@ -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');