From a7a1cef3d63a5fb19805ec49fbbdcd87c40b20d1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 05:05:41 +0200 Subject: [PATCH] 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 --- src/remote-hosts.ts | 63 +++++++++++++++++++++++++++++++++ src/session.ts | 33 +++++++++++++++-- test/remote-ssh-options.test.ts | 33 ++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 753d45ee..9d43213c 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -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> = { + 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 & 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 & RemoteSshOptions, + mode: SessionMode +): Promise { + 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. diff --git a/src/session.ts b/src/session.ts index 989a5e1f..23e29271 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index f13fc8b4..cb565424 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -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(); + }); +});