import os from 'node:os'; import path from 'node:path'; import pty from 'node-pty'; import fs from 'node:fs'; import { remoteState, setPaused } from './remote.js'; import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js'; import { update, list } from './sessions.js'; import { captureOpencodeSession, opencodeSessionExists, readTrace } from './traces.js'; import { buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns, } from './snapshot.js'; import { createTerminalHistoryCheckpoint, loadTerminalHistory, TERMINAL_HISTORY_VERSION, traceHistoryLines, } from './history-store.js'; import { createTerminalModeTracker } from './terminal-modes.js'; // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it // is guarded so a platform without a prebuilt still boots and says so, rather // than taking the whole app down. let ghostty = null; export let ghosttyError = null; try { ghostty = await import('@coder/libghostty-vt-node'); buildPaletteIndex(ghostty.createTerminal); } catch (err) { ghosttyError = String(err && err.message ? err.message : err); console.error('[runner] libghostty-vt unavailable:', ghosttyError); } 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 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`; // ---------- session hosts (what replaced tmux) ---------- // // Every session is one PTY held by THIS process, with a libghostty-vt terminal // fed from its output. That terminal is the authoritative screen, so: // // * a browser attaching gets canonical history plus a snapshot repaint, // instead of asking tmux to redraw and hoping the agent's TUI cooperates; // * several browsers can watch the same session, with one explicit input/size // controller instead of a one-device-at-a-time disconnect handover; // * agent state is a property read rather than a `tmux capture-pane` // subprocess per session per poll. // // PTYs still end with this process, but canonical scrollback is checkpointed to // durable storage below and rejoined with the CLI's resumed screen on relaunch. // Rendered text unchanged for this long means the agent isn't working. const BUSY_SECS = 4; // Re-rendering the grid to text on every chunk during a burst is wasteful. const SAMPLE_THROTTLE_MS = 250; // Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is // a byte budget. Passing a line count such as 20,000 retains only a small native // allocation (about 700 ordinary rows). Keep the unit explicit at our boundary. const DEFAULT_SCROLLBACK_BYTES = 64 * 1024 * 1024; const configuredScrollback = process.env.AM_SCROLLBACK_BYTES === undefined ? Number.NaN : Number(process.env.AM_SCROLLBACK_BYTES); const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScrollback >= 0 ? configuredScrollback : DEFAULT_SCROLLBACK_BYTES; // A layout resize is only worth acting on once it stops changing. ResizeObserver // fires per animation frame while a window is dragged; coalescing that burst // avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH. const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120); // Primary-screen agent TUIs redraw their whole frame after SIGWINCH. Let that // burst settle in a scratch emulator, then merge its final frame once; streaming // the raw redraw would turn every overflow row into duplicate history. const RESIZE_CAPTURE_IDLE_MS = Number(process.env.AM_RESIZE_CAPTURE_IDLE_MS || 80); const RESIZE_CAPTURE_MAX_MS = Number(process.env.AM_RESIZE_CAPTURE_MAX_MS || 900); // A resumed agent paints through the same primary-screen protocol as a resize, // but may pause for seconds between its welcome frame and replayed transcript. // Keep that transaction distinct from the deliberately short resize debounce: // committing its first chunk makes every later chunk look like new history. const STARTUP_CAPTURE_IDLE_MS = Number(process.env.AM_STARTUP_CAPTURE_IDLE_MS || 5000); const STARTUP_CAPTURE_MAX_MS = Number(process.env.AM_STARTUP_CAPTURE_MAX_MS || 20000); // Unlike the PTY process, /data survives a Space rebuild. Checkpoint canonical // scrollback there so deploys and sleeps do not turn a resumed agent into a // terminal with only its freshly repainted viewport. const HISTORY_SAVE_MS = Number(process.env.AM_HISTORY_SAVE_MS || 5000); const HISTORY_DIR = path.join(STATE_DIR, 'terminal-history'); // Claude's resume stream can pause between its welcome frame and replayed // conversation. Treat that as one startup transaction; hydrating during the // pause would seed a turn that Claude is about to print itself. const TRACE_HYDRATE_IDLE_MS = Number(process.env.AM_TRACE_HYDRATE_IDLE_MS || 5000); const TRACE_HYDRATE_MIN_MS = Number(process.env.AM_TRACE_HYDRATE_MIN_MS || 3000); // Bound untrusted WebSocket geometry without imposing the old 400x200 ceiling, // which left visible dead space on high-DPI displays at low zoom levels. const MIN_COLS = 20; const MIN_ROWS = 5; const MAX_COLS = 1000; const MAX_ROWS = 500; const hosts = new Map(); // session id -> host function djb2(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; } export function isRunning(id) { return hosts.has(id); } export function ghosttyReady() { return !!ghostty; } /** Sample the grid's rendered text and record when it last changed. */ function sampleScreen(host) { const now = Date.now(); if (host.lastSampleAt && now - host.lastSampleAt < SAMPLE_THROTTLE_MS) return; host.lastSampleAt = now; let text = ''; try { text = host.vt.getVisibleText(); } catch { return; } const sig = djb2(text); if (host.screenSig !== sig) { host.screenSig = sig; host.screenChangedAt = now; } } /** * Activity per session, keyed like the old tmux sweep so callers don't change. * No subprocess and no memo: the grid is already current, so this is a map build * over held sessions. */ export function agentInfo() { const map = new Map(); const now = Date.now(); for (const [id, host] of hosts) { const changedAt = host.screenChangedAt || host.startedAt; map.set(id, { age: Math.round((now - changedAt) / 1000), bells: host.bells || 0 }); } return map; } /** * Map activity + the session's known CLI into a UI state: * working — screen 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' || session.cli === 'trace') return 'idle'; // passive panels, not processes // A remote agent's liveness comes from its polling, not from a pane we can // capture — there is no tmux session here to diff. if (isRemote(session.cli)) return remoteState(session); if (!info) return isRunning(session.id) ? 'idle' : 'stopped'; if (info.age <= BUSY_SECS) return 'working'; return session.cli === 'shell' ? 'idle' : 'waiting'; } /** The visible screen as text, with no browser attached. */ export function peek(id) { const host = hosts.get(id); if (!host) return null; try { return host.vt.getVisibleText(); } catch { return null; } } // ---------- the shared grid ---------- // // A PTY has exactly one geometry. One attached viewer therefore holds a size // lease (the controller); every other viewer watches the same grid without // changing it. A watcher can take the lease through an explicit interaction. // This prevents a phone or background tab from resizing a desktop session merely // by connecting. function effectiveGrid(host) { return host.controller?.want || { cols: host.cols, rows: host.rows }; } function preferredGrid(cols, rows, fallback) { const c = Number.isFinite(cols) ? Math.round(cols) : fallback.cols; const r = Number.isFinite(rows) ? Math.round(rows) : fallback.rows; return { cols: Math.max(MIN_COLS, Math.min(MAX_COLS, c)), rows: Math.max(MIN_ROWS, Math.min(MAX_ROWS, r)), }; } function notifyGrid(host, reset = false) { for (const sub of host.subs) { sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset); } } function clearCaptureTimers(txn) { if (txn.idleTimer) clearTimeout(txn.idleTimer); if (txn.maxTimer) clearTimeout(txn.maxTimer); txn.idleTimer = null; txn.maxTimer = null; } function logicalText(lines, cols) { let text = ''; for (let line = 0; line < lines.length; line++) { const row = lines[line].text || ''; text += row; // A full terminal row is a soft wrap. Do not insert whitespace: Claude's // words can be split at an arbitrary column between snapshots. A shorter // row ended with a real line break, which must remain part of the overlap. if (textColumns(row) < cols) { text += '\n'; } } return text; } function logicalHistory(text) { if (!text) return []; const lines = text.split('\n'); if (lines.at(-1) === '') lines.pop(); return lines.map((line) => ({ text: line })); } function visibleRows(vt) { try { return vt.getVisibleText().split('\n').map((text) => ({ text })); } catch { return []; } } function longestPrefixOccurrence(pattern, text) { const prefix = new Array(pattern.length).fill(0); for (let i = 1, matched = 0; i < pattern.length; i++) { while (matched && pattern[i] !== pattern[matched]) matched = prefix[matched - 1]; if (pattern[i] === pattern[matched]) matched++; prefix[i] = matched; } let matched = 0, best = 0, bestEnd = -1; for (let i = 0; i < text.length; i++) { while (matched && text[i] !== pattern[matched]) matched = prefix[matched - 1]; if (text[i] === pattern[matched]) matched++; if (matched > best) { best = matched; bestEnd = i; } if (matched === pattern.length) matched = prefix[matched - 1]; } return { length: best, end: bestEnd }; } /** * Merge a primary-screen repaint into the fullest transcript seen so far. * * A growing pane can reveal an archived suffix after a fresh welcome/header, * so the overlap may occur inside the repaint rather than at its first byte. * Searching the reversed strings finds the longest archive suffix anywhere in * the repaint in linear time. The repaint prefix is inserted before that * suffix, preserving a header without retaining two copies of the turns. */ export function mergeRepaintArchive(base, repaint) { if (!base) return repaint; if (!repaint) return base; if (!base.trim()) return repaint; if (!repaint.trim()) return base; const reverse = (text) => { let result = ''; for (let i = text.length - 1; i >= 0; i--) result += text[i]; return result; }; // Shape 1: an archive suffix is revealed after a freshly painted header. const suffix = longestPrefixOccurrence( reverse(base.slice(-repaint.length)), reverse(repaint), ); // Shape 2: the repaint starts in the archive, then replaces its volatile // footer (dimensions, status line, prompt chrome) with the new one. const prefix = longestPrefixOccurrence(repaint.slice(0, base.length), base); const minimum = Math.min(12, base.trim().length, repaint.trim().length); if (Math.max(suffix.length, prefix.length) < minimum) return base + repaint; if (prefix.length > suffix.length) { const baseOffset = prefix.end - prefix.length + 1; return base.slice(0, baseOffset) + repaint; } const repaintOffset = repaint.length - 1 - suffix.end; return repaint.slice(0, repaintOffset) + base + repaint.slice(repaintOffset + suffix.length); } /** * Replace the archive's old visible tail with a new repaint. * * Resize output is presentation, never appended terminal output. Match the * longest prefix of the new visible grid inside the archive; everything before * that point is the hidden prefix, and the new grid replaces everything after * it (including dimension-dependent status/footer text). */ export function repaintArchiveView(archive, visible, fallbackHistory = '') { if (!visible) return { archive, history: logicalHistory(archive) }; const leading = visible.match(/^(?:[ \t]*\n)*/)?.[0].length || 0; const candidate = visible.slice(leading); let match = candidate ? longestPrefixOccurrence(candidate, archive) : { length: 0, end: -1 }; // A few shared characters are not a safe repaint boundary in a substantial // screen. Claude's randomized status lines, for example, all begin with // "Worked for ". Anchoring a fresh screen there can retain the old turn that // precedes a later status line, then append that same turn again. Require a // meaningful overlap for long screens, while still accepting complete short // prompts in small panes. const archiveContentLength = archive.trim().length; const confidence = (tail) => Math.min( 64, archiveContentLength, Math.max(12, Math.floor(tail.trim().length / 2)), ); let minimum = confidence(candidate); // A repaint may start with one volatile row before its stable transcript. // If the first row offers only a weak match, advance by logical lines until // the first substantial overlap is found. The visible grid is bounded to 500 // rows, and the cap keeps a maliciously fragmented screen from repeatedly // scanning a large archive. if (match.length < minimum) { let offset = 0; for (let attempts = 0; attempts < 32;) { const newline = candidate.indexOf('\n', offset); if (newline < 0) break; offset = newline + 1; const tail = candidate.slice(offset); if (!tail.trim()) break; const nextBreak = tail.indexOf('\n'); const firstLine = tail.slice(0, nextBreak < 0 ? undefined : nextBreak); if (firstLine.trim().length < 12) continue; attempts++; const next = longestPrefixOccurrence(tail, archive); const nextMinimum = confidence(tail); if (next.length >= nextMinimum) { match = next; minimum = nextMinimum; break; } } } let history = fallbackHistory; if (match.length >= minimum && minimum > 0) { const archiveOffset = match.end - match.length + 1; history = archive.slice(0, archiveOffset); } return { archive: history + visible, history: logicalHistory(history) }; } function terminalArchive(vt, snap) { return logicalText([...(snap.scrollbackLines || []), ...visibleRows(vt)], snap.cols); } const MAX_HISTORY_STYLES = 50_000; function learnHistoryStyles(host, vt, snap) { if (!host.historyStyles || typeof vt.formatHtml !== 'function') return; const visibleHasStyle = (snap.cells || []).some((cell) => cell.bold || cell.italic || cell.underline || cell.foreground || cell.background); if (!visibleHasStyle && host.historyStyles.size === 0) return; let styled; try { styled = styledSnapshotLines(snap, vt.formatHtml()); } catch { return; } for (const line of [...styled.rows, ...styled.logical]) { if (!line.text || !line.ansi) continue; // Refresh insertion order so frequently repainted transcript rows survive // the bounded cache while old one-off status lines age out. host.historyStyles.delete(line.text); host.historyStyles.set(line.text, line.ansi); } while (host.historyStyles.size > MAX_HISTORY_STYLES) { host.historyStyles.delete(host.historyStyles.keys().next().value); } } function withHistoryStyles(host, lines) { return (lines || []).map((line) => { const ansi = line.ansi || host.historyStyles?.get(line.text || ''); return ansi ? { ...line, ansi } : line; }); } function styledSnapshot(host, vt, snap) { learnHistoryStyles(host, vt, snap); return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) }; } // A Ghostty snapshot restores the rendered grid. Append the interaction modes // observed on the PTY so a newly attached xterm also behaves like the terminal // that saw the TUI start (mouse reporting, bracketed paste, cursor-key mode…). function viewerRestoreAnsi(host, vt, snap) { return snapshotToRestoreAnsi(styledSnapshot(host, vt, snap)) + host.terminalModes.restoreAnsi(); } /** * Commit one captured agent repaint without duplicating its overflow rows. * * The pre-resize scrollback boundary and final repaint are merged once, then * both the server terminal and every viewer are restored from that same state. * Shell sessions never use this path. */ function finishCapturedGrid(host, txn) { if (host.resizeCapture !== txn) return; host.resizeCapture = null; clearCaptureTimers(txn); let snap = null; let replacement = null; let committedArchive = null; if (txn.sawData) { try { snap = txn.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch {} if (snap) { try { learnHistoryStyles(host, txn.vt, snap); const visible = visibleRows(txn.vt); const visibleText = logicalText(visible, txn.cols); const view = repaintArchiveView(txn.archive, visibleText, txn.fallbackHistory); committedArchive = view.archive; replacement = ghostty.createTerminal({ cols: txn.cols, rows: txn.rows, scrollbackLimit: SCROLLBACK_BYTES, }); replacement.feed(snapshotToRestoreAnsi({ ...snap, scrollbackLines: withHistoryStyles(host, view.history), })); } catch (error) { console.error('[runner] resize capture commit', error && error.message); try { replacement?.dispose(); } catch {} replacement = null; snap = null; } } } try { if (!snap) { // The foreground process ignored SIGWINCH. Ordinary reflow is the only // faithful outcome because no repaint exists to replace the old screen. host.vt.resize(txn.cols, txn.rows); host.cols = txn.cols; host.rows = txn.rows; host.repaintArchive = null; notifyGrid(host, false); } else { const previous = host.vt; host.vt = replacement; host.cols = txn.cols; host.rows = txn.rows; // The archive retains the full transcript even when a wide grid exposes // all of it and therefore needs no scrollback. A later narrow repaint can // derive the hidden prefix again instead of losing the oldest rows. host.repaintArchive = committedArchive; // Reset and restore one canonical snapshot. Keeping a browser's old // scrollback while painting only the new viewport makes the two models // diverge after a narrow zoom, even when the server history is correct. notifyGrid(host, true); const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true }); const ansi = viewerRestoreAnsi(host, host.vt, committed); for (const sub of host.subs) sub.onData(ansi); try { previous.dispose(); } catch {} sampleScreen(host); } } finally { try { txn.vt.dispose(); } catch {} } host.historyCheckpoint.schedule(); // A newer controller preference may have arrived while the repaint settled. const next = effectiveGrid(host); if (next.cols !== host.cols || next.rows !== host.rows) scheduleGrid(host); } function armCapturedGrid(host, txn) { if (txn.idleTimer) clearTimeout(txn.idleTimer); txn.idleTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.idleMs); if (txn.idleTimer.unref) txn.idleTimer.unref(); } /** Start or supersede the bounded repaint transaction for an agent TUI. */ function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) { const previous = host.resizeCapture; // A viewer can report its measured geometry before the resumed CLI emits // its first byte. That resize is still part of startup, so claim the durable // seed here rather than leaving it for a later output chunk. const pendingStartup = !seed && !previous ? host.startupHistory : null; if (pendingStartup) { seed = { history: pendingStartup.lines, historyCols: pendingStartup.cols, logical: true, }; } // Resizing during a startup capture supersedes its scratch grid, but must not // downgrade its long idle window to the ordinary resize debounce. Otherwise // a delayed resume repaint is committed later as duplicate terminal output. const startupCapture = !!seed || !!previous?.startupCapture; let archive = seed?.history ? (seed.logical ? `${seed.history.map((line) => line.text || '').join('\n')}\n` : logicalText(seed.history, seed.historyCols || host.cols)) : host.repaintArchive; let fallbackHistory = seed?.history ? archive : null; if (previous) { archive = previous.archive; fallbackHistory = previous.fallbackHistory; host.resizeCapture = null; clearCaptureTimers(previous); try { previous.vt.dispose(); } catch {} } try { const source = host.vt.snapshot({ includeScrollback: true }); if (archive == null) { archive = terminalArchive(host.vt, source); } if (fallbackHistory == null) { fallbackHistory = logicalText(source.scrollbackLines || [], source.cols); } } catch { return false; } let scratch; try { scratch = ghostty.createTerminal({ cols, rows, scrollbackLimit: 4 * 1024 * 1024 }); } catch (error) { console.error('[runner] resize capture setup', error && error.message); try { scratch?.dispose(); } catch {} return false; } const txn = { vt: scratch, cols, rows, archive: archive || '', fallbackHistory: fallbackHistory || '', sawData: false, startupCapture, idleMs: startupCapture ? STARTUP_CAPTURE_IDLE_MS : RESIZE_CAPTURE_IDLE_MS, maxMs: startupCapture ? STARTUP_CAPTURE_MAX_MS : RESIZE_CAPTURE_MAX_MS, idleTimer: null, maxTimer: null, }; host.resizeCapture = txn; if (pendingStartup) host.startupHistory = null; if (resizePty) { try { host.pty.resize(cols, rows); } catch {} } txn.maxTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.maxMs); if (txn.maxTimer.unref) txn.maxTimer.unref(); return true; } /** * Move the session to the size its viewers imply. * * Shells use ordinary reflow: every row is real output. Known agent TUIs on the * primary screen instead use a bounded scratch transaction because their full * SIGWINCH redraw is presentation, not new history. Alternate-screen programs * are already isolated from primary scrollback and use ordinary resize. */ function applyGrid(host) { const { cols, rows } = effectiveGrid(host); if (!host.resizeCapture && cols === host.cols && rows === host.rows) return false; if (host.captureResize) { let alt = false; try { alt = !!host.vt.snapshot().isAltScreen; } catch {} if (!alt && startCapturedGrid(host, cols, rows)) return true; } host.cols = cols; host.rows = rows; try { host.vt.resize(cols, rows); } catch {} // Put the geometry frame on every viewer's ordered WebSocket stream before // SIGWINCH can make the foreground application repaint at the new size. // Otherwise those repaint bytes may be interpreted using the old grid and // leave duplicated or displaced rows in the browser emulator. notifyGrid(host, false); try { host.pty.resize(cols, rows); } catch {} return true; } /** * Apply the controller's preferred grid once requests stop arriving. If a * pending request clamps back to the current size, report that canonical size. */ function scheduleGrid(host) { if (host.gridTimer) clearTimeout(host.gridTimer); host.gridTimer = setTimeout(() => { host.gridTimer = null; if (!applyGrid(host)) notifyGrid(host, false); }, RESIZE_SETTLE_MS); if (host.gridTimer.unref) host.gridTimer.unref(); } function armTraceHydration(host) { if (!host.traceHistoryPage || host.traceHistoryTimer) return; const readyAt = Math.max( host.startedAt + TRACE_HYDRATE_MIN_MS, (host.lastOutputAt || host.startedAt) + TRACE_HYDRATE_IDLE_MS, host.resizeCapture ? Date.now() + TRACE_HYDRATE_IDLE_MS : 0, ); host.traceHistoryTimer = setTimeout(() => { host.traceHistoryTimer = null; if (hosts.get(host.id) !== host || !host.traceHistoryPage) return; if (host.resizeCapture || Date.now() < readyAt) { armTraceHydration(host); return; } let snap; try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return; } const visible = visibleRows(host.vt); const visibleText = logicalText(visible, snap.cols); const currentText = visible.map((line) => line.text || '').join('\n'); const recovered = traceHistoryLines(host.traceHistoryPage, currentText); host.traceHistoryPage = null; learnHistoryStyles(host, host.vt, snap); let replacement = null; try { // The trace supplies missing older turns while the settled startup grid // supplies the welcome and the newest live turn. Merge their overlap // into one archive rather than appending either presentation verbatim. const startup = terminalArchive(host.vt, snap); const archive = mergeRepaintArchive(logicalText(recovered, snap.cols), startup); const view = repaintArchiveView(archive, visibleText); replacement = ghostty.createTerminal({ cols: host.cols, rows: host.rows, scrollbackLimit: SCROLLBACK_BYTES, }); replacement.feed(snapshotToRestoreAnsi({ ...snap, scrollbackLines: withHistoryStyles(host, view.history), })); const previous = host.vt; host.vt = replacement; host.repaintArchive = view.archive; notifyGrid(host, true); const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true }); const ansi = viewerRestoreAnsi(host, host.vt, committed); for (const sub of host.subs) sub.onData(ansi); try { previous.dispose(); } catch {} sampleScreen(host); host.historyCheckpoint.schedule(); } catch (error) { console.error('[runner] trace history restore', error && error.message); try { replacement?.dispose(); } catch {} } }, Math.max(0, readyAt - Date.now())); if (host.traceHistoryTimer.unref) host.traceHistoryTimer.unref(); } async function hydrateTraceHistory(session, host) { try { let page = await readTrace(session, { offset: 0, limit: 500 }); if (page.total > page.turns.length) { page = await readTrace(session, { offset: Math.max(0, page.total - 500), limit: 500 }); } if (hosts.get(host.id) !== host) return; host.traceHistoryPage = page; armTraceHydration(host); } catch { // A new/onboarding session may not have a trace yet; live output remains // authoritative and its first checkpoint will become the durable seed. } } // ---------- 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 Map(); // id -> pending re-pin timer // Every harness's conversation pin is re-checked on this cadence for as long as // the pane is alive, so a mid-session reset (/clear and friends) can't leave the // pin describing a conversation the user has moved on from. const REPIN_MS = 20_000; 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. Codex's // session_meta line carries the full embedded instruction text (~22KB as of // 0.142), so read in chunks until the newline — a fixed small buffer would // truncate the JSON and make every capture silently fail. // The first line of a transcript that records a `cwd`, with its timestamp. // Bounded on lines AND bytes: a file-history-snapshot line can be megabytes. function transcriptHead(p) { // openSync INSIDE the try: on the bucket mount a transcript can rotate away // between the stat that found it and this open, and the only caller runs in a // setTimeout where a throw is unhandled. let fd = null; try { fd = fs.openSync(p, 'r'); const CHUNK = 65536, MAX_BYTES = 512 * 1024, MAX_LINES = 64; let carry = '', pos = 0, lines = 0; while (pos < MAX_BYTES && lines < MAX_LINES) { const b = Buffer.alloc(Math.min(CHUNK, MAX_BYTES - pos)); const n = fs.readSync(fd, b, 0, b.length, pos); if (!n) break; pos += n; carry += b.toString('utf8', 0, n); let nl; while ((nl = carry.indexOf('\n')) >= 0 && lines < MAX_LINES) { const line = carry.slice(0, nl); carry = carry.slice(nl + 1); lines++; if (!line.trim()) continue; let j; try { j = JSON.parse(line); } catch { continue; } if (j && j.cwd) return { cwd: j.cwd, timestamp: j.timestamp || null }; } if (n < b.length) break; // EOF } return null; } catch { return null; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch {} } } } function firstLine(p) { const fd = fs.openSync(p, 'r'); try { const CHUNK = 65536, MAX = 1024 * 1024; let buf = Buffer.alloc(0); for (let pos = 0; pos < MAX; pos += CHUNK) { const b = Buffer.alloc(CHUNK); const n = fs.readSync(fd, b, 0, CHUNK, pos); buf = Buffer.concat([buf, b.subarray(0, n)]); const nl = buf.indexOf(0x0a); if (nl >= 0) return buf.toString('utf8', 0, nl); if (n < CHUNK) break; // EOF } return buf.toString('utf8'); } 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)); const pinned = (list().find((s) => s.id === sessionId) || {}).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; } const mp = (meta && meta.payload) || {}; if (mp.cwd !== workdir) continue; // A sibling's ongoing conversation in the same folder gets fresh writes // (mtime) during our capture window — require the rollout to have been // CREATED after this launch so we never claim someone else's thread. const created = Date.parse(mp.timestamp || meta.timestamp || '') || 0; if (created && created < sinceMs - 15_000) continue; // Skip Codex's internal guardian/subagent rollouts — they share the cwd but // aren't this agent's conversation, so pinning one would break resume and // the Overview digest. if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue; // The watcher re-runs for the life of the pane, so the usual outcome is // "still the same conversation" — don't rewrite sessions.json for that. if (m[1] === pinned) return true; if (pinned) console.warn(`[codex] re-pinning ${sessionId}: ${pinned} -> ${m[1]} (conversation was replaced)`); update(sessionId, { codexSessionId: m[1], codexRollout: c.p }); return true; } return false; } // A pin captured before subagents were filtered out (or one whose rollout was // rotated away) may point at a guardian/missing rollout — clear it so we // re-capture the real conversation on this launch. function pinIsStale(session) { if (!session.codexSessionId) return false; const p = session.codexRollout; if (!p || !fs.existsSync(p)) return true; try { const mp = (JSON.parse(firstLine(p)) || {}).payload || {}; return mp.thread_source === 'subagent' || !!(mp.source && mp.source.subagent); } catch { return false; } } // Same staleness problem as Claude's pin, same remedy: codex starts a fresh // conversation — and a fresh rollout file — when the thread is reset, so a pin // captured once at launch stops describing the live conversation. Keep watching // for as long as the pane is alive and follow the newest rollout this folder // produces. tryCaptureCodexId only writes when the id actually changes. function scheduleCodexCapture(session, workdir) { if (session.codexSessionId && pinIsStale(session)) { session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session; } const prev = codexCapturing.get(session.id); if (prev) clearTimeout(prev); const since = Date.now() - 2000; let warnedShared = false; const tick = () => { if (!isRunning(session.id)) { codexCapturing.delete(session.id); return; } if (folderIsShared(session.id, workdir, 'codex')) { if (!warnedShared) { warnedShared = true; console.warn(`[codex] ${session.id}: folder shared with another live session — not following thread resets here`); } } else { tryCaptureCodexId(session.id, workdir, since); } const t = setTimeout(tick, REPIN_MS); if (t.unref) t.unref(); codexCapturing.set(session.id, t); }; const t0 = setTimeout(tick, 5000); // rollout appears ~instantly if (t0.unref) t0.unref(); codexCapturing.set(session.id, t0); } // opencode has no per-conversation handle we can pass on launch, so we can't // mint an id like Claude's --session-id. Instead, capture the ses_ row opencode // writes to its db and pin it — mirrors the codex approach. The row appears // only once the conversation has content (the user's first message), so retry // on a longer, sparser schedule than codex. // ---------- Claude conversation re-pinning ---------- // We ASK for a conversation id up front (`claude --session-id `), which // normally makes the transcript filename equal session.sessionUuid. But the // launch line ends in `|| exec claude`, and when the first invocation exits // non-zero — a fresh install running its onboarding does exactly that — Claude // starts again and picks an id of its OWN. The pin then matches nothing on disk, // forever: the Overview shows no digest, `--resume` can't find the transcript so // the session silently starts fresh every launch, and sharing can't locate it. // // So verify the pin after launch — and keep verifying it, see the watcher below — // re-pinning to the transcript Claude actually wrote. Observed live on a test Space: // session pinned 4efced14…, transcript on disk cb22b656…. const claudeCapturing = new Map(); // id -> pending re-pin timer function claudeProjectDirs() { const home = process.env.HOME || ''; return [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')] .filter(Boolean).filter((d, i, a) => a.indexOf(d) === i) .map((d) => path.join(d, 'projects')); } // Every transcript, newest first, touched since `sinceMs`. function claudeTranscriptsSince(sinceMs) { const out = []; for (const proj of claudeProjectDirs()) { let dirs = []; try { dirs = fs.readdirSync(proj, { withFileTypes: true }); } catch { continue; } for (const d of dirs) { if (!d.isDirectory()) continue; let files = []; try { files = fs.readdirSync(path.join(proj, d.name)); } catch { continue; } for (const f of files) { if (!f.endsWith('.jsonl')) continue; const p = path.join(proj, d.name, f); try { const st = fs.statSync(p); if (st.mtimeMs >= sinceMs) out.push({ p, m: st.mtimeMs }); } catch {} } } } return out.sort((a, b) => b.m - a.m); } const transcriptExists = (uuid) => !!uuid && claudeProjectDirs().some((proj) => { let dirs = []; try { dirs = fs.readdirSync(proj, { withFileTypes: true }); } catch { return false; } return dirs.some((d) => { if (!d.isDirectory()) return false; try { return fs.readdirSync(path.join(proj, d.name)).some((f) => f.startsWith(uuid)); } catch { return false; } }); }); // A transcript's opening cwd/timestamp never changes once written, and the // filename IS the conversation id, so a path is never reused for a different // conversation. That makes the head safe to remember — which matters because the // watcher rescans every REPIN_MS for the life of every session, and on the Space // these are synchronous reads against a FUSE mount. Without this, every tick // re-read every transcript on disk and would show up as event-loop lag. const headMemo = new Map(); // transcript path -> head function transcriptHeadCached(p) { const hit = headMemo.get(p); if (hit) return hit; const head = transcriptHead(p); // Only remember a definite answer: null can just mean the file has no cwd line // yet (still being written), and caching that would poison it for the process. if (head) { if (headMemo.size > 500) headMemo.clear(); headMemo.set(p, head); } return head; } // The newest conversation written in `workdir` that no other session has pinned, // as { uuid, start }. `start` is the conversation's OWN first timestamp, which is // what distinguishes a /clear-spawned successor from the thread it replaced — // both keep receiving mtime updates, only the successor is newly born. // Exported for server/test/repin.test.mjs. export function claudeCandidate(sessionId, workdir, sinceMs) { const claimed = new Set(list().filter((s) => s.id !== sessionId && s.sessionUuid).map((s) => s.sessionUuid)); let best = null; for (const c of claudeTranscriptsSince(sinceMs)) { const uuid = path.basename(c.p).replace(/\.jsonl$/, ''); if (claimed.has(uuid)) continue; // The cwd is NOT on the first line: a transcript opens with metadata lines // (mode, permission-mode, file-history-snapshot, ai-title, worktree-state) // that have no cwd, and only the conversation lines carry one — line 4 or 5 // in every real transcript measured. Reading line 1 made this check always // fail, so the re-pin could never actually claim anything. const head = transcriptHeadCached(c.p); // Only claim a conversation started in THIS session's folder. if (!head || head.cwd !== workdir) continue; const start = Date.parse(head.timestamp || '') || 0; // Born in this launch window, or it's an older thread that merely received // writes — someone else's, or our own pre-relaunch one. if (start && start < sinceMs - 15_000) continue; if (!best || start > best.start) best = { uuid, start }; } return best; } // Another LIVE session of the same harness on the same folder makes a new // conversation there unattributable: we cannot tell whose /clear produced it. // Refuse to guess, the way share.js does when a folder has rivals. function folderIsShared(sessionId, workdir, cli) { return list().some((s) => s.id !== sessionId && s.cli === cli && path.join(WORKSPACES_DIR, s.path ?? s.id) === workdir && isRunning(s.id)); } // Re-pinning is NOT a one-shot check, because the pin can go stale mid-session. // `/clear` ends the conversation and starts a new one with an id of its own, // exactly like the onboarding fallback above: the transcript we pinned stops // growing and Claude writes a NEW file. Nothing told the manager, so the pin // aged out silently — the Overview digest, the trace panel and sharing all kept // reading the pre-/clear thread, and the next launch ran `--resume `, // restoring the conversation as it was BEFORE the /clear and discarding // everything since. That is why a Space restart brought back the old session. // // So the watcher keeps looking for as long as the pane is alive and follows the // session forward onto whatever conversation Claude is actually writing. One // mechanism now covers both failure modes: the launch-time fallback (pin never // honoured) and a /clear at any later point. // ---------- breadcrumbs: the pane tells us, so we don't have to guess ---------- // The transcript scan above cannot attribute a new conversation when several // live claude panes share a folder — folderIsShared refuses, and a /clear in // such a folder was never followed. But the pane itself KNOWS: a SessionStart // hook (installed into settings.json below, script at scripts/am-repin-hook.sh) // runs inside the pane's process tree, where $AM_ID names the pane and the // payload carries the new conversation's id. It drops that as a breadcrumb // here; the watcher consumes it and re-pins with no guessing at all. The scan // stays as the fallback for panes without a breadcrumb (hook newly installed, // crumb lost) — and for codex/opencode, which have no hook mechanism. const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin'; // Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb // can never flip a pin backwards after a later, scan-based re-pin. function takeClaudeBreadcrumb(sessionId) { const p = path.join(REPIN_DIR, `${sessionId}.json`); let raw; try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; } try { fs.unlinkSync(p); } catch {} try { return JSON.parse(raw); } catch { return null; } } // The pane's root process (what tmux spawned). After `exec claude` this IS // claude; in the `claude --session-id … || exec claude` branch claude is a // child of it. Either way the hook's $CLAUDE_PID must descend from it. function paneRootPid(sessionId) { try { const out = execFileSync('tmux', ['list-panes', '-t', tmuxName(sessionId), '-F', '#{pane_pid}'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV }); const pid = parseInt(out.trim().split('\n')[0], 10); return Number.isInteger(pid) && pid > 1 ? pid : null; } catch { return null; } } // Walk /proc ppid links. comm in /proc//stat may contain spaces and // parens, so split after the LAST ') '. function pidHasAncestor(pid, ancestor, readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) { for (let p = pid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) { if (p === ancestor) return true; let stat; try { stat = readStat(p); } catch { return false; } const tail = stat.slice(stat.lastIndexOf(') ') + 2).split(' '); p = parseInt(tail[1], 10); // state ppid … } return false; } // Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs. // `facts` carries everything environmental: { workdir, pinned, claimed (Set of // uuids other sessions pin), pidTrusted (bool: claudePid descends from the // pane) }. Returns { repin: uuid } or { repin: null, why }. export function breadcrumbVerdict(crumb, sessionId, facts) { if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' }; if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' }; const uuid = crumb.payload?.session_id; if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid || '')) return { repin: null, why: 'no session_id' }; // A crumb written before a pane was moved to another folder must not follow // it there — same folder-scoping rule the transcript scan applies. if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' }; // Nested `claude -p` runs inherit $AM_ID and fire SessionStart too (verified // on 2.1.220 — the docs say -p skips hooks; it does not). The hook filters // on CLAUDE_CODE_ENTRYPOINT, and this is the backstop: only a process that // descends from the pane speaks for the pane. if (!facts.pidTrusted) return { repin: null, why: 'pid not in pane' }; if (facts.claimed?.has(uuid)) return { repin: null, why: 'claimed by another session' }; if (uuid === facts.pinned) return { repin: null, why: 'already pinned' }; return { repin: uuid }; } // Register the SessionStart hook in $CLAUDE_CONFIG_DIR/settings.json. Merge, // never replace: the file also holds permissions/model/theme. Idempotent — a // second boot finds the entry and writes nothing. A file that exists but does // not parse is left alone (clobbering the user's settings to install a hook // would be a terrible trade), and every failure is non-fatal: without the // hook the watcher simply keeps today's behaviour. export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh') { const dir = process.env.CLAUDE_CONFIG_DIR; if (!dir) return false; const file = path.join(dir, 'settings.json'); let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') { console.warn(`[claude] not installing repin hook: ${file} unreadable (${e.message})`); return false; } } if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) { console.warn(`[claude] not installing repin hook: ${file} is not an object`); return false; } const entries = Array.isArray(cfg.hooks?.SessionStart) ? cfg.hooks.SessionStart : []; const present = entries.some((m) => (m?.hooks || []).some((h) => String(h?.command || '').includes('am-repin-hook.sh'))); if (present) return true; // No matcher: fire for every source. `startup` replaces the "--session-id // not honoured" heuristic with a fact, `resume` is a proven no-op (same id), // and `clear` is the case this exists for. Filtering happens server-side. cfg.hooks = cfg.hooks || {}; cfg.hooks.SessionStart = [...entries, { hooks: [{ type: 'command', command: hookCmd, timeout: 5 }] }]; try { const tmp = `${file}.am-tmp`; fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n'); fs.renameSync(tmp, file); console.warn(`[claude] repin hook installed in ${file}`); return true; } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; } } function scheduleClaudeCapture(session, workdir) { // A relaunch restarts the watch with a fresh window, so `since` can't drift // older and start admitting pre-relaunch threads as candidates. const prev = claudeCapturing.get(session.id); if (prev) clearTimeout(prev); const since = Date.now() - 2000; let warnedShared = false; const tick = () => { if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; } const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid; // Breadcrumb first: the pane's own SessionStart hook told us which // conversation it is on, so no guessing — and no shared-folder refusal — // is needed. Consumed on read; a rejected crumb falls through to the scan. const crumb = takeClaudeBreadcrumb(session.id); if (crumb) { const root = paneRootPid(session.id); const verdict = breadcrumbVerdict(crumb, session.id, { workdir, pinned, claimed: new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid)), pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root), }); if (verdict.repin) { console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`); update(session.id, { sessionUuid: verdict.repin }); const t = setTimeout(tick, REPIN_MS); if (t.unref) t.unref(); claudeCapturing.set(session.id, t); return; } if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`); } if (folderIsShared(session.id, workdir, 'claude')) { if (!warnedShared) { warnedShared = true; console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`); } } else { const hit = claudeCandidate(session.id, workdir, since); if (hit && hit.uuid !== pinned) { const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured'; console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${hit.uuid} (${why})`); update(session.id, { sessionUuid: hit.uuid }); } } const t = setTimeout(tick, REPIN_MS); if (t.unref) t.unref(); claudeCapturing.set(session.id, t); }; const t0 = setTimeout(tick, 5000); if (t0.unref) t0.unref(); claudeCapturing.set(session.id, t0); } const opencodeCapturing = new Map(); // id -> pending re-pin timer // As with Claude and codex, the pin has to keep up with the live conversation: // starting a new opencode conversation writes a new `session` row, and a pin // captured once at launch would keep pointing at the abandoned one. function scheduleOpencodeCapture(session, workdir) { const prev = opencodeCapturing.get(session.id); if (prev) clearTimeout(prev); const since = Date.now() - 2000; let warnedShared = false; const tick = () => { if (!isRunning(session.id)) { opencodeCapturing.delete(session.id); return; } if (folderIsShared(session.id, workdir, 'opencode')) { if (!warnedShared) { warnedShared = true; console.warn(`[opencode] ${session.id}: folder shared with another live session — not following new conversations here`); } } else { const claimed = new Set(list().filter((s) => s.id !== session.id && s.opencodeSessionId).map((s) => s.opencodeSessionId)); const pinned = (list().find((s) => s.id === session.id) || session).opencodeSessionId; const hit = captureOpencodeSession(workdir, since, claimed); if (hit && hit.id !== pinned) { if (pinned) console.warn(`[opencode] re-pinning ${session.id}: ${pinned} -> ${hit.id} (conversation was replaced)`); update(session.id, { opencodeSessionId: hit.id }); } } const t = setTimeout(tick, REPIN_MS); if (t.unref) t.unref(); opencodeCapturing.set(session.id, t); }; const t0 = setTimeout(tick, 3000); if (t0.unref) t0.unref(); opencodeCapturing.set(session.id, t0); } // Single-quote a string for embedding in an `sh -lc` command line. const shq = (t) => `'${String(t).replace(/'/g, `'\\''`)}'`; // Conversation ids reach the launch line unquoted, and this one comes back out // of a database rather than from us (Claude's uuid we mint ourselves). Shape-check // it so nothing but an opencode session id can ever be interpolated. const SES_ID = /^ses_[A-Za-z0-9_-]+$/; export function commandFor(session) { const cli = cliById(session.cli) || cliById('shell'); if (cli.id === 'shell') return bashLaunch; // Quickstart: a prompt queued at creation rides the FIRST launch command // (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') — the CLI // starts already working on it, no typing race against a booting TUI. const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : ''; // 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}${q0 ? ` ${q0}` : ''} || 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`; } // opencode (seen on 1.17.13): at startup it creates a DIRECTORY named // opencode.json at its own config path, then every message dies re-reading // it (EISDIR → the model silently never responds; the next launch exits // instantly). Clear a directory-shaped entry AND occupy the path with a real // config file — copied from opencode.jsonc when one exists — so the bug // can't re-trigger mid-session. An existing config FILE is left untouched. if (cli.id === 'opencode') { const guard = 'G="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"; ' + 'mkdir -p "$(dirname "$G")"; [ -d "$G" ] && rm -rf "$G"; ' + '[ -e "$G" ] || { [ -f "${G}c" ] && cp "${G}c" "$G" || echo "{}" > "$G"; }; '; // Resume the conversation this session is PINNED to, by id. `--continue` // (below) takes the most-recent conversation in the CWD instead, which // ignores the pin entirely: after a reset a restart came back on whichever // conversation the folder touched last, and the pin the watcher maintains — // the one the digest, the trace panel and sharing all read — described a // different thread than the pane was showing. Existence-checked in JS rather // than in the shell, because the db is one file for all conversations (no // per-conversation path to test) and a missing row is fatal, not a fallback. const pin = session.opencodeSessionId; if (session.everStarted && pin && SES_ID.test(pin) && opencodeSessionExists(pin)) { return `${guard}exec opencode --session ${pin}`; } // Unpinned (or the conversation is gone): `--continue` resumes the // most-recent conversation in the CWD, so two opencode agents sharing a // folder would resume onto the SAME one (cross-talk) — same hazard as codex // `resume --last`. Only continue when this session holds its folder alone; // otherwise start fresh, and capture pins the new conversation right away // (see scheduleOpencodeCapture). const folder = session.path ?? session.id; const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder); const base = session.everStarted && cli.cont && !shared ? `${cli.cont} || exec ${cli.run}` : `exec ${q0 && cli.withPrompt ? cli.withPrompt(q0) : cli.run}`; return `${guard}${base}`; } // codex without a pinned conversation: `resume --last` scopes to the cwd, // so in a SHARED folder it can resume a SIBLING's conversation (cross-talk). // Only resume when this session has the folder to itself; otherwise start // fresh — capture pins the new conversation right away. if (cli.id === 'codex' && session.everStarted) { const folder = session.path ?? session.id; const shared = list().some((o) => o.id !== session.id && o.cli === 'codex' && (o.path ?? o.id) === folder); if (shared) return `exec ${cli.run}`; } // Other agents: resume when one likely exists, else a fresh launch. `exec` so // the agent is the PTY's foreground process; when it exits the session ends — // a clear "done" signal — and the fallback preserves that. if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`; if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`; return `exec ${cli.run}`; } /** * Start the session's PTY and its grid, without any browser attached. Returns * true if it had to spawn. This is now the ONLY way a session starts, so the * Overview reply box waking a stopped agent and a browser opening a pane take * exactly the same path. */ export function ensureRunning(session, cols = 120, rows = 34) { // Nothing to start: a remote agent starts itself, on its own machine. Both // callers guard this too; keep the refusal here so no future one can spawn // a PTY for a pane that can never use it. if (isRemote(session.cli)) throw new Error('a remote agent runs on its own machine — nothing to start here'); const existing = hosts.get(session.id); if (existing) return false; if (!ghostty) throw new Error(`libghostty-vt unavailable: ${ghosttyError}`); // 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); const captureResize = cliById(session.cli)?.resizeMode === 'repaint'; const env = { ...TERM_ENV, AM_SESSION: folder, AM_NAME: session.name, AM_ID: session.id, AM_USER, AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this }; const term = pty.spawn('bash', ['-lc', full], { name: 'xterm-256color', cols, rows, cwd: workdir, env, }); const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_BYTES }); const loadedHistory = loadTerminalHistory(HISTORY_DIR, session.id); // Older agent checkpoints may contain startup repaint frames or a turn that // trace hydration raced with Claude's own replay. Rebuild those once from // the trace. Shell history never used that path and remains safe to restore. const persistedHistory = captureResize && loadedHistory?.version < TERMINAL_HISTORY_VERSION ? null : loadedHistory; if (persistedHistory) { try { vt.feed(snapshotToRestoreAnsi({ cols, rows, cursorRow: 0, cursorCol: 0, isAltScreen: false, cells: [], scrollbackLines: persistedHistory.lines, })); } catch (error) { console.error('[runner] history restore', error && error.message); } } const host = { id: session.id, pty: term, vt, cols, rows, captureResize, resizeCapture: null, repaintArchive: null, historyStyles: new Map((persistedHistory?.lines || []) .filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])), terminalModes: createTerminalModeTracker(), startupHistory: captureResize ? persistedHistory : null, historyCheckpoint: null, traceHistoryPage: null, traceHistoryTimer: null, subs: new Set(), controller: null, gridTimer: null, startedAt: Date.now(), lastOutputAt: Date.now(), screenChangedAt: Date.now(), bells: 0, }; host.historyCheckpoint = createTerminalHistoryCheckpoint({ directory: HISTORY_DIR, id: host.id, delayMs: HISTORY_SAVE_MS, snapshot: () => { const snap = host.vt.snapshot({ includeScrollback: true }); learnHistoryStyles(host, host.vt, snap); if (!captureResize) { return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) }; } const archive = host.repaintArchive ?? terminalArchive(host.vt, snap); return { cols: host.cols, scrollbackLines: withHistoryStyles(host, logicalHistory(archive)), }; }, blocked: () => !!host.resizeCapture, persistedBody: persistedHistory?.body || null, }); term.onData((chunk) => { host.lastOutputAt = Date.now(); host.terminalModes.feed(chunk); if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; } if (host.traceHistoryPage) armTraceHydration(host); let txn = host.resizeCapture; if (!txn && host.startupHistory) { startCapturedGrid(host, host.cols, host.rows, null, false); txn = host.resizeCapture; } if (txn) { try { txn.vt.feed(chunk); } catch (e) { console.error('[runner] resize capture', e && e.message); } txn.sawData = true; for (let i = 0; i < chunk.length; i++) { if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); } } armCapturedGrid(host, txn); return; } // This is real output outside a resize transaction. It may have advanced // scrollback, so the next transaction takes a fresh canonical boundary. host.repaintArchive = null; try { host.vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); } host.historyCheckpoint.schedule(); // State detection rides the feed path: the grid is already current, so there // is nothing to poll and no subprocess to spawn. sampleScreen(host); for (let i = 0; i < chunk.length; i++) { if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); } } for (const sub of host.subs) sub.onData(chunk); }); term.onExit(() => { hosts.delete(session.id); if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; } if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; } if (host.resizeCapture) { clearCaptureTimers(host.resizeCapture); try { host.resizeCapture.vt.dispose(); } catch {} host.resizeCapture = null; } host.historyCheckpoint.flush(); try { host.vt.dispose(); } catch {} for (const sub of host.subs) sub.onExit(); host.subs.clear(); }); hosts.set(session.id, host); if (!persistedHistory && captureResize) hydrateTraceHistory(session, host); if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined }); if (session.cli === 'codex') scheduleCodexCapture(session, workdir); if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir); if (session.cli === 'claude') scheduleClaudeCapture(session, workdir); return true; } /** * Subscribe a viewer to a session, starting it if needed. * * Unlike the tmux version this does NOT spawn anything per viewer, and * `handle.kill()` only unsubscribes — closing a tab must never stop an agent. * `handle.restore()` returns a canonical snapshot: Ghostty's complete plain-text * history followed by a styled visible-screen repaint. It never replays an * arbitrary suffix of old PTY bytes at a new geometry. */ export function attach(session, cols, rows) { ensureRunning(session, cols, rows); const host = hosts.get(session.id); if (!host) throw new Error('session failed to start'); const sub = { onData: () => {}, onExit: () => {}, onGrid: () => {}, want: preferredGrid(cols, rows, host), }; host.subs.add(sub); if (!host.controller) host.controller = sub; // Existing viewers need to know that the session is now shared. The new // viewer receives the same role/count in its restore frame below. notifyGrid(host, false); if (host.controller === sub && (sub.want.cols !== host.cols || sub.want.rows !== host.rows)) scheduleGrid(host); return { onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; }, onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; }, onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; }, // Input and terminal-query responses are accepted from one emulator only. write: (d) => { if (host.controller !== sub) return; try { host.pty.write(d); } catch {} }, // Every viewer remembers what it can display, but only the current // controller's request changes the PTY. resize: (c, r) => { if (!Number.isFinite(c) || !Number.isFinite(r)) return; const want = preferredGrid(c, r, host); const had = sub.want; sub.want = want; if (had && had.cols === want.cols && had.rows === want.rows) return; if (host.controller === sub) scheduleGrid(host); }, claim: () => { if (!host.subs.has(sub) || host.controller === sub) return; host.controller = sub; notifyGrid(host, false); scheduleGrid(host); }, restore: () => { let snap; try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; } return { ansi: viewerRestoreAnsi(host, host.vt, snap), cols: snap.cols, rows: snap.rows, viewers: host.subs.size, controller: host.controller === sub, }; }, // Detach this viewer only. The session, its grid and its scrollback stay. kill: () => { const controlled = host.controller === sub; host.subs.delete(sub); if (controlled) host.controller = host.subs.values().next().value || null; notifyGrid(host, false); if (controlled && host.controller) scheduleGrid(host); }, }; } /** Type a line into the session's terminal (works with no browser attached). */ export async function sendInput(id, text) { const host = hosts.get(id); if (!host) throw new Error('session is not running'); // Multi-line prompts go in as a bracketed paste so the CLI's composer treats // the inner newlines as soft line breaks instead of submitting early. const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text; // The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input // bursts as a paste, and a CR inside the burst becomes a newline in the // composer instead of a submit. A short gap breaks the burst. host.pty.write(payload); await new Promise((r) => setTimeout(r, 300)); host.pty.write('\r'); } /** * The session's rendered screen plus `lines` of scrollback above it — what a * human would see in the pane. Used by the agent API so one agent can watch * another's progress instead of spending a turn asking. * * Reads the grid we already hold, so it costs no subprocess. snapshot() returns * history as plain text, which is exactly what the tmux `capture-pane -p` this * replaced produced, so callers see the same shape. */ export function capturePane(id, lines = 80) { const host = hosts.get(id); if (!host) return null; // stopped const n = Math.max(0, Math.min(2000, lines)); let snap; try { snap = host.vt.snapshot({ includeScrollback: n > 0 }); } catch { return null; } const history = (snap.scrollbackLines || []).slice(-n).map((l) => l.text); const visible = (snap.visibleLines || []).map((l) => l.text); // Trailing blank rows are padding, not content: a short screen should not // arrive as 50 lines of nothing. return [...history, ...visible].join('\n').replace(/\s+$/, ''); } /** Stop a session entirely (kills the process; viewers get an exit close code). */ export function stop(id) { const host = hosts.get(id); if (!host) return; try { host.pty.kill(); } catch {} } /** * Kill every session. Without tmux nothing outlives this process, so a clean * shutdown should not leave orphaned PTYs behind holding the workspace. */ export function stopAll() { for (const host of hosts.values()) { try { host.pty.kill(); } catch {} } }