Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
7.3 kB · 195 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196import { spawn as spawnPty } from "bun-pty";import { hostname } from "os";import { randomUUID } from "crypto";import type { Session } from "../types";import * as sw from "./silverwood";
const QUIET = !!process.env.OPENUI_QUIET;const log = QUIET ? () => {} : console.log.bind(console);
// Resolve the silverwood binary to an absolute path once, so the PTY can exec it// without a PATH in the minimal seed env below. A bare name is searched for on// PATH now; if that fails we fall back to the name and let the spawn surface it.const SILVERWOOD_EXE = sw.SILVERWOOD_BIN.includes("/") ? sw.SILVERWOOD_BIN : Bun.which(sw.SILVERWOOD_BIN) ?? sw.SILVERWOOD_BIN;
// This papyrus instance's advisory-lock holder token, fixed for the server's// lifetime. The random suffix means a restarted papyrus (which may reuse a pid)// never mistakes a lock left by a previous run for its own — it would force-steal// deliberately instead.export const HOLDER = `papyrus:${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
const MAX_BUFFER_SIZE = 1000;
// The in-memory live-PTY registry, keyed by the claude session id. An entry exists// iff a PTY is live. Purely ephemeral — PTYs, WebSocket clients, and scrollback// live here and vanish on restart. All durable state is in silverwood; nothing// here is cached from it.export const sessions = new Map<string, Session>();
// Release the advisory lock this instance holds for a session, if any// (best-effort; a crash skips it and the lock is recovered via a force-steal).// The registry key IS the silverwood session id.function releaseLock(sessionKey: string, session: Session): void { if (session.holdsLock) { session.holdsLock = false; sw.sessionUnlock( session.workstreamId, session.durableSessionId ?? sessionKey, HOLDER, ).catch((e) => log(`\x1b[38;5;141m[lock]\x1b[0m release ${sessionKey}: ${e.message}`), ); }}
// The minimal, explicit environment handed to `silverwood spawn`. NOT papyrus's// inherited devshell env (`{ ...process.env }`, which is polluted with// IN_NIX_SHELL/DEVENV_*/an augmented PATH): silverwood scrubs and rebuilds the// real login env from this seed before it execs the agent, so none of papyrus's// env reaches claude. HOME/USER/SHELL seed that reconstruction; SSH_AUTH_SOCK is// forwarded into it; SILVERWOOD_FOREST_PATH lets `spawn from-id` resolve the same// forest papyrus's other silverwood calls do; CLAUDE_CONFIG_DIR lets it find Claude's// transcripts to decide first-run vs resume (all consumed by silverwood, then scrubbed).function seedEnv(): Record<string, string> { const env: Record<string, string> = { TERM: "xterm-256color" }; for (const k of [ "HOME", "USER", "LOGNAME", "SHELL", "SSH_AUTH_SOCK", "SILVERWOOD_FOREST_PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", ]) { const v = process.env[k]; if (v) env[k] = v; } return env;}
/// Spawn a terminal for an existing session: a PTY running `silverwood spawn from-id <sid>/// --workstream <ws>` in the checkout dir, registered under `sessionKey`. The durable/// session must already exist in silverwood (papyrus records it before spawning) — silverwood/// reads its kind and runs it (deciding first-run vs resume for a claude kind from whether a/// transcript exists), scrubs the environment, and `exec`s it, so this PTY tracks the shell's/// lifetime directly: when it exits the PTY exits and we tear down (releasing the lock, dropping/// the entry). `sessionKey` is the silverwood session id; `kind` is recorded on the/// registry entry for reference but does not shape this resume command.export function spawnTerminal(params: { sessionKey: string; workstreamId: string; cwd: string; kind?: string;}): Session { const { sessionKey, workstreamId, cwd, kind = "claude-code" } = params;
const args = ["spawn", "from-id", sessionKey, "--workstream", workstreamId];
return spawnSilverwoodPty({ sessionKey, workstreamId, cwd, kind, args });}
/// Start Codex before a durable session id exists. The temporary `sessionKey` is/// passed as the initial record name, giving reconciliation an unambiguous marker/// for the SessionStart hook's eventual durable record.export function spawnFreshCodexTerminal(params: { sessionKey: string; workstreamId: string; cwd: string;}): Session { const { sessionKey, workstreamId, cwd } = params; return spawnSilverwoodPty({ sessionKey, workstreamId, cwd, kind: "codex", args: ["session", "create", "codex", workstreamId, "--name", sessionKey], });}
function spawnSilverwoodPty(params: { sessionKey: string; workstreamId: string; cwd: string; kind: string; args: string[];}): Session { const { sessionKey, workstreamId, cwd, kind, args } = params; const ptyProcess = spawnPty(SILVERWOOD_EXE, args, { name: "xterm-256color", cwd, env: seedEnv(), rows: 30, cols: 120, });
const session: Session = { pty: ptyProcess, workstreamId, cwd, clients: new Set(), outputBuffer: [], kind, createdAt: new Date().toISOString(), }; sessions.set(sessionKey, session);
ptyProcess.onData((data: string) => { session.outputBuffer.push(data); if (session.outputBuffer.length > MAX_BUFFER_SIZE) session.outputBuffer.shift(); for (const client of session.clients) { if (client.readyState === 1) { client.send(JSON.stringify({ type: "output", data })); } } });
ptyProcess.onExit(() => { // Only tear down if this exact entry is still the live one for the key. A claude // resume against a missing conversation no longer needs special handling: silverwood // decides first-run vs resume from the transcript, so a never-prompted session starts // fresh instead of exiting with an error. if (sessions.get(sessionKey) === session) { releaseLock(sessionKey, session); session.clients.clear(); sessions.delete(sessionKey); log(`\x1b[38;5;141m[terminal]\x1b[0m exited ${sessionKey}`); } });
log(`\x1b[38;5;141m[terminal]\x1b[0m spawned ${sessionKey} in ${cwd}`); return session;}
/// Resolve a runtime entry by its stable live key or its eventual Codex durable id.export function resolveRuntime(id: string): [string, Session] | undefined { const s = sessions.get(id); if (s) return [id, s]; for (const [key, session] of sessions) { if (session.durableSessionId === id) return [key, session]; } return undefined;}
/// Tear down a session's terminal + registry entry, releasing its lock. Does NOT/// touch silverwood's durable session record.export function killTerminal(sessionKey: string): boolean { const session = sessions.get(sessionKey); if (!session) return false; releaseLock(sessionKey, session); session.pty.kill(); session.clients.clear(); sessions.delete(sessionKey); log(`\x1b[38;5;141m[terminal]\x1b[0m killed ${sessionKey}`); return true;}
/// Kill runtime entries whose workstream no longer exists (archived/deleted/// elsewhere), releasing their locks — keeps the registry a projection of the forest.export function pruneWorkstreams(aliveWsIds: Set<string>): void { for (const [key, s] of [...sessions]) { if (!aliveWsIds.has(s.workstreamId)) killTerminal(key); }}