fix(edit-history): remote lookup finds tmux, a timeout reaches ssh, docs follow

Applies the review of #766.

The live lookup ran tmux on the bare PATH of the ssh command. On a host
where tmux sits in ~/.local/bin or /opt/homebrew/bin it found no pane, the
2>/dev/null hid that, and the panel stayed on the launch conversation after
a /clear. It now goes through withRemoteTmuxPath(), inside the sh script and
scoped to the $(...), so the other tools keep the PATH they had. The fake
tmux of the test block moved to $HOME/.local/bin for the same reason: that
directory is first on the new PATH, so a real tmux is never asked.

The two ssh commands get exec. Node signals the local sh -c, and dash stays
the parent of a plain command, so ssh survived the timeout. A test with a
fake ssh shows the process dies now and survives without exec.

The comment at the meta budget claimed a meta past it comes with a later
read. A meta comes only with a subagent's first read, so it is never sent.

Docs: the store is named as the exception to "never copied to local disk",
remoteProbePaths and the remote transcript lookup are described, and the
line saying a remote session loads no store is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:57:58 +01:00
1 parent cce28c24f9
commit 26b63aea2d
7 files changed
+90 -17

No files matched your search

+26 -5
View File
@@ -30,7 +30,7 @@ import { exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
@@ -429,7 +429,11 @@ const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.m
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
const EDIT_SOURCE_META_MAX_BYTES = 8192;
/** Cap on all meta records of one read; a meta past it is sent with a later read. */
/**
* Cap on all meta records of one read (the local reader has none). A meta comes only with a
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
* as usual, and the panel names it by its id instead of its description.
*/
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
/** Room for the record headers (one per transcript file) on top of budget and metas. */
@@ -451,6 +455,15 @@ export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
}
/**
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
* plain command, which would leave ssh running and the remote read holding its connection.
*/
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
}
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
@@ -495,12 +508,17 @@ function claudeConfigDirLines(): string[] {
* 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`).
*
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
*/
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
return [
'live=',
`pp=$(tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}`)} -F '#{pane_pid}' 2>/dev/null | head -n 1)`,
`pp=$(${withRemoteTmuxPath(`tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}`)} -F '#{pane_pid}' 2>/dev/null | head -n 1`)})`,
"case $pp in ''|*[!0-9]*) pp= ;; esac",
'if [ -n "$pp" ]; then',
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
@@ -582,7 +600,7 @@ export async function remoteLocateClaudeTranscript(
pane?: RemoteClaudePane
): Promise<string | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
const command = buildRemoteEditHistoryCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
@@ -790,7 +808,10 @@ export async function remoteReadClaudeEditSources(
): Promise<EditSources | null> {
assertNotUnderTest();
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const command = buildRemoteFileCommand(remote, buildRemoteClaudeEditSourcesCommand(mainPath, requests, budget, opts));
const command = buildRemoteEditHistoryCommand(
remote,
buildRemoteClaudeEditSourcesCommand(mainPath, requests, budget, opts)
);
const maxBytes = budget + EDIT_SOURCE_META_BUDGET_BYTES + EDIT_SOURCE_HEADER_SLACK_BYTES;
let stdout: Buffer;
try {