import { existsSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { basename, dirname, join, resolve } from 'path' import { getParentPid, getProcessComm } from './parent-pid' import { writeSecureJsonAtomic } from './secure-file' const CURRENT_SIM_VERSION = 2 as const const CURRENT_SIM_PATH_ENV = 'SOOTSIM_CLI_CURRENT_SIM_PATH' export interface LocalCurrentTarget { version: 2 plane: 'local' simId: string updatedAt: string } // env vars that tend to identify a specific terminal, pane, or agent run. // checked in order — the first one present wins. anything further down is a // progressively weaker signal of "same caller across invocations". const IDENTITY_ENV_VARS = [ 'RNX_CLI_IDENTITY', // explicit override — fastest path, skips all pid lookups 'TM_SESSION', // Team Machine session id; routes prompt-bar followups back to this agent 'CLAUDE_CODE_SESSION_ID', // claude code, if ever set 'CODEX_THREAD_ID', // codex / openai agent thread, stable across tool invocations 'TERM_SESSION_ID', // macOS Terminal.app + iTerm2 'ITERM_SESSION_ID', // iTerm2 (older variant) 'TMUX_PANE', // tmux — stable per pane 'STY', // GNU screen session 'KITTY_WINDOW_ID', // kitty 'WEZTERM_PANE', // wezterm 'ALACRITTY_WINDOW_ID', // alacritty 'WINDOWID', // X11 terminals 'VSCODE_INJECTION', // vscode integrated terminal ] as const export interface CliIdentity { key: string source: (typeof IDENTITY_ENV_VARS)[number] | 'grand-ppid' | 'ppid' | 'explicit-sim-id' stable: boolean } // derive a stable per-terminal identity key so different agents/terminals // don't share state. priority: // 1. explicit env vars (iTerm, tmux, etc.) // 2. grand-ppid — the parent of our invoking shell, which in Claude Code's // Bash tool is the per-agent claude process (stable across invocations, // distinct between agents). generic — not claude-specific. // 3. ppid — the invoking shell's pid. stable when the shell is reused // across invocations (claude's bash tool, persistent tty sessions). // unstable when each call spawns a fresh shell — in that case set an // env var from IDENTITY_ENV_VARS to pin the identity. // an earlier env-hash fallback was removed: sha'ing process.env looks stable // but in practice any per-turn env drift (timestamps, cwd, agent vars) // flipped the hash and made the same agent look like a new client. // walk up to the topmost non-init ancestor. needed because intermediate // pids drift across invocations: // - claude code's bash tool spawns a fresh shell per call, so the // immediate gppid (the shell's parent) can change // - shell pipelines (`rnx list | grep`) wrap the command in a // subshell, adding an extra ppid layer that shifts gppid down by one // the topmost user process (just below init/launchd) is stable across // all of these — same agent / login shell / terminal across calls. // terminal-multiplexer servers are effectively immortal (the tmux server // lives while ANY session exists), so an owner-pid walk that climbs into one // produces an owner that never dies — detached browser hosts then leak until // an explicit close (real incident: a tmux-launched sim's chrome survived its // pane at 129% cpu). the Team Machine daemon is the same class: it spawns worker // harnesses directly, outlives them all, and additionally collapses every // worker into one shared identity key (real incident 2026-07-14: three film // render hosts leaked with owner = the daemon while load hit 224). stop // BELOW these hosts: the child (pane shell / worker harness) dies with the // session, which is the lifetime a sim owner should have. // these are matched against libproc's pbi_comm, the executable's basename, // truncated to 16 chars. Team Machine reports comm `tm`; matching its product // name never fired and every agent-launched sim resolved its owner to the terminal // tab's /usr/bin/login instead (real incident 2026-08-06: six chrome hosts // survived their agents, 17GB rss, load 19 with the GPU saturated). const IMMORTAL_HOST_COMM_RE = /^(?:tmux|screen)|^tm$/i function findTopAncestor(start: number, maxDepth = 20): number | null { // check each CANDIDATE's own comm and return the child below a multiplexer // (prev). checking only the parent's comm looked equivalent but was not: on // macOS the tmux server is launchd's direct child, so it arrives as the // candidate via the initializer and the parent<=1 branch returned it before // any comm check ran (runtime-caught on a real tmux box: owner resolved to // the immortal server). prev starts at `start` so a pane shell directly // under the server resolves to itself. let prev = start let pid = getParentPid(start) if (!pid || pid <= 1) { // a readable ordinary process directly below init is itself the stable // session root. this is the shape produced by detached job launchers, and // returning null here used to disable the browser owner's lifetime watch. // an unreadable or known-immortal process has no defensible child bound at // this point, so leave it unresolved and make the launcher fail loudly. const comm = getProcessComm(start) return start > 1 && comm && !IMMORTAL_HOST_COMM_RE.test(comm) ? start : null } for (let i = 0; i < maxDepth; i++) { const comm = getProcessComm(pid) if (comm && IMMORTAL_HOST_COMM_RE.test(comm)) return prev const parent = getParentPid(pid) if (!parent || parent <= 1) { // top of the tree we can read. an unreadable comm means a root-owned // session host — /usr/bin/login under a terminal tab — which outlives // every agent and shell inside it, so stop below it. a readable top is // an ordinary user process (a CI job root) and is a valid owner. return comm ? pid : prev } prev = pid pid = parent } return pid } export function getCliIdentity(): CliIdentity { for (const name of IDENTITY_ENV_VARS) { const value = process.env[name] if (value && value.trim()) { return { key: `${name}:${value.trim()}`, source: name, stable: true } } } const topPid = findTopAncestor(process.ppid) if (topPid && topPid > 1) { return { key: `gppid-${topPid}`, source: 'grand-ppid', stable: true } } return { key: `pid-${process.ppid}`, source: 'ppid', stable: false } } export function getCliIdentityKey(): string { return getCliIdentity().key } // the stable owning-session pid: the topmost non-init ancestor of this CLI // process — in Claude Code's Bash tool the per-agent process, otherwise the // login shell / terminal / CI process-tree root. it lives exactly as long as // the session that wants a sim, and dies when that session does. used to tie // a detached browser host's lifetime to its owner so the host self-terminates // (closing Chrome) when the owner exits, instead of leaking a headless // browser forever. returns null on platforms where ancestry can't be read. export function getStableOwnerPid(): number | null { return findTopAncestor(process.ppid) } export function getCurrentSimPath() { if (process.env[CURRENT_SIM_PATH_ENV]) { return resolve(process.env[CURRENT_SIM_PATH_ENV]) } const key = getCliIdentityKey() return join(tmpdir(), `rnx-current-sim-${key}.json`) } function legacyCloudCredentialDirectory(filepath: string): string { return join(dirname(filepath), `${basename(filepath)}.credentials`) } export function readCurrentTarget(): LocalCurrentTarget | null { const filepath = getCurrentSimPath() rmSync(legacyCloudCredentialDirectory(filepath), { recursive: true, force: true }) if (!existsSync(filepath)) return null try { const parsed: unknown = JSON.parse(readFileSync(filepath, 'utf8')) if (parsed === null || typeof parsed !== 'object') { rmSync(filepath, { force: true }) return null } const version = Reflect.get(parsed, 'version') const plane = Reflect.get(parsed, 'plane') const simId = Reflect.get(parsed, 'simId') const updatedAt = Reflect.get(parsed, 'updatedAt') if ( (version !== 1 && version !== CURRENT_SIM_VERSION) || (version === CURRENT_SIM_VERSION && plane !== 'local') || typeof simId !== 'string' || !simId.trim() || typeof updatedAt !== 'string' ) { rmSync(filepath, { force: true }) return null } const target = { version: CURRENT_SIM_VERSION, plane: 'local', simId: simId.trim(), updatedAt, } satisfies LocalCurrentTarget if (version === 1) writeSecureJsonAtomic(filepath, target) return target } catch { rmSync(filepath, { force: true }) return null } } export const readCurrentSim = readCurrentTarget export function readCurrentSimId() { return readCurrentTarget()?.simId ?? null } export function saveCurrentSimId(simId: string) { const normalized = simId.trim() if (!normalized) return writeSecureJsonAtomic(getCurrentSimPath(), { version: CURRENT_SIM_VERSION, plane: 'local', simId: normalized, updatedAt: new Date().toISOString(), } satisfies LocalCurrentTarget) } export function clearCurrentSimId() { rmSync(getCurrentSimPath(), { force: true }) }