// session lifecycle shared between the CLI (`sootsim agent ...`) and electron // main (IPC handlers). both contexts spawn the same `sootsim agent-wrapper` // subcommand, mutate the same AttachedProjects store, and subscribe to the // same events.out FIFO. // // this file is the canonical implementation; thin wrappers in the CLI and // electron layers call these functions for their UX needs. import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import fs, { constants as fsConstants } from 'node:fs' import path from 'node:path' import readline from 'node:readline' import { rnxSelfInvocation, type RnxSelfInvocation } from '../cli/self-invocation.ts' import { IS_STANDALONE } from '../cli/standalone.ts' import { parseAgentEventLine, type AgentEvent } from './agent-events.ts' import { encodeAgentPromptEnvelope, type AgentPromptEnvelope } from './agent-prompt.ts' import { findProjectById, findSessionById, getUserDataDir, listSessions, updateSessionStatus, upsertSession, type AgentSession, } from './attached-projects.ts' import { rnxPublicBrand } from './public-brand.ts' export type Provider = 'codex' | 'claude' // --- fs layout --- export function sessionDir(sessionId: string): string { return path.join(getUserDataDir(), 'sessions', sessionId) } export function promptFifoPath(sessionId: string): string { return path.join(sessionDir(sessionId), 'prompt.in') } export function eventsFifoPath(sessionId: string): string { return path.join(sessionDir(sessionId), 'events.out') } export function transcriptPath(sessionId: string): string { return path.join(getUserDataDir(), 'transcripts', `${sessionId}.log`) } // --- pid helpers --- /** returns true iff a process with this pid is alive AND owned by us. pid * recycling means kill(0) can return true for an unrelated process that * happened to take the same number — we also require the sentinel file * written by the wrapper at startup to still exist. */ export function pidIsAlive(pid: number | undefined, sessionId?: string): boolean { if (!pid) return false try { process.kill(pid, 0) } catch { return false } if (sessionId) { // sentinel: the events.out fifo's session dir. if the session was ended // via cmdEnd, the dir was removed; kill(0) returning true on that pid // would be a recycled pid from an unrelated process. if (!fs.existsSync(sessionDir(sessionId))) return false } return true } // --- invocation resolution --- /** locate the rnx CLI so this process can run a subcommand of it. callers * range from the CLI itself to electron main and the vite-plugin daemon. * priority: * 1. RNX_BIN env var (explicit override) * 2. this process already IS the CLI — `rnxSelfInvocation` owns that answer * 3. electron prod: bundled binary under process.resourcesPath/bin/ * 4. workspace build artifacts (dist-bin/ or dist-cli/bin.js) * — shared between electron dev, the vite-plugin-hosted daemon, and * any other node process that isn't launched with the CLI entry * script directly (e.g. argv[1] = .../node_modules/.bin/vite). * 5. CLI dev (argv[1] points at the entry script): re-use argv[0] + argv[1] */ export function resolveSootsimInvocation(): RnxSelfInvocation { if (process.env.RNX_BIN) { return { executable: process.env.RNX_BIN, prefixArgs: [] } } // the compiled standalone binary cannot be found through argv: bun reports // argv[0] as the literal string "bun" and argv[1] as a path inside its // virtual filesystem, so both fall through to spawning a `bun` that a // shell-installed user does not have. if (IS_STANDALONE) return rnxSelfInvocation() // electron prod: packaged binary lives under Resources/bin/. if (process.versions.electron) { const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }) .resourcesPath if (resourcesPath) { const candidates = [ path.join(resourcesPath, 'bin', 'sootsim'), path.join(resourcesPath, 'bin', `sootsim-${process.platform}-${process.arch}`), ] for (const c of candidates) { if (fs.existsSync(c)) return { executable: c, prefixArgs: [] } } } } // workspace build artifacts — tried for every non-CLI-entry node context // (electron dev, vite-plugin-hosted daemon, random script) before falling // back to argv. prefers the compiled bun binary because `bun dev` runs // `watch:cli:binary`; the same dev graph runs `watch:cli` to keep the node // fallback and browser-facing dist-lib exports fresh. still warn when a // caller starts outside that graph with stale artifacts. const workspace = tryWorkspaceSootsim() if (workspace) return workspace // argv fallback. only trust it when argv[1] looks like a real entry // script — `.js`/`.ts`/`.mjs` etc. launching vite gives us argv[1] = // `…/node_modules/.bin/vite` (no extension), which would make us try // to run agent-wrapper under the vite shim and silently fail. const argv1 = process.argv[1] if (argv1 && /\.(ts|tsx|mjs|cjs|js)$/.test(argv1)) { return { executable: process.argv[0], prefixArgs: [argv1] } } // anything else — a bin shim such as `…/node_modules/.bin/vite`, or no // argv[1] at all — names no rnx entry script, so say so instead of // spawning the host runtime with no arguments. throw new Error( 'rnx CLI not found. set RNX_BIN to the path of the rnx binary, ' + 'or build the workspace CLI via `bun run --cwd packages/sootsim build:cli`.', ) } function tryWorkspaceSootsim(): RnxSelfInvocation | null { try { const sootsimDir = resolveSootsimPackageDir() if (!sootsimDir) return null const binaryName = `rnx-${ process.platform === 'win32' ? 'windows' : process.platform }-${process.arch}${process.platform === 'win32' ? '.exe' : ''}` const distBinary = path.join(sootsimDir, 'dist-bin', binaryName) if (fs.existsSync(distBinary)) return { executable: distBinary, prefixArgs: [] } const distBin = path.join(sootsimDir, 'dist-cli', 'bin.js') if (fs.existsSync(distBin)) { try { const src = path.join(sootsimDir, 'cli', 'commands', 'agent-wrapper.ts') if (fs.existsSync(src)) { const srcMtime = fs.statSync(src).mtimeMs const buildMtime = fs.statSync(distBin).mtimeMs if (buildMtime < srcMtime) { console.warn( `[rnx] dist-cli/bin.js is older than agent-wrapper.ts — ` + `rebuild with \`bun run --cwd packages/sootsim build:cli\`.`, ) } } } catch {} return { executable: process.execPath, prefixArgs: [distBin] } } return null } catch { return null } } /** locate the workspace sootsim package directory. tries `require.resolve` * first (works in most contexts), then walks up from this module file * looking for `packages/sootsim/package.json` (works when sootsim is * loaded via vite's native TS resolution, where require.resolve doesn't * know about the workspace). */ function resolveSootsimPackageDir(): string | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const resolved = require.resolve(`${rnxPublicBrand.packageName}/package.json`) return path.dirname(resolved) } catch {} // walk up from this file: .../packages/sootsim/src/agent-sessions.ts // → .../packages/sootsim/. the module path is whatever the runtime // gave us; when bundled by esbuild this will be the bundle path, not // the source path, which is why we try require.resolve first. const here = fileFromImportMeta() if (!here) return null let cur = path.dirname(here) for (let i = 0; i < 8; i++) { const pkg = path.join(cur, 'package.json') try { if (fs.existsSync(pkg)) { const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')) as { name?: string } if (parsed.name === rnxPublicBrand.packageName) return cur } } catch {} const parent = path.dirname(cur) if (parent === cur) break cur = parent } return null } function fileFromImportMeta(): string | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const url = (import.meta as unknown as { url?: string }).url if (!url || !url.startsWith('file://')) return null return decodeURIComponent(url.slice('file://'.length)) } catch { return null } } // --- lockfile for start races --- async function withStartLock( projectId: string, provider: Provider, fn: () => Promise, ): Promise { const lockDir = path.join(getUserDataDir(), 'locks') fs.mkdirSync(lockDir, { recursive: true }) try { fs.chmodSync(lockDir, 0o700) } catch {} const lockPath = path.join(lockDir, `start-${projectId}-${provider}.lock`) const deadline = Date.now() + 4000 let fd: number | null = null while (fd === null) { try { fd = fs.openSync( lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600, ) } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err // staleness check: if the pid in the lockfile is dead, steal it try { const stale = Number(fs.readFileSync(lockPath, 'utf8').trim()) if (stale && !isProcessAlive(stale)) { fs.unlinkSync(lockPath) continue } } catch {} if (Date.now() > deadline) { throw new Error( `another start is in progress for project=${projectId} provider=${provider} ` + `(lock: ${lockPath})`, ) } await new Promise((r) => setTimeout(r, 50)) } } try { fs.writeFileSync(fd, String(process.pid)) return await fn() } finally { try { fs.closeSync(fd) } catch {} try { fs.unlinkSync(lockPath) } catch {} } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0) return true } catch { return false } } // --- FIFO helpers --- export function mkfifoSync(p: string): void { const parent = path.dirname(p) fs.mkdirSync(parent, { recursive: true }) try { fs.chmodSync(parent, 0o700) } catch {} if (fs.existsSync(p)) { try { const stat = fs.statSync(p) if (stat.isFIFO()) { try { fs.chmodSync(p, 0o600) } catch {} return } fs.unlinkSync(p) } catch { fs.unlinkSync(p) } } const result = spawnSync('mkfifo', ['-m', '600', p]) if (result.status !== 0) { throw new Error( `mkfifo(${p}) failed: ${result.stderr?.toString().trim() || 'unknown error'}`, ) } } // --- session lifecycle --- export interface StartSessionOpts { projectId: string provider?: Provider codexBin?: string claudeBin?: string freshThread?: boolean readyTimeoutMs?: number } export interface StartSessionResult { session: AgentSession /** child pid — the same value stored on session.wrapperPid */ wrapperPid: number } export class AgentSessionError extends Error { code: string constructor(code: string, message: string) { super(message) this.code = code } } export async function startSession(opts: StartSessionOpts): Promise { const project = findProjectById(opts.projectId) if (!project) { throw new AgentSessionError('NO_PROJECT', `no project with id=${opts.projectId}`) } const provider: Provider = opts.provider || project.preferredProvider || 'codex' return withStartLock(project.id, provider, async () => { // re-check inside the lock. a concurrent start that slipped through the // pre-check can't slip through both the check AND the lock. const existingLive = listSessions(project.id).find( (s) => s.provider === provider && s.status !== 'ended' && pidIsAlive(s.wrapperPid, s.id), ) if (existingLive) { throw new AgentSessionError( 'ALREADY_RUNNING', `session already running for project=${project.id} provider=${provider} ` + `(session ${existingLive.id}, pid ${existingLive.wrapperPid}). ` + 'end it first with `rnx agent end `.', ) } // generate the claude session uuid once and persist it so successive // wrapper restarts resume the same `~/.claude/projects//.jsonl` // file. codex has its own thread-id persistence via `thread/list`, so we // only need this for claude. const claudeSessionUuid = provider === 'claude' ? randomUUID() : undefined const session = upsertSession({ projectId: project.id, provider, transport: 'pty', cwd: project.cwd, status: 'idle', claudeSessionUuid, }) const promptIn = promptFifoPath(session.id) const eventsOut = eventsFifoPath(session.id) const transcript = transcriptPath(session.id) mkfifoSync(promptIn) mkfifoSync(eventsOut) // transcripts dir locked too const transcriptDir = path.dirname(transcript) fs.mkdirSync(transcriptDir, { recursive: true }) try { fs.chmodSync(transcriptDir, 0o700) } catch {} const { executable, prefixArgs } = resolveSootsimInvocation() const wrapperArgs = [ ...prefixArgs, 'agent-wrapper', '--session-id', session.id, '--project-id', project.id, '--provider', provider, '--cwd', project.cwd, '--prompt-in', promptIn, '--events-out', eventsOut, '--transcript', transcript, ] if (opts.codexBin) wrapperArgs.push('--codex-bin', opts.codexBin) if (opts.claudeBin) wrapperArgs.push('--claude-bin', opts.claudeBin) if (opts.freshThread) wrapperArgs.push('--fresh-thread') if (claudeSessionUuid) { wrapperArgs.push('--claude-session-uuid', claudeSessionUuid) } const child = spawn(executable, wrapperArgs, { detached: true, stdio: 'ignore', env: { ...process.env, SOOTSIM_USER_DATA_DIR: getUserDataDir(), }, }) child.unref() const readyTimeout = opts.readyTimeoutMs ?? 6000 const boot = await waitForFirstEvent( eventsOut, (e) => e.type === 'ready' || e.type === 'error', readyTimeout, ) if (!boot || boot.type === 'error') { if (child.pid) { try { process.kill(child.pid, 'SIGTERM') } catch {} } try { fs.rmSync(sessionDir(session.id), { recursive: true, force: true }) } catch {} updateSessionStatus(session.id, { status: 'ended' }) const reason = boot && boot.type === 'error' ? boot.message : `no ready event within ${readyTimeout}ms` throw new AgentSessionError('WRAPPER_FAILED', reason) } updateSessionStatus(session.id, { wrapperPid: child.pid, status: 'idle', }) const updated = findSessionById(session.id)! return { session: updated, wrapperPid: child.pid! } }) } export async function sendPrompt( sessionId: string, prompt: AgentPromptEnvelope, ): Promise { const session = findSessionById(sessionId) if (!session) { throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`) } if (!pidIsAlive(session.wrapperPid, sessionId)) { updateSessionStatus(sessionId, { status: 'ended' }) throw new AgentSessionError( 'NOT_ALIVE', `session wrapper is not alive (pid=${session.wrapperPid}). start a new session.`, ) } const fifo = promptFifoPath(sessionId) if (!fs.existsSync(fifo)) { throw new AgentSessionError('NO_FIFO', `prompt FIFO missing: ${fifo}`) } const fd = fs.openSync(fifo, fsConstants.O_WRONLY) try { const wireText = encodeAgentPromptEnvelope(prompt) if (!wireText) { throw new AgentSessionError('EMPTY_PROMPT', 'prompt text is empty') } fs.writeSync(fd, wireText + '\n') } finally { fs.closeSync(fd) } updateSessionStatus(sessionId, { lastPrompt: prompt.displayText ?? prompt.text, status: 'working', }) } export async function endSession(sessionId: string): Promise { const session = findSessionById(sessionId) if (!session) { throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`) } if (pidIsAlive(session.wrapperPid, sessionId)) { try { process.kill(session.wrapperPid!, 'SIGTERM') } catch {} } // guard rm: only delete paths we fully own, derived from getUserDataDir() const dir = sessionDir(sessionId) const base = getUserDataDir() if (dir.startsWith(base)) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} } updateSessionStatus(sessionId, { status: 'ended', wrapperPid: undefined }) } // --- event subscription --- /** open events.out and stream events to onEvent. returns an unsubscribe fn. * this is a single-reader channel — only ONE caller (the bridge daemon's * AgentHost) should hold a live subscription at a time. every other * consumer (electron, cli watch, browser shell) routes through the daemon * via the agent:* ws protocol, where the daemon fans events out to N * subscribers without re-reading the FIFO. */ export function subscribeEvents( sessionId: string, onEvent: (event: AgentEvent) => void, ): () => void { const fifo = eventsFifoPath(sessionId) if (!fs.existsSync(fifo)) { throw new AgentSessionError('NO_FIFO', `events FIFO missing: ${fifo}`) } // O_RDWR keeps the FIFO open even if every writer closes. the empty line in // unsubscribe wakes any blocking read before destroy closes the fd; without // that wake, repeated daemon teardown leaves reads occupying libuv workers // and later sessions stop receiving events. const fd = fs.openSync(fifo, fsConstants.O_RDWR) const stream = fs.createReadStream('', { fd, autoClose: true }) const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }) rl.on('line', (line) => { const event = parseAgentEventLine(line) if (event) onEvent(event) }) let closed = false return () => { if (closed) return closed = true try { fs.writeSync(fd, '\n') } catch {} try { rl.close() } catch {} try { stream.destroy() } catch {} } } // --- wait for a specific event (used by startSession + watch tools) --- async function waitForFirstEvent( fifo: string, predicate: (e: AgentEvent) => boolean, timeoutMs: number, ): Promise { const fd = fs.openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK) const buf = Buffer.alloc(8192) let leftover = '' const deadline = Date.now() + timeoutMs try { while (Date.now() < deadline) { let n = 0 try { n = fs.readSync(fd, buf, 0, buf.length, null) } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EAGAIN') throw err n = 0 } if (n > 0) { leftover += buf.subarray(0, n).toString('utf8') let idx: number while ((idx = leftover.indexOf('\n')) >= 0) { const line = leftover.slice(0, idx) leftover = leftover.slice(idx + 1) const event = parseAgentEventLine(line) if (event && predicate(event)) return event } } else { await new Promise((r) => setTimeout(r, 30)) } } return null } finally { fs.closeSync(fd) } }