mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
848ab48b0a
commit
245ee68c7c
27 files changed
+2622
-35
No files matched your search
@@ -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[];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user