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');