Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations

The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:36:20 +01:00
commit c65706a3e4
64 files changed
+5321 -103

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview Discovery contract for FOREIGN tmux sessions — the ones a human
* started by hand (`tmux new -s work`, then `claude` or `codex` inside it), which
* Codeman did not create and does not own.
*
* Codeman's own sessions live on a dedicated, instance-scoped socket
* (`tmux -L codeman`) and are gated by `SAFE_MUX_NAME_PATTERN`. Everything here
* is about the OTHER servers: the default socket, a colleague's `-L` socket, the
* tmux inside a container, the tmux on an ssh host.
*
* Key exports:
* - ForeignTmuxLocation, WHERE a foreign tmux server lives — the same three
* locations Codeman already runs sessions in (local / docker / remote).
* - ForeignPaneProbe, one parsed row of the probe script's output.
* - ForeignTmuxSession, one adoptable candidate as the UI sees it.
* - ForeignDiscoveryResult, one scan of one or more locations.
*
* ⚠️ `location` here describes where a candidate was FOUND. Once adopted, the
* location fact is carried by `SessionAdopt` (types/session.ts) instead. The two
* must never stand in for each other, or an un-adopted candidate starts looking
* like a live session.
*
* No I/O here. The probe/parse/classify core is `src/foreign-tmux.ts`; the three
* transports are `src/foreign-tmux-discovery.ts`.
*/
import type { SessionMode } from './session.js';
/** Where a foreign tmux server lives. Mirrors Codeman's own three locations. */
export type ForeignTmuxLocationKind = 'local' | 'docker' | 'remote';
/**
* A location to scan. `hostId` refers to the saved docker-host / remote-host
* registry entry, so the transport can be rebuilt without trusting the browser.
*/
export interface ForeignTmuxLocation {
kind: ForeignTmuxLocationKind;
/** Registry id (`docker-hosts.json` / `remote-hosts.json`). Absent for local. */
hostId?: string;
/** Display label for the UI. Absent for local. */
label?: string;
/** Container to `docker exec` into (`kind === 'docker'`). */
containerName?: string;
}
/** One pane row from the probe script, after parsing. */
export interface ForeignPaneProbe {
socketPath: string;
sessionName: string;
windowIndex: number;
paneId: string;
panePid: number;
/** tmux's own idea of the running command — `node` for both claude and codex. */
paneCurrentCommand: string;
paneCurrentPath: string;
sessionAttached: boolean;
/** tmux `session_created`, epoch SECONDS. */
sessionCreated: number;
windows: number;
}
/** One adoptable foreign session. */
export interface ForeignTmuxSession {
/**
* Stable, opaque id for the UI and the adopt request. Derived from
* location + socket + session name, so it survives a re-scan and can be
* matched against an already-adopted session without echoing raw paths back
* through the browser as command inputs.
*/
id: string;
location: ForeignTmuxLocationKind;
hostId?: string;
hostLabel?: string;
containerName?: string;
/** Absolute socket path AS SEEN FROM THE LOCATION (not from Codeman's host). */
socketPath: string;
sessionName: string;
windows: number;
attached: boolean;
/** Epoch MILLISECONDS (tmux reports seconds; normalized on parse). */
createdAt: number;
/**
* What the session's FIRST LISTED pane is running, from the bounded process-tree
* walk. Not the active window: claude in window 1 of a session whose window 0 holds
* a shell reads as `shell`. The tab lands on that same first window, so the two agree.
*/
mode: SessionMode;
/** Truncated command line behind the classification, for the UI subtitle. */
command: string;
/** That same pane's cwd. Informational: an adopted pane is never `cd`'d. */
workingDir: string;
/** Codeman session id already wrapping this target, when one exists. */
adoptedBy?: string;
}
/** One discovery scan. Never throws — unreachable locations become notes. */
export interface ForeignDiscoveryResult {
sessions: ForeignTmuxSession[];
/** Epoch ms of the scan the result came from (may be a cache hit). */
scannedAt: number;
/**
* Non-fatal reasons a location produced nothing (host unreachable, no tmux,
* engine down). Surfaced so an empty list is never silently ambiguous.
*/
notes: string[];
}
+1
View File
@@ -75,4 +75,5 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './foreign-tmux.js';
export * from './mcp-sync.js';
+74
View File
@@ -366,6 +366,74 @@ export interface SessionDocker {
owned?: boolean;
}
/**
* Connection facts needed to reach an adopted foreign tmux server. Deliberately
* the exact `Pick`s that `buildSshConnectionArgs` / `buildDockerBaseArgs` consume,
* so an adopted session can rebuild its command after a server restart without
* re-reading a registry that may have been edited in the meantime.
*/
export interface AdoptRemoteConnection extends RemoteSshOptions {
hostId: string;
label: string;
host: string;
username: string;
port?: number;
}
export interface AdoptDockerConnection {
hostId: string;
label: string;
engine: DockerEngine;
containerName: string;
daemonHost?: string;
context?: string;
}
/**
* ADOPTION overlay — a tmux session a HUMAN started, that Codeman attached to.
*
* This is the FOURTH location overlay, alongside remote-SSH and Docker, and it
* obeys the same rule they do: it is **not** a `SessionMode`. The mode is still
* claude / codex / shell / …, decided by the process-tree probe rather than by
* the user.
*
* Shape of an adopted session: Codeman creates a NORMAL wrapper session on its
* OWN socket (`codeman-<8hex>`, so `isValidMuxName` and every capture/input/
* recovery path is untouched) whose single pane runs a command that attaches to
* the foreign target. Killing the tab kills only the wrapper — the foreign
* session is structurally out of reach, exactly like a non-owned remote session.
*
* ⚠️ There is no `owned` field here, unlike `SessionRemote`/`SessionDocker`: an
* adopted session is *by definition* someone else's. A field that could be true
* would invite a code path that kills it.
*/
export interface SessionAdopt {
/** Where the foreign tmux server lives. */
location: 'local' | 'docker' | 'remote';
/** Absolute socket path as seen FROM that location (passed to `tmux -S`). */
socketPath: string;
/** The foreign session we attach to. Never Codeman-named, never name-validated. */
targetSession: string;
/**
* Our own grouped VIEW session on the foreign server. Grouping (rather than a
* bare `attach`) buys `status off` and an independent CURRENT WINDOW, both of
* which land on our session instead of mutating the owner's.
*
* ⚠️ It does NOT buy an independent size, and the comment here used to claim it
* did. A window is one object with one size and a group shares that object, so
* our attach resizes the owner's view exactly as any second tmux client does
* (measured on 3.3a: bare attach and grouped both shrink a 200x49 target to
* 80x23; only `window-size largest` holds it, and that is a shared window
* option which survives our detach — see foreign-tmux.ts for why it is not set).
* Created with `destroy-unattached on` so it evaporates with our pane.
*/
viewSession: string;
/** The pane cwd at adoption time. Informational: an adopted pane is never `cd`'d. */
paneCurrentPath?: string;
remote?: AdoptRemoteConnection;
docker?: AdoptDockerConnection;
}
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
@@ -884,6 +952,12 @@ export interface SessionState {
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
docker?: SessionDocker;
/**
* Adoption metadata, present when this session is a wrapper around a tmux
* session a human started outside Codeman. Its presence is what disables
* respawn/Ralph/orchestrator and hook-backed waits for the session.
*/
adopt?: SessionAdopt;
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
owner?: string;
/**