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) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
This commit is contained in:
Aamer Akhter
2026-07-17 15:49:24 -04:00
parent 7f24a132d0
commit fb013e9de0
11 changed files with 760 additions and 5 deletions
+78 -2
View File
@@ -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 [<COD-107 connection opts>] user@host \
* 'tmux -L codeman attach -t <session>'
*
* - `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;