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