mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user