import os from 'node:os'; import path from 'node:path'; import pty from 'node-pty'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import { USE_TMUX, cliById, WORKSPACES_DIR } from './config.js'; import { update, list } from './sessions.js'; const TERM_ENV = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', LANG: process.env.LANG || 'C.UTF-8', }; const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc'; const TMUX_CONF = process.env.TMUX_CONF || '/app/tmux.conf'; const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user'; // Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile, // so this is safe in local dev where /app/session.bashrc doesn't exist). const bashLaunch = `exec bash --rcfile ${BASHRC} -i`; const tmuxName = (id) => `am-${id}`; // If the rendered pane text hasn't changed for this long, it's not working. const BUSY_SECS = 4; const paneSig = new Map(); // id -> { sig, changedAt } (changedAt in unix seconds) function djb2(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; } // In direct-PTY (no-tmux) mode we track live handles to report running status. const live = new Map(); // id -> Set(handle) const track = (id, h) => { if (!live.has(id)) live.set(id, new Set()); live.get(id).add(h); }; const untrack = (id, h) => { const s = live.get(id); if (s) { s.delete(h); if (!s.size) live.delete(id); } }; export function isRunning(id) { if (USE_TMUX) { try { execFileSync('tmux', ['has-session', '-t', tmuxName(id)], { stdio: 'ignore' }); return true; } catch { return false; } } return live.has(id); } /** * Detect activity by DIFFING each pane's rendered text between polls. This * ignores colour-only animations (e.g. Codex's shimmering banner) that fooled a * raw output-activity check, while still catching spinners, streaming output and * elapsed-time counters (their text changes). Returns Map id -> { age } where * age is seconds since the pane text last changed. */ let infoMemo = { ts: 0, map: new Map() }; export function agentInfo() { // The sweep shells out to tmux once per session, synchronously. Memoize it // briefly so N browser tabs polling /api/tree don't multiply that cost. if (Date.now() - infoMemo.ts < 1500) return infoMemo.map; const map = new Map(); infoMemo = { ts: Date.now(), map }; if (!USE_TMUX) return map; let list; try { list = execFileSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }); } catch { paneSig.clear(); return map; // no server / no sessions } const now = Math.floor(Date.now() / 1000); const live = new Set(); for (const name of list.split('\n')) { if (!name.startsWith('am-')) continue; const id = name.slice(3); live.add(id); let text = ''; try { text = execFileSync('tmux', ['capture-pane', '-p', '-t', name], { encoding: 'utf8' }); } catch {} const sig = djb2(text); const prev = paneSig.get(id); const changedAt = !prev || prev.sig !== sig ? now : prev.changedAt; paneSig.set(id, { sig, changedAt }); map.set(id, { age: now - changedAt }); } for (const id of [...paneSig.keys()]) if (!live.has(id)) paneSig.delete(id); return map; } /** * Map activity + the session's known CLI into a UI state: * working — pane text is actively changing (thinking / streaming / a command) * waiting — agent alive but its screen is static → it's your turn * idle — a plain shell sitting at its prompt * stopped — no live session */ export function deriveState(session, info) { if (session.cli === 'files') return 'idle'; // passive panel, not a process if (!info) return isRunning(session.id) ? 'idle' : 'stopped'; if (info.age <= BUSY_SECS) return 'working'; return session.cli === 'shell' ? 'idle' : 'waiting'; } // ---------- Codex conversation pinning ---------- // Codex picks its own conversation id at launch and doesn't accept one up // front — but it announces the pick immediately: a rollout file named // rollout--.jsonl appears under $CODEX_HOME/sessions with the cwd in // its first line. Capture that id shortly after launch and pin it on the // session, so restarts resume THIS agent's conversation — `resume --last` // would grab whichever Codex agent in the same folder ran last. const codexCapturing = new Set(); // session ids with a capture in flight function codexSessionsRoot() { const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'); return path.join(home, 'sessions'); } // Rollout files touched since `sinceMs`, newest first. function codexRolloutsSince(sinceMs) { const out = []; const walk = (dir, depth) => { if (depth > 5) return; let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p, depth + 1); else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) { try { const m = fs.statSync(p).mtimeMs; if (m >= sinceMs) out.push({ p, m }); } catch {} } } }; walk(codexSessionsRoot(), 0); return out.sort((a, b) => b.m - a.m); } // First line of a (potentially large) file without reading all of it. function firstLine(p) { const fd = fs.openSync(p, 'r'); try { const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, buf.length, 0); return buf.toString('utf8', 0, n).split('\n', 1)[0]; } finally { fs.closeSync(fd); } } function tryCaptureCodexId(sessionId, workdir, sinceMs) { const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId)); for (const c of codexRolloutsSince(sinceMs)) { const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/); if (!m || claimed.has(m[1])) continue; let meta; try { meta = JSON.parse(firstLine(c.p)); } catch { continue; } if ((meta && meta.payload && meta.payload.cwd) !== workdir) continue; update(sessionId, { codexSessionId: m[1], codexRollout: c.p }); return true; } return false; } function scheduleCodexCapture(session, workdir) { if (session.codexSessionId || codexCapturing.has(session.id)) return; codexCapturing.add(session.id); const since = Date.now() - 2000; const delays = [5000, 15000, 45000]; // rollout appears ~instantly; retries cover slow starts const attempt = (i) => { if (tryCaptureCodexId(session.id, workdir, since) || i + 1 >= delays.length) { codexCapturing.delete(session.id); return; } const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]); if (t.unref) t.unref(); }; const t0 = setTimeout(() => attempt(0), delays[0]); if (t0.unref) t0.unref(); } function commandFor(session) { const cli = cliById(session.cli) || cliById('shell'); if (cli.id === 'shell') return bashLaunch; // Claude keys conversations by working directory, so grouped sessions sharing // a folder would all `--continue` onto the SAME most-recent conversation. Pin // each session to its own conversation id instead: create it with // --session-id, resume it with --resume. Decide resume-vs-fresh by whether the // transcript exists on disk (NOT via `resume || fresh`: that chain also fired // when claude itself exited non-zero, silently respawning a crashed session // as a fresh conversation and eating the pane's "done" signal). The fresh // branch keeps `|| exec claude` as a last resort for an unsupported flag. if (cli.id === 'claude' && session.sessionUuid) { const fresh = `claude --session-id ${session.sessionUuid} || exec claude`; if (!session.everStarted) return fresh; const projects = '"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"'; const hasTranscript = `[ -n "$(find ${projects} -name '${session.sessionUuid}*' -print -quit 2>/dev/null)" ]`; return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`; } // OpenClaw: first run needs its onboarding wizard (keys, workspace); once the // config exists, go straight to the TUI. Decided by config-file existence — // same honest pattern as the Claude transcript check. if (cli.id === 'openclaw') { // OpenClaw runs with its own HOME on local disk (see entrypoint.sh): its // state can't live on the FUSE bucket and it rejects symlinked paths. // Locally (no OPENCLAW_HOME) the real HOME is used unchanged. return 'HOME="${OPENCLAW_HOME:-$HOME}"; export HOME; ' + 'if [ -s "$HOME/.openclaw/openclaw.json" ]; then exec openclaw chat; ' + 'else openclaw onboard && exec openclaw chat; fi'; } // Codex: resume this agent's pinned conversation (captured from its rollout // file after launch — see scheduleCodexCapture). Existence-checked like // Claude, so a purged rollout starts fresh honestly and a crash ends the // pane instead of respawning. Unpinned sessions fall through to the generic // `resume --last` below (correct while the agent has its folder to itself). if (cli.id === 'codex' && session.codexSessionId && session.codexRollout) { return `if [ -f '${session.codexRollout}' ]; then exec codex resume ${session.codexSessionId}; else exec codex; fi`; } // Other agents: resume when one likely exists, else a fresh launch. `exec` so // the agent is the pane's foreground process; when it exits the tmux session // ends — a clear "done" signal — and the fallback preserves that. if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`; return `exec ${cli.run}`; } /** Attach a new PTY client to the session (creating the tmux session if needed). */ export function attach(session, cols, rows) { // The recorded workspace-relative path ('' = the workspaces root itself). // If the folder was deleted or moved, mkdir simply recreates it empty — no // tracking, no magic. const folder = session.path ?? session.id; const workdir = path.join(WORKSPACES_DIR, folder); fs.mkdirSync(workdir, { recursive: true }); const full = commandFor(session); let term; if (USE_TMUX) { const args = []; if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF); args.push( // -A: attach if it exists, else create. We deliberately do NOT pass -D // (detach others): the same session may be open in two browser windows, // and -D + auto-reconnect would make them fight. Instead each client // re-syncs its size on focus (see TerminalPane), and window-size=latest // makes the focused window fit exactly. 'new-session', '-A', '-s', tmuxName(session.id), '-c', workdir, '-e', `AM_SESSION=${folder}`, '-e', `AM_NAME=${session.name}`, '-e', `AM_ID=${session.id}`, '-e', `AM_USER=${AM_USER}`, '-e', `AM_ROOT=${WORKSPACES_DIR}`, // prompt shows $PWD relative to this 'sh', '-lc', full, ); term = pty.spawn('tmux', args, { name: 'xterm-256color', cols, rows, cwd: workdir, env: TERM_ENV }); } else { const env = { ...TERM_ENV, AM_SESSION: folder, AM_NAME: session.name, AM_ID: session.id, AM_USER, AM_ROOT: WORKSPACES_DIR }; term = pty.spawn('bash', ['-lc', full], { name: 'xterm-256color', cols, rows, cwd: workdir, env }); } if (!session.everStarted) update(session.id, { everStarted: true }); if (session.cli === 'codex') scheduleCodexCapture(session, workdir); const handle = { onData: (cb) => term.onData(cb), onExit: (cb) => term.onExit(cb), write: (d) => { try { term.write(d); } catch {} }, resize: (c, r) => { try { term.resize(c, r); } catch {} }, kill: () => { try { term.kill(); } catch {} }, }; track(session.id, handle); term.onExit(() => untrack(session.id, handle)); return handle; } /** * Make sure the session's tmux process exists WITHOUT a browser pane attached * (used by the Overview reply box to wake a stopped agent). Returns true if it * had to spawn. Direct-PTY mode has no detached equivalent — throws if dead. */ export function ensureRunning(session) { if (isRunning(session.id)) return false; if (!USE_TMUX) throw new Error('session is not running'); const folder = session.path ?? session.id; const workdir = path.join(WORKSPACES_DIR, folder); fs.mkdirSync(workdir, { recursive: true }); const args = []; if (fs.existsSync(TMUX_CONF)) args.push('-f', TMUX_CONF); args.push( 'new-session', '-d', '-s', tmuxName(session.id), '-c', workdir, '-x', '200', '-y', '50', // sane size until a client attaches (window-size=latest) '-e', `AM_SESSION=${folder}`, '-e', `AM_NAME=${session.name}`, '-e', `AM_ID=${session.id}`, '-e', `AM_USER=${AM_USER}`, '-e', `AM_ROOT=${WORKSPACES_DIR}`, 'sh', '-lc', commandFor(session), ); execFileSync('tmux', args, { stdio: 'ignore', env: TERM_ENV }); if (!session.everStarted) update(session.id, { everStarted: true }); if (session.cli === 'codex') scheduleCodexCapture(session, workdir); return true; } /** Type a line into the session's terminal (works with no browser attached). */ export function sendInput(id, text) { if (USE_TMUX) { execFileSync('tmux', ['send-keys', '-t', tmuxName(id), '-l', '--', text], { stdio: 'ignore' }); execFileSync('tmux', ['send-keys', '-t', tmuxName(id), 'Enter'], { stdio: 'ignore' }); return; } const set = live.get(id); if (!set || !set.size) throw new Error('session is not running'); set.values().next().value.write(`${text}\r`); } /** Stop a session entirely (kills the tmux session / the running process). */ export function stop(id) { if (USE_TMUX) { try { execFileSync('tmux', ['kill-session', '-t', tmuxName(id)], { stdio: 'ignore' }); } catch {} } else { const s = live.get(id); if (s) for (const h of s) h.kill(); } }