From 7f24a132d0ec49ba309554c046f5a311e9cda070 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 10:29:56 -0400 Subject: [PATCH 1/6] COD-104 fix: skip remote tmux prereq check under VITEST (test-mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COD-104 wired checkRemoteTmuxAvailable into the remote-session create path, but it does a real `ssh` via exec — so 2 remote-create tests in session-routes.test.ts hit a ~10s ssh timeout and failed (422). Mirror TmuxManager's IS_TEST_MODE no-op-shell-under-VITEST: short-circuit the live probe to {ok:true} under vitest. Command construction stays covered by buildRemoteTmuxCheckCommand unit tests. session-routes.test.ts now 61/61. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 6ae2c0b8160090a1f0f6b32a3fe8496d402ac2c6) --- src/remote-hosts.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index dbf82cff..7e5d7252 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -173,6 +173,14 @@ export interface RemoteTmuxCheckResult { export async function checkRemoteTmuxAvailable( host: Pick & RemoteSshOptions ): Promise { + // Under vitest, never open a real ssh connection — mirrors TmuxManager's + // no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case + // create-path tests hit a real ~10s ssh timeout. The command construction is + // covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is + // short-circuited here. + if (process.env.VITEST) { + return { ok: true, tmuxPath: '(test-mode)' }; + } const command = buildRemoteTmuxCheckCommand(host); try { const { stdout } = await execAsync(command, { timeout: 15_000 }); From fb013e9de0f5fde312eef6de489bc13c7eccb2bc Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 11:04:18 -0400 Subject: [PATCH 2/6] COD-105 discover + attach existing remote tmux sessions (detach-not-kill) Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already running on a remote host (created by the remote's own Codeman or another instance) and attach to one this Codeman didn't launch, with detach-not-kill ownership for non-owned sessions. - remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws) + pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote now marks the COD-104 launch path owned:true. - tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand); buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains a detach-not-kill early return for non-owned remote sessions: tears down only the LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote kill-session. - types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName. - schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName}; fixed a pre-existing no-useless-escape lint error in the jumpHost regex. - case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery). - session-routes.ts: attachRemoteSession create path -> non-owned session. - UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions" button + Attach action (owned:false). No auto-discover. Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests: parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524) --- src/remote-hosts.ts | 139 +++++++++++++++++++ src/tmux-manager.ts | 80 ++++++++++- src/types/session.ts | 37 +++++ src/web/public/index.html | 11 ++ src/web/public/session-ui.js | 144 ++++++++++++++++++++ src/web/public/styles.css | 31 +++++ src/web/routes/case-routes.ts | 20 ++- src/web/routes/session-routes.ts | 25 +++- src/web/schemas.ts | 16 +++ test/remote-discover-attach.test.ts | 149 +++++++++++++++++++++ test/routes/remote-discover-routes.test.ts | 113 ++++++++++++++++ 11 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 test/remote-discover-attach.test.ts create mode 100644 test/routes/remote-discover-routes.test.ts diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 7e5d7252..92620454 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -8,6 +8,7 @@ import type { RemoteCase, RemoteCommandMode, RemoteHost, + RemoteSessionInfo, RemoteSshOptions, SessionMode, SessionRemote, @@ -210,6 +211,105 @@ export async function checkRemoteTmuxAvailable( } } +/** + * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a + * remote host's canonical `-L codeman` socket. + * + * `list-sessions` exits NON-ZERO with empty output when no sessions exist (and + * the server isn't running), so `2>/dev/null` swallows tmux's "no server + * running" stderr; the caller treats a non-zero exit / empty output as "no + * sessions" rather than an error. + * + * COD-107 — connection options come from the shared `buildSshConnectionArgs`, so + * discovery connects with the SAME port/identity/proxy/jump-host as the launch + * and the tmux prereq probe. + */ +export function buildRemoteListSessionsCommand( + host: Pick & RemoteSshOptions +): string { + const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); + const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)]; + // The tmux list-sessions invocation is passed as ONE shell-quoted argument so + // the remote login shell runs it verbatim. The `-F` format uses literal `\t` + // separators (tmux expands them); `2>/dev/null` is inside the quoted command. + const remoteCmd = + 'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null'; + parts.push(remoteSshTarget(host), shellescape(remoteCmd)); + return parts.join(' '); +} + +/** + * COD-105 — pure parser for the `tmux list-sessions -F` output emitted by + * `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable + * without opening a real ssh connection. + * + * - Splits each non-empty line into [name, attached, created, windows] on the + * field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT + * expand `\t` to a real tab — it emits the LITERAL two-character sequence + * `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal + * backslash-t sequence; we also tolerate a real tab in case a tmux build + * does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the + * regex `\\t`.) + * - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux + * sessions that happen to share the socket). + * - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints. + * - Skips malformed lines (wrong column count or non-numeric created/windows) + * rather than emitting garbage. + */ +export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] { + const out: RemoteSessionInfo[] = []; + for (const rawLine of stdout.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + // Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a + // real tab character. `/\\t|\t/` = the two-char sequence, or a TAB. + const cols = line.split(/\\t|\t/); + if (cols.length !== 4) continue; + const [name, attachedStr, createdStr, windowsStr] = cols; + if (!name.startsWith('codeman-')) continue; + const created = Number(createdStr); + const windows = Number(windowsStr); + if (!Number.isFinite(created) || !Number.isFinite(windows)) continue; + out.push({ + name, + attached: attachedStr.trim() === '1', + created: Math.trunc(created), + windows: Math.trunc(windows), + }); + } + return out; +} + +/** + * COD-105 — discover `codeman-*` tmux sessions already running on a remote host + * (created by the remote's own Codeman, another instance, or this one), so the + * operator can attach to one this Codeman didn't launch. + * + * NEVER throws: returns `[]` on unreachable host / no tmux / no sessions + * (`list-sessions` exits non-zero with empty output when there are none). + * + * VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a + * real ssh never runs in a request path (which would make route tests hit a + * ~10s timeout). The command construction is covered by + * `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`. + */ +export async function listRemoteCodemanSessions( + remote: Pick & RemoteSshOptions +): Promise { + if (process.env.VITEST) { + return []; + } + const command = buildRemoteListSessionsCommand(remote); + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + return parseRemoteSessionList(stdout); + } catch { + // Unreachable host, no tmux server, or no sessions (non-zero exit). All map + // to "nothing to attach to" — never surface as an error to the caller. + return []; + } +} + export function remoteDisplayPath( remote: Pick | { username: string; host: string; path: string } ): string { @@ -226,6 +326,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi port: host.port, remotePath: remoteCase.remotePath, commands: host.commands, + // COD-105 — the COD-104 launch path creates the remote session, so we own it + // (an explicit kill may propagate a remote kill-session). Discovered+attached + // sessions go through `toAttachedSessionRemote` with `owned: false`. + owned: true, // COD-107 — carry the advanced SSH options from host config into the session // so the launch/prereq commands connect the same way the operator configured. identityFile: host.identityFile, @@ -234,3 +338,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi extraSshOptions: host.extraSshOptions, }; } + +/** + * COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*` + * session already running on a remote host (discovered via + * `listRemoteCodemanSessions`). The resulting session's pane runs + * `tmux -L codeman attach -t ` (see + * `buildRemoteAttachCommand`), and because we did NOT create the remote session, + * `owned: false` means closing the tab DETACHES rather than killing it. + * + * `remotePath` is informational here (the attached remote session keeps its own + * cwd); we record the host's nominal path so display helpers still show + * `user@host:path`. + */ +export function toAttachedSessionRemote( + host: RemoteHost, + remoteSessionName: string, + remotePath: string +): SessionRemote { + return { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + remotePath, + commands: host.commands, + // Discovered + attached — another Codeman created it. Detach-not-kill. + owned: false, + remoteSessionName, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0139d20d..298e212f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -812,6 +812,49 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' '); } +/** + * COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux + * session on the remote host (one this Codeman didn't create — discovered via + * `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`. + * + * Emits: + * ssh -o BatchMode=yes -t [] user@host \ + * 'tmux -L codeman attach -t ' + * + * - `attach` (NOT `new-session -A`) so we only join an existing session; the + * remote session keeps running independent of us, which is exactly why the + * resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing + * the local tab must detach, never `kill-session` the remote. + * - The remote session name is shell-escaped so a value with metachars stays a + * single token inside the quoted tmux invocation. + * - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`, + * arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach + * connects identically to launch / discovery / the prereq probe. `-t` sits + * right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux). + */ +export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string { + const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`; + const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote); + const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)]; + return sshParts.join(' '); +} + +/** + * COD-105 — choose the right remote ssh command for a session's ownership: + * - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux + * session by its EXISTING name (`remote.remoteSessionName`, falling back to + * this session's deterministic name). We only join — never create. + * - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand` + * (COD-104), which we then own and may explicitly kill. + */ +function buildRemoteSessionCommand(mode: SessionMode, remote: SessionRemote, sessionId: string): string { + if (remote.owned === false) { + const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId); + return buildRemoteAttachCommand(remote, target); + } + return buildRemoteLaunchCommand({ mode, remote, sessionId }); +} + /** * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. @@ -1273,7 +1316,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // Build the full command to run inside tmux const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -1521,7 +1564,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd; try { // For OpenCode: set sensitive env vars via tmux setenv before respawn @@ -1650,6 +1693,39 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } + // COD-105 — DETACH-NOT-KILL for NON-owned remote sessions. + // + // When this session was created by ATTACHING a remote tmux session another + // Codeman owns (`remote.owned === false`), closing the tab must NOT propagate + // a remote `tmux kill-session` — that would nuke work the remote's own + // Codeman (or another instance) still relies on. We tear down ONLY the LOCAL + // pane that holds the ssh client: killing the local ssh sends SIGHUP to its + // remote `tmux attach`, which DETACHES (the durable remote session survives). + // + // This early return is the structural guarantee: no code below this point + // (now or in future for owned sessions) can ever issue a remote kill-session + // for a non-owned session. The only `kill-session` we run is on OUR LOCAL + // socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the + // local pane — it does NOT reach the REMOTE socket. + if (session.remote && session.remote.owned === false) { + console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`); + if (isValidMuxName(session.muxName)) { + try { + // Local socket only — detaches the remote session by killing the local ssh pane. + execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, { + timeout: EXEC_TIMEOUT_MS, + }); + } catch { + // Local pane may already be gone. + } + } + this.lastPaneCount.delete(session.muxName); + this.sessions.delete(sessionId); + this.saveSessions(); + this.emit('sessionKilled', { sessionId }); + return true; + } + // Get current PID (may have changed) const currentPid = this.getPanePid(session.muxName) || session.pid; diff --git a/src/types/session.ts b/src/types/session.ts index f74d711d..9cac4a71 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -96,6 +96,43 @@ export interface SessionRemote extends RemoteSshOptions { port?: number; remotePath: string; commands?: Partial>; + /** + * COD-105 — whether THIS Codeman created the remote tmux session. + * + * - `true` (default for COD-104 launched sessions): we own the remote session; + * an explicit "kill" may propagate a remote `tmux kill-session`. + * - `false` (discovered + attached an existing remote session another Codeman + * created): closing the local tab must DETACH only — we must NEVER issue a + * remote `kill-session`, or we'd nuke work the remote's own Codeman (or + * another instance) still relies on. See `killSession()` gate. + * + * Absent is treated as owned (legacy/COD-104 sessions persisted before this + * field existed were all launched by us). + */ + owned?: boolean; + /** + * COD-105 — for a NON-owned (discovered + attached) session, the EXISTING + * remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs + * from this Codeman's deterministic `codeman-` name because the remote + * session was created elsewhere. Only meaningful when `owned === false`. + */ + remoteSessionName?: string; +} + +/** + * COD-105 — a `codeman-*` tmux session discovered on a remote host's + * `tmux -L codeman` socket (may have been created by the remote's own Codeman, + * another instance, or this one). Returned by `listRemoteCodemanSessions`. + */ +export interface RemoteSessionInfo { + /** tmux session name (always starts `codeman-`). */ + name: string; + /** Whether a client is currently attached to the remote session. */ + attached: boolean; + /** tmux `session_created` epoch seconds. */ + created: number; + /** Number of windows in the remote session. */ + windows: number; } /** diff --git a/src/web/public/index.html b/src/web/public/index.html index 362e9a07..00d5dc1c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1915,6 +1915,17 @@

Add Case

+ +
+ Discover existing sessions +
+ Find codeman-* tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed. +
+ +
+
+
+
+
+ + + Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff) +