diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 726c6bfe..2588028a 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 `/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. +⚠️ **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 most recently written `/projects/*/.jsonl` of the stored history's conversation, `` and `` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); 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 a login or interactive profile (`.profile`, `.bash_profile`, `.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 5867ed5f..c20ba14d 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -324,9 +324,10 @@ 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 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. + conversation its Claude writes now, else the most recently written of the one the window + showed last and 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 `.profile`, `.bash_profile` or `.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 637bb62f..14ba227b 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -478,9 +478,9 @@ export function isRemoteClaudeTranscriptPath(path: string): boolean { * 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. + * 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']; @@ -519,10 +519,13 @@ 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 `/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. + * 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 + * `/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 @@ -538,14 +541,18 @@ export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], 'export LC_ALL', 'best=', ...claudeConfigDirLines(), - ...liveLines, - `for id in ${liveLines.length ? '$live ' : ''}${safeIds.map((id) => shellescape(id)).join(' ')}; do`, - ' for f in "$cfg"/projects/*/"$id".jsonl; do', + // newest : `best` becomes that id's transcript if it is newer than the current best. + 'newest() {', + ' for f in "$cfg"/projects/*/"$1".jsonl; do', ' [ -f "$f" ] || continue', ' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi', ' done', - ' [ -n "$best" ] && break', - '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'); diff --git a/src/web/edit-history-sync.ts b/src/web/edit-history-sync.ts index a252d747..1852734a 100644 --- a/src/web/edit-history-sync.ts +++ b/src/web/edit-history-sync.ts @@ -113,10 +113,11 @@ export interface SyncOptions { } /** - * 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. + * 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( diff --git a/src/web/public/edit-history-ui.js b/src/web/public/edit-history-ui.js index 99191696..c871031a 100644 --- a/src/web/public/edit-history-ui.js +++ b/src/web/public/edit-history-ui.js @@ -369,7 +369,7 @@ Object.assign(CodemanApp.prototype, { ? t('Showing saved entries only: {reason}', { reason: data.reason || '' }) : this.sessions?.get?.(h.sessionId)?.remote ? t( - '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.' + '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( '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.' diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 1d6f9ea7..2c7fd332 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -570,8 +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 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': '加载更早的编辑', diff --git a/test/edit-history-sync.test.ts b/test/edit-history-sync.test.ts index 443aaa7f..38c4f19a 100644 --- a/test/edit-history-sync.test.ts +++ b/test/edit-history-sync.test.ts @@ -179,7 +179,7 @@ describe('unsupportedReason', () => { }); describe('remoteTranscriptIds', () => { - it('tries the stored conversation before the launch id: no hook tells Codeman of a remote /clear', () => { + it('includes the stored conversation besides 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']); @@ -734,6 +734,16 @@ describe('reads only on request (wiring guard)', () => { expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/); }); + it('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => { + const server = read('src/web/server.ts'); + const start = server.indexOf('new EditHistorySync({'); + const lookup = server.slice(start, server.indexOf('readSources:', start)); + expect(lookup).toMatch( + /remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/ + ); + expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/); + }); + it('no other module holds the reader, and the edit history modules run no timer or watcher', () => { const root = join(__dirname, '..'); const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' }) diff --git a/test/edit-history.browser.test.ts b/test/edit-history.browser.test.ts index 5aef59bd..b853c72a 100644 --- a/test/edit-history.browser.test.ts +++ b/test/edit-history.browser.test.ts @@ -389,17 +389,20 @@ describe('Edit history panel in a real browser', () => { w.__openFilePreview = w.app.openFilePreview; w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]); }, sessionA); - await reload(); - await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor(); - expect(await thumbs()).toBe(0); - await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click(); - expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]); - await page.evaluate((sid) => { - const w = window as any; - delete w.app.sessions.get(sid).remote; - w.app.openFilePreview = w.__openFilePreview; - }, sessionA); - await reload(); + try { + await reload(); + await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor(); + expect(await thumbs()).toBe(0); + await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click(); + expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]); + } finally { + await page.evaluate((sid) => { + const w = window as any; + delete w.app.sessions.get(sid).remote; + w.app.openFilePreview = w.__openFilePreview; + }, sessionA); + await reload(); + } await page.locator('#editHistoryBody img.edit-history-thumb').waitFor(); }); @@ -473,15 +476,20 @@ describe('Edit history panel in a real browser', () => { 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(); + try { + await reload(); + const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', { + hasText: 'No transcript found on the remote host yet', + }); + await remoteNote.waitFor(); + const text = (await remoteNote.textContent()) ?? ''; + expect(text).not.toContain('next prompt'); + expect(text).toContain('.bash_profile'); + } finally { + await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA); + state.set(sessionA, s); + await reload(); + } }); it('follows the active session, and says when a session is not a Claude session', async () => { diff --git a/test/remote-edit-sources.test.ts b/test/remote-edit-sources.test.ts index ed1d130b..62fa5621 100644 --- a/test/remote-edit-sources.test.ts +++ b/test/remote-edit-sources.test.ts @@ -105,9 +105,16 @@ describe('the transcript lookup on a real shell', () => { expect(command).not.toMatch(/(^|[\s;|(])ls\s/m); }); - it('finds the newest .jsonl of the first id that has one', () => { + it('takes the most recently written transcript of all ids, not the first id that has one', () => { + // conv-1 lies in old/ (1000 s) and new/ (2000 s); solo is older than both, then newest. + const solo = join(projects(), 'old', 'solo.jsonl'); + utimesSync(solo, 1_500, 1_500); expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl')); - expect(locate(['missing', 'solo'])).toBe(join(projects(), 'old', 'solo.jsonl')); + expect(locate(['solo', 'conv-1'])).toBe(join(projects(), 'new', 'conv-1.jsonl')); + expect(locate(['missing', 'solo'])).toBe(solo); + // A respawn resumed the launch conversation after a /clear: the one written last wins. + utimesSync(solo, 3_000, 3_000); + expect(locate(['conv-1', 'solo'])).toBe(solo); }); it('exits 3 when no id has a transcript, and drops ids that are not plain tokens', () => {