Merge master into PR #156 (remote tmux durability)

Resolutions:
- session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108)
  and add master's docker/owner fields to it
- tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand
  (now an options object threading claudeMode/allowedTools into
  buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade)
- case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery
  endpoint admin-only in multi-user mode (hosts are machine-level infra)
- settings-ui.js: union of remoteAutoReconnect + master's header-button defaults
- session-routes.ts: union of imports; session gets remote + owner

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 14:27:02 +02:00
88 changed files with 11862 additions and 616 deletions
+364 -10
View File
@@ -29,7 +29,8 @@ const execAsync = promisify(exec);
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
import { homedir } from 'node:os';
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
import {
ProcessStats,
PersistedRespawnConfig,
@@ -43,9 +44,24 @@ import {
type EffortLevel,
type GeminiConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
import {
buildDockerBaseArgs,
buildDockerCreateArgs,
containerApiUrl,
CONTAINER_HOME,
defaultDockerCommandForMode,
hostGatewayAlias,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
@@ -571,6 +587,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
switch (mode) {
case 'dangerously-skip-permissions':
return ' --dangerously-skip-permissions';
case 'auto':
return ' --permission-mode auto';
case 'allowedTools':
if (allowedTools) {
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
@@ -682,7 +700,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
return flag && value ? ` ${flag} '${value}'` : '';
}
function buildSpawnCommand(options: {
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
model?: string;
@@ -785,9 +803,22 @@ export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
}): string {
const { mode, remote, sessionId } = options;
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
// command is byte-identical to before. Non-claude modes are unchanged.
const override = remote.commands?.[mode];
const modeCommand = override
? override
: mode === 'claude'
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
: defaultRemoteCommandForMode(mode);
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
@@ -843,6 +874,295 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
}
// ========== Docker cases (COD-Docker) ==========
//
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
// container is per-CASE, so many sessions `docker exec` into the same one. See
// docs/docker-cases-plan.md.
/**
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
* adopts/resizes/respawns our session (same defence as the remote socket).
*/
const DOCKER_TMUX_SOCKET = 'codeman-docker';
/**
* Deterministic, reattach-stable in-container tmux session name. Derived from the
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
*/
export function dockerTmuxSessionName(sessionId: string): string {
return `codeman-dkr-${sessionId.slice(0, 8)}`;
}
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
/**
* Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
* on a live reattach), i.e. exactly when the previous live agent was lost and we
* want to resume the conversation from the bind-mounted transcript. Claude mode
* uses claudeDockerPaneCommand instead.
*/
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
switch (mode) {
case 'gemini':
return `${modeCommand} --resume ${resumeId}`;
case 'codex':
return `${modeCommand} resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
}
/**
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
* `--session-id <sessionId>`, so the in-container conversation id is knowable
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
* hook reachability. When the in-container tmux was re-created after a container
* stop/reboot, the same command re-runs against the surviving transcript:
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
* conversation (verified CLI behavior). An explicit resumeId gets the local
* builder's shape — resume first, session-id fallback — so a stale id never
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
* fall back.
*/
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
const cmd = modeCommand.replace(/^exec\s+/, '');
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
if (rid && rid !== sessionId) {
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
}
const cid = rid ?? sessionId;
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
}
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
export interface DockerLaunchOptions {
mode: SessionMode;
docker: SessionDocker;
sessionId: string;
resumeSessionId?: string;
createContext: DockerCreateContext;
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
execEnv: Record<string, string>;
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
execEnvNames: string[];
/**
* Files to copy from read-only seed mounts into the container's writable HOME once
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
* are writable copies (not host mounts), so the container never re-auths and never
* writes its runtime state back into the host `~/.claude`.
*/
seedCopies?: DockerSeedCopy[];
}
/**
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
const base = buildDockerBaseArgs(docker).join(' ');
const createArgs = buildDockerCreateArgs(createContext).join(' ');
const name = shellescape(docker.containerName);
const workdir = shellescape(docker.containerWorkdir);
const image = shellescape(docker.image);
const dkrName = dockerTmuxSessionName(sessionId);
const sid = sessionId.slice(0, 8);
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
if (mode === 'claude') {
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
// in-container tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
'setenv -g CODEMAN_MUX 1',
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
`set -t ${dkrName} status off`,
`set -t ${dkrName} mouse off`,
`set -t ${dkrName} prefix C-q`,
'set -s escape-time 0',
].join(' \\; ');
const execEnvFlags: string[] = [];
for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`));
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
// the secret never appears in argv (no `ps` leak) and is not committed.
for (const n of execEnvNames) execEnvFlags.push('--env', n);
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
const imageMissingMsg = shellescape(
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
);
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
// constants (no shell metachars), so the whole inner command is shell-quoted once.
const seedSteps = (seedCopies ?? []).map((s) => {
const cp = s.recursive ? 'cp -a' : 'cp';
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
});
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
return [imageCheck, ensure, start, execCmd].join(' ; ');
}
/**
* Kill ONLY this session's in-container tmux session. The container is shared by
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
* (buildDockerRemoveCommand). Fired best-effort on session kill.
*/
export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string {
const { docker, sessionId } = options;
const base = buildDockerBaseArgs(docker).join(' ');
const dkrName = dockerTmuxSessionName(sessionId);
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
}
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
export function buildDockerStopCommand(docker: SessionDocker): string {
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
}
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
export function buildDockerRemoveCommand(docker: SessionDocker): string {
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
}
/**
* Resolve the environment-dependent bits of a docker launch (host uid, existing
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
* launch path (createSession/respawnPane no-op under VITEST).
*/
export function resolveDockerLaunchOptions(
mode: SessionMode,
docker: SessionDocker,
sessionId: string,
resumeSessionId?: string
): DockerLaunchOptions {
const home = homedir();
const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
const userArgs: string[] =
docker.engine === 'podman'
? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
: isDesktop
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
const gatewayAlias = hostGatewayAlias(docker.engine);
const credentialMounts: DockerMount[] = [];
const extraMounts: DockerMount[] = [];
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
// sessions/+history) and seeds everything else (tokens, settings, configs) as
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
// its runtime state back into the host dirs. Only when credentials are mounted.
let seedCopies: DockerSeedCopy[] = [];
if (docker.mountCredentials) {
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
const credArtifacts = resolveDockerCredentialArtifacts(home);
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
}
const envCreate: Record<string, string> = {
HOME: CONTAINER_HOME,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
// is refused when that path pre-exists root-owned — which happens when the
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
// to any workspace location. Non-secret path, safe to be committed on export.
CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
};
if (docker.hooksEnabled) {
// Derive a container-reachable API url (scheme + port preserved; host swapped
// for the engine gateway alias). Prod is HTTPS on 3000.
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
const hookSecretPath = dataPath('hook-secret');
if (existsSync(hookSecretPath)) {
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
}
}
const createContext: DockerCreateContext = {
docker,
sessionId,
instance: CODEMAN_INSTANCE,
userArgs,
credentialMounts,
extraMounts,
envCreate,
addHostGateway: !isDesktop,
gatewayAlias,
};
const execEnv: Record<string, string> = {
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
// renders box-drawing correctly even when reattaching to a container created
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
CODEMAN_MUX: '1',
};
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
// inherits it), so API-key CLIs get their key without it appearing in argv.
const execEnvNames =
mode === 'codex'
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
: mode === 'gemini'
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
: [];
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
}
/**
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
* session on the remote host (one this Codeman didn't create — discovered via
@@ -878,12 +1198,19 @@ export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionNam
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
* (COD-104), which we then own and may explicitly kill.
*/
function buildRemoteSessionCommand(mode: SessionMode, remote: SessionRemote, sessionId: string): string {
function buildRemoteSessionCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
}): string {
const { remote, sessionId } = options;
if (remote.owned === false) {
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
return buildRemoteAttachCommand(remote, target);
}
return buildRemoteLaunchCommand({ mode, remote, sessionId });
return buildRemoteLaunchCommand(options);
}
/**
@@ -1294,6 +1621,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
owner,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -1313,6 +1642,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
createdAt: Date.now(),
workingDir,
remote,
docker,
owner,
mode,
attached: false,
name,
@@ -1358,7 +1689,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -1409,7 +1744,7 @@ 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 ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
@@ -1484,6 +1819,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
createdAt: Date.now(),
workingDir,
remote,
docker,
owner,
mode,
attached: false,
name,
@@ -1569,6 +1906,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -1606,7 +1944,11 @@ 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 = remote ? buildRemoteSessionCommand(mode, remote, sessionId) : localFullCmd;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -1624,7 +1966,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides);
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
@@ -1851,6 +2193,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
// ONLY this session's in-container tmux session (best-effort). The container is
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
if (session.docker && !IS_TEST_MODE) {
try {
exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {});
} catch {
// Best-effort — never affects the local kill result.
}
}
// Strategy 4: Direct kill by PID as final fallback
if (this.isProcessAlive(currentPid)) {
try {