fix(session): probe the Claude CLI version over ssh for remote sessions

Remote Claude sessions were the one backend left relying on the
startup-banner scrape for cliVersion (the unreliable path #154 was filed
for: newer Claude Code builds print no banner and resumed sessions never
do), so wheel/touch forwarding silently stayed off for them. Mirror the
docker approach: a deferred best-effort probe at session start, running
claude --version on the remote host through the same
buildSshConnectionArgs + login-shell wrapper as the real launch, parsing
the first semver in stdout (an interactive login shell may echo rc-file
noise around it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-07 05:05:41 +02:00
parent a1d7ec02e9
commit a7a1cef3d6
3 changed files with 126 additions and 3 deletions
+63
View File
@@ -256,6 +256,69 @@ export async function checkRemoteTmuxAvailable(
}
}
/**
* The CLI binary each session mode runs on the remote host. Antigravity's
* binary is `agy` (the mode name is not the command); shell has no CLI to
* probe, so it is absent.
*/
const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
claude: 'claude',
opencode: 'opencode',
codex: 'codex',
gemini: 'gemini',
antigravity: 'agy',
};
/**
* Build the SSH command that reads the remote CLI's version (`claude --version`
* on the remote host). The version query is routed through
* `remoteLoginShellCommand` (the SAME `$SHELL -i -l -c` wrapper the real
* launch uses), because agent CLIs live on PATH only after the remote user's
* interactive-login startup files run (see defaultRemoteCommandForMode); a bare
* `claude --version` over ssh exits 127. Connection options come from the
* shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the
* launch can reach. Returns null for modes with no CLI (shell).
*/
export function buildRemoteCliVersionProbeCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
mode: SessionMode
): string | null {
const bin = REMOTE_CLI_BIN[mode];
if (!bin) return null;
return [
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(remoteLoginShellCommand(`${bin} --version`)),
].join(' ');
}
/**
* Read the CLI version installed ON THE REMOTE HOST. Feeds Session.cliVersion
* for remote sessions: the deterministic local probe deliberately skips them
* (it would report the LOCAL host's claude), and the startup-banner scrape is
* unreliable (newer Claude Code builds print no banner; resumed sessions never
* do), which left cliVersion undefined and silently disabled wheel-forwarding
* to the CLI transcript (residual #154, noted in the #205 analysis). The
* version is parsed as the first semver in stdout, never raw output: an
* interactive-login shell may echo rc-file noise around it. Returns undefined
* on any failure. No-op under VITEST (mirrors checkRemoteTmuxAvailable).
*/
export async function probeRemoteCliVersion(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
mode: SessionMode
): Promise<string | undefined> {
if (process.env.VITEST) return undefined;
const command = buildRemoteCliVersionProbeCommand(host, mode);
if (!command) return undefined;
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
const match = stdout.match(/\d+\.\d+\.\d+/);
return match ? match[0] : undefined;
} catch {
return undefined;
}
}
/**
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
* remote host's canonical `-L codeman` socket.
+31 -2
View File
@@ -54,6 +54,7 @@ import {
type SessionDocker,
} from './types.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import { probeRemoteCliVersion } from './remote-hosts.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
@@ -226,6 +227,8 @@ const IS_TEST_MODE = !!process.env.VITEST;
const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);';
/** Delay before the in-container Claude CLI version probe (lets the container start). */
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */
const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000;
/**
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
@@ -1534,8 +1537,8 @@ export class Session extends EventEmitter {
// never show it — which left cliVersion undefined and silently disabled
// wheel-forwarding to Claude's own transcript (the only route to history in
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
// another host, so a local probe wouldn't reflect their version — skip them
// and let the banner scrape handle those. Cached process-wide, best-effort.
// another host, so a local probe wouldn't reflect their version; they get
// their own over-ssh probe below. Cached process-wide, best-effort.
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = getClaudeCliVersion();
if (probedVersion) {
@@ -1574,6 +1577,32 @@ export class Session extends EventEmitter {
}, DOCKER_CLI_VERSION_PROBE_DELAY_MS);
}
// Remote sessions run claude on ANOTHER HOST, so neither the local nor the
// docker probe applies, and the banner-scrape fallback they were left with
// is the unreliable path #154 was filed for, so remote Claude cases silently
// never got wheel-forwarding (noted in the #205 analysis). Probe over ssh,
// deferred so session start never waits on the ssh round-trip.
if (this.mode === 'claude' && this._remote && !this._cliVersion) {
const remoteMeta = this._remote;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
void probeRemoteCliVersion(remoteMeta, this.mode)
.then((version) => {
if (!version || this._isStopped || this._cliVersion) return;
this._cliVersion = version;
this.emit('cliInfoUpdated', {
version: this._cliVersion,
model: this._cliModel,
accountType: this._cliAccountType,
latestVersion: this._cliLatestVersion,
});
})
.catch(() => {
/* best-effort */
});
}, REMOTE_CLI_VERSION_PROBE_DELAY_MS);
}
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
+32 -1
View File
@@ -20,7 +20,12 @@
import { homedir } from 'node:os';
import { describe, it, expect } from 'vitest';
import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js';
import {
buildSshConnectionArgs,
buildRemoteTmuxCheckCommand,
buildRemoteCliVersionProbeCommand,
remoteSshTarget,
} from '../src/remote-hosts.js';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
@@ -198,3 +203,29 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
});
});
describe('buildRemoteCliVersionProbeCommand: remote CLI version over the same connection (#205)', () => {
it('routes the version query through the interactive-login shell wrapper, like the launch', () => {
const cmd = buildRemoteCliVersionProbeCommand(baseRemote, 'claude');
// Same PATH-resolution wrapper as defaultRemoteCommandForMode: a bare
// `claude --version` over ssh sees only sshd's minimal PATH (exit 127).
expect(cmd).toBe(
'ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ' +
`'exec "\${SHELL:-/bin/sh}" -i -l -c '\\''claude --version'\\'''`
);
});
it('uses the shared connection args (proxy/identity/port), so it reaches what the launch reaches', () => {
const cmd = buildRemoteCliVersionProbeCommand(aaDesktop, 'claude');
expect(cmd).toContain('-o BatchMode=yes');
expect(cmd).toContain('-p 2222');
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
expect(cmd).toContain('aakht@192.168.55.170');
});
it('maps antigravity to its real binary name and shell to no probe at all', () => {
expect(buildRemoteCliVersionProbeCommand(baseRemote, 'antigravity')).toContain('agy --version');
expect(buildRemoteCliVersionProbeCommand(baseRemote, 'shell')).toBeNull();
});
});