// user-facing `rnx agent` command tree. // // session-lifecycle commands (start, prompt, end, watch) route through // the bridge daemon — the daemon owns the single FIFO reader per // session, so concurrent cli watches + electron UI subscribers no longer // steal bytes from each other. the daemon auto-spawns on first use via // `connectToDaemon`. // // disk-only inspection commands (projects, sessions, paths, transcript, // attach, project show/remove) read the attached-projects JSON directly — // no daemon needed, so `rnx agent projects` stays fast when no // wrapper is live. import fs from 'node:fs' import path from 'node:path' import { AgentDaemonError, connectToDaemon, type AgentDaemonClient, } from '../../src/agent-daemon-client' import { parseAgentEventLine, type AgentEvent } from '../../src/agent-events' import { eventsFifoPath, pidIsAlive, promptFifoPath, sessionDir, transcriptPath, type Provider, } from '../../src/agent-sessions' import { costThisWeek, deleteProject, findProjectById, getUserDataDir, listProjects, listSessions, upsertProject, type AgentSession, type AttachedProject, } from '../../src/attached-projects' import { printCommandHelp } from '../help' import { resolveDefaultBridgePort } from '../ws-bridge' // the bridge this invocation talks to: `-p`, else the same dev-bridge or // daemon pick every other command makes (PORT_OFFSET and SOOTSIM_BRIDGE_PORT // included). let daemonPort = 0 export async function runAgentCommand( argv: string[], opts: { port?: number } = {}, ): Promise { daemonPort = opts.port ?? resolveDefaultBridgePort() const [sub, ...rest] = argv try { switch (sub) { case undefined: case '--help': case '-h': case 'help': printHelp() return 0 case 'attach': return await cmdAttach(rest) case 'projects': return await cmdProjects() case 'project': return await cmdProject(rest) case 'sessions': return await cmdSessions(rest) case 'start': return await cmdStart(rest) case 'prompt': return await cmdPrompt(rest) case 'watch': return await cmdWatch(rest) case 'transcript': return await cmdTranscript(rest) case 'end': return await cmdEnd(rest) case 'paths': return await cmdPaths() default: process.stderr.write(`unknown agent subcommand: ${sub}\n`) printHelp() return 2 } } catch (err) { if (err instanceof AgentDaemonError) { process.stderr.write(`${err.message}\n`) return 1 } throw err } } /** open a daemon client for a single command. callers are responsible for * closing the client when done so the CLI process exits promptly. */ async function withDaemon(fn: (client: AgentDaemonClient) => Promise): Promise { const client = await connectToDaemon({ port: daemonPort, clientLabel: 'sootsim-agent-cli', }) try { return await fn(client) } finally { client.close() } } function printHelp(): void { // delegate to the meta-driven renderer so bare `rnx agent`, `--help`, // and `unknown subcommand` paths all show the same rich help (header, // options, examples) instead of a sparse hand-coded summary. printCommandHelp('agent') } // --- arg parsing --- function parseFlags( argv: string[], known: Record, ): { flags: Record; positional: string[] } { const flags: Record = {} const positional: string[] = [] for (let i = 0; i < argv.length; i++) { const arg = argv[i] const kind = known[arg] if (kind === 'value') { flags[arg] = argv[i + 1] ?? '' i++ } else if (kind === 'bool') { flags[arg] = true } else { positional.push(arg) } } return { flags, positional } } function resolveProvider(raw: unknown, fallback: Provider = 'codex'): Provider { if (raw === 'codex' || raw === 'claude') return raw return fallback } // --- subcommands --- async function cmdAttach(argv: string[]): Promise { const { flags, positional } = parseFlags(argv, { '--name': 'value', '--provider': 'value', }) const dir = positional[0] if (!dir) { process.stderr.write( 'usage: rnx agent attach [--name X] [--provider codex|claude]\n', ) return 2 } const absolute = path.resolve(dir) if (!fs.existsSync(absolute)) { process.stderr.write(`directory does not exist: ${absolute}\n`) return 1 } const project = upsertProject({ cwd: absolute, name: (flags['--name'] as string) || path.basename(absolute), preferredProvider: resolveProvider(flags['--provider']), sourceRoots: [absolute], }) process.stdout.write( `✓ attached "${project.name}" (id: ${project.id})\n` + ` cwd: ${project.cwd}\n` + ` framework: ${project.framework}\n` + ` preferred provider: ${project.preferredProvider}\n`, ) return 0 } async function cmdProjects(): Promise { const projects = [...listProjects()].sort((a, b) => b.updatedAt - a.updatedAt) if (projects.length === 0) { process.stdout.write('no attached projects yet. try `rnx agent attach `.\n') return 0 } const header = `${'ID'.padEnd(18)} ${'NAME'.padEnd(20)} ${'FRAMEWORK'.padEnd(10)} ${'PROVIDER'.padEnd(8)} ${'7D COST'.padEnd(10)} CWD\n` process.stdout.write(header) for (const p of projects) { const cost = costThisWeek(p) const costCell = cost > 0 ? `$${cost.toFixed(2)}` : '—' process.stdout.write( `${p.id.padEnd(18)} ${truncate(p.name, 20).padEnd(20)} ${p.framework.padEnd(10)} ${p.preferredProvider.padEnd(8)} ${costCell.padEnd(10)} ${p.cwd}\n`, ) } return 0 } async function cmdProject(argv: string[]): Promise { const [action, id] = argv if (action === 'show') { if (!id) { process.stderr.write('usage: rnx agent project show \n') return 2 } const project = findProjectById(id) if (!project) { process.stderr.write(`no project with id=${id}\n`) return 1 } process.stdout.write(JSON.stringify(project, null, 2) + '\n') return 0 } if (action === 'remove') { if (!id) { process.stderr.write('usage: rnx agent project remove \n') return 2 } deleteProject(id) process.stdout.write(`✓ removed project ${id}\n`) return 0 } process.stderr.write('usage: rnx agent project [show|remove] \n') return 2 } async function cmdSessions(argv: string[]): Promise { const { flags } = parseFlags(argv, { '--project': 'value', '--all': 'bool' }) const projectId = (flags['--project'] as string) || undefined const showAll = flags['--all'] === true let sessions = listSessions(projectId) if (!showAll) { sessions = sessions.filter((s) => s.status !== 'ended') } sessions = sessions.sort((a, b) => b.lastSeenAt - a.lastSeenAt) if (sessions.length === 0) { process.stdout.write( showAll ? 'no sessions.\n' : 'no active sessions. pass --all to include ended.\n', ) return 0 } const header = `${'SESSION'.padEnd(26)} ${'PROJECT'.padEnd(18)} ${'PROVIDER'.padEnd(8)} ${'STATUS'.padEnd(16)} ${'ALIVE'.padEnd(5)} LAST PROMPT\n` process.stdout.write(header) for (const s of sessions) { const alive = pidIsAlive(s.wrapperPid, s.id) ? 'yes' : 'no' const prompt = s.lastPrompt ? truncate(s.lastPrompt, 40) : '' process.stdout.write( `${shortId(s.id).padEnd(26)} ${shortId(s.projectId).padEnd(18)} ${s.provider.padEnd(8)} ${s.status.padEnd(16)} ${alive.padEnd(5)} ${prompt}\n`, ) } return 0 } async function cmdStart(argv: string[]): Promise { const { flags, positional } = parseFlags(argv, { '--provider': 'value', '--codex-bin': 'value', '--claude-bin': 'value', '--fresh-thread': 'bool', }) const projectId = positional[0] if (!projectId) { process.stderr.write( 'usage: rnx agent start [--provider codex|claude] [--fresh-thread]\n', ) return 2 } const result = await withDaemon((client) => client.startSession({ projectId, provider: resolveProvider(flags['--provider'], undefined as unknown as Provider) || undefined, codexBin: (flags['--codex-bin'] as string) || undefined, claudeBin: (flags['--claude-bin'] as string) || undefined, freshThread: flags['--fresh-thread'] === true, }), ) const project = findProjectById(result.session.projectId)! process.stdout.write( `✓ session ${result.session.id} started\n` + ` project: ${project.name} (${project.id})\n` + ` provider: ${result.session.provider}\n` + ` pid: ${result.wrapperPid}\n` + ` prompt with: rnx agent prompt ${result.session.id} ""\n` + ` watch events: rnx agent watch ${result.session.id}\n`, ) return 0 } async function cmdPrompt(argv: string[]): Promise { const [sessionId, ...rest] = argv const text = rest.join(' ') if (!sessionId || !text) { process.stderr.write('usage: rnx agent prompt ""\n') return 2 } await withDaemon((client) => client.sendPrompt(sessionId, { text })) process.stdout.write(`✓ prompt sent to ${sessionId}\n`) return 0 } async function cmdWatch(argv: string[]): Promise { const sessionId = argv[0] if (!sessionId) { process.stderr.write('usage: rnx agent watch \n') return 2 } process.stdout.write(`watching ${sessionId}\n---\n`) // the daemon owns the FIFO reader. we stay subscribed until SIGINT — // this is the one command that requires a persistent daemon client. const client = await connectToDaemon({ port: daemonPort, clientLabel: 'sootsim-agent-watch', }) let resolveExit: (code: number) => void const exit = new Promise((resolve) => { resolveExit = resolve }) const offEvent = client.onAgentEvent(({ sessionId: sid, event }) => { if (sid !== sessionId) return process.stdout.write(formatEvent(event) + '\n') }) const offDisconnect = client.onDisconnect(() => { process.stderr.write('\n[rnx] daemon disconnected; exiting watch.\n') resolveExit(1) }) try { await client.subscribeEvents(sessionId) } catch (err) { offEvent() offDisconnect() client.close() throw err } process.on('SIGINT', () => { offEvent() offDisconnect() void client.unsubscribeEvents(sessionId).catch(() => {}) client.close() resolveExit(130) }) return exit } function formatEvent(ev: AgentEvent): string { const ts = fmtTs(ev.ts) switch (ev.type) { case 'ready': return `[${ts}] ready ${ev.provider} @ ${ev.cwd}` case 'prompt-received': return `[${ts}] prompt "${truncate(ev.text, 120)}"` case 'turn-started': return `[${ts}] turn-started ${ev.turnId ?? ''}` case 'turn-message': return `[${ts}] turn-message ${ev.delta.replace(/\s+$/, '')}` case 'turn-reasoning': return `[${ts}] turn-reasoning ${truncate(ev.delta, 200)}` case 'tool-call': return `[${ts}] tool-call ${ev.name}` case 'file-edited': return `[${ts}] file-edited ${ev.kind.padEnd(6)} ${ev.path}` case 'turn-completed': return `[${ts}] turn-completed ${ev.durationMs}ms files=${ev.filesTouched.length}` case 'approval-needed': return `[${ts}] approval ${ev.kind}` case 'error': return `[${ts}] error ${ev.message}` case 'exited': return `[${ts}] exited code=${ev.code}` case 'file-diff-delta': return `[${ts}] file-diff-delta ${ev.path}` case 'turn-plan': return `[${ts}] turn-plan steps=${ev.steps.length}` default: return `[${ts}] ${(ev as { type: string }).type}` } } async function cmdTranscript(argv: string[]): Promise { const sessionId = argv[0] if (!sessionId) { process.stderr.write('usage: rnx agent transcript \n') return 2 } const p = transcriptPath(sessionId) if (!fs.existsSync(p)) { process.stderr.write(`transcript not found: ${p}\n`) return 1 } process.stdout.write(fs.readFileSync(p, 'utf8')) return 0 } async function cmdEnd(argv: string[]): Promise { const sessionId = argv[0] if (!sessionId) { process.stderr.write('usage: rnx agent end \n') return 2 } await withDaemon((client) => client.endSession(sessionId)) process.stdout.write(`✓ ended session ${sessionId}\n`) return 0 } async function cmdPaths(): Promise { const dir = getUserDataDir() process.stdout.write( `userData: ${dir}\n` + `state: ${path.join(dir, 'attached-projects.json')}\n` + `sessions: ${path.join(dir, 'sessions')}\n` + `transcripts: ${path.join(dir, 'transcripts')}\n` + `locks: ${path.join(dir, 'locks')}\n`, ) return 0 } // --- utilities --- function truncate(s: string, max: number): string { if (s.length <= max) return s return s.slice(0, max - 1) + '…' } function shortId(id: string): string { return id.length > 14 ? `${id.slice(0, 14)}…` : id } function fmtTs(ts: number): string { return new Date(ts).toISOString().replace('T', ' ').slice(0, 19) } // keep these importable for the legacy e2e script paths — the canonical // source of truth is now `sootsim/agent-sessions`. export const __internals = { sessionDir, promptFifoPath, eventsFifoPath, transcriptPath, parseAgentEventLine, } export type { AgentSession, AttachedProject }