Leandro von Werra
Restore terminal interaction modes on reattach (#33)
1bb8739 unverified
Raw
History Blame
69.1 kB
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-<ts>-<id>.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 <uuid>`), 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 <old uuid>`,
// 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/<pid>/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 {}
}
}