aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
797 lines • 35.7 kB
JavaScript
/**
* WebSocket PTY Bridge
*
* Bridges browser WebSocket connections to PTY sessions. Three modes:
*
* Phase 1 (local exec): spawns commands using node-pty.
* Phase 2a (sandbox WS): bridges to agentic-sandbox management WebSocket.
* Connects to the sandbox's management WS server, subscribes to an agent,
* starts an interactive shell, and multicasts PTY output to all browser clients.
* Leverages the sandbox's tokio broadcast channel for zero-copy multicast.
* Phase 2b (sandbox REST, deprecated): polls /api/v1/tasks log endpoint.
*
* Browser ↔ serve protocol:
* Client → Server: { type: 'data', payload: string } — stdin to PTY
* Client → Server: { type: 'resize', cols: number, rows: number }
* Client → Server: { type: 'close' } — request graceful shutdown
* Server → Client: { type: 'data', payload: string } — stdout/stderr from PTY
* Server → Client: { type: 'exit', code: number } — PTY process exited
* Server → Client: { type: 'error', message: string } — error notification
*
* Sandbox management WS protocol (agentic-sandbox):
* → { type: 'subscribe', agent_id }
* → { type: 'start_shell', agent_id, cols, rows }
* → { type: 'list_sessions', agent_id } — resolves session_name (#901)
* → { type: 'send_input', agent_id, command_id, data }
* → { type: 'pty_resize', agent_id, command_id, cols, rows }
* → { type: 'kill_session', agent_id, session_name } — must use session_name, not command_id
* ← { type: 'shell_started', agent_id, command_id } — idempotent: same cmd_id on reconnect (#903)
* ← { type: 'session_list', agent_id, sessions[] } — provides session_name for kill (#901)
* ← { type: 'output', agent_id, command_id, stream, data, ts, seq? }
* ← { type: 'session_killed' | 'session_detached', agent_id, exit_code? }
*
* Replay-on-attach (#1144). After `shell_started`, AIWG sends:
* → { type: 'join_session', agent_id, command_id, replay_from }
* and the sandbox emits buffered output frames carrying `seq`. AIWG prepends
* `\x1bc` (full terminal reset) ahead of the first replay frame so xterm.js
* agrees with tmux on the starting cursor/screen state, eliminating the
* first-frame alignment glitch. On unexpected disconnect, the bridge resumes
* from the last observed seq instead of replaying from zero.
*
* Capability negotiation (operator review on #1144, 2026-05-07): the original
* implementation gated this strictly on the sandbox advertising `join_session`
* + `replay_buffer` in a server-hello banner (agentic-sandbox#190). That
* banner doesn't ship today, so the gate kept replay disabled even on
* sandboxes that fully support it. The bridge now uses an opportunistic
* probe: if the banner explicitly signals support, replay is enabled; if the
* banner is absent, the bridge sends `join_session` anyway and treats an
* `error` response with an "unknown" / "not supported" / "session not found"
* message as the negative signal. Either path is logged so an operator can
* tell from `aiwg serve` output which mode the bridge is running in.
*
* @issue #712
* @issue #1144 — replay session history when AIWG attaches to running session
* @see #657 — agentic-sandbox PTY transport
* @see #711 — HTTP server scaffold
*/
// ============================================================
// Constants
// ============================================================
const OUTPUT_BUFFER_MAX = 64 * 1024; // 64 KB replay buffer per session
const SESSION_TTL_MS = 30_000; // 30 s before orphaned session is cleaned up
// ============================================================
// Session Registry
// ============================================================
export class PtySessionRegistry {
sessions = new Map();
cleanupTimer = null;
constructor() {
// Periodically clean up orphaned sessions
this.cleanupTimer = setInterval(() => this.evictExpired(), SESSION_TTL_MS);
if (this.cleanupTimer.unref)
this.cleanupTimer.unref();
}
get(id) {
return this.sessions.get(id);
}
create(id) {
const session = {
id,
clients: new Map(),
outputBuffer: '',
pty: null,
lastDisconnect: 0,
exited: false,
};
this.sessions.set(id, session);
return session;
}
delete(id) {
const session = this.sessions.get(id);
if (session?.pty) {
try {
session.pty.kill();
}
catch { /* ignore */ }
}
this.sessions.delete(id);
}
addClient(sessionId, clientId, ws) {
const session = this.sessions.get(sessionId);
if (session)
session.clients.set(clientId, ws);
}
removeClient(sessionId, clientId) {
const session = this.sessions.get(sessionId);
if (!session)
return;
session.clients.delete(clientId);
if (session.clients.size === 0) {
session.lastDisconnect = Date.now();
}
}
appendOutput(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session)
return;
session.outputBuffer += data;
if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
}
}
broadcast(sessionId, msg) {
const session = this.sessions.get(sessionId);
if (!session)
return;
const text = JSON.stringify(msg);
for (const ws of session.clients.values()) {
if (ws.readyState === 1 /* OPEN */) {
try {
ws.send(text);
}
catch { /* ignore closed sockets */ }
}
}
}
evictExpired() {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [id, session] of this.sessions) {
const orphaned = session.clients.size === 0 && session.lastDisconnect < cutoff;
if (orphaned || session.exited) {
this.delete(id);
}
}
}
shutdown() {
if (this.cleanupTimer)
clearInterval(this.cleanupTimer);
for (const id of this.sessions.keys()) {
this.delete(id);
}
}
}
// Singleton registry shared across WebSocket connections
export const registry = new PtySessionRegistry();
// ============================================================
// PTY Spawn
// ============================================================
/**
* Spawn a PTY process for the given session.
*
* Priority order:
* 1. Explicit wsEndpoint opt → sandbox management WebSocket bridge
* 2. Auto-detect: first connected sandbox in registry → sandbox WS bridge
* 3. AIWG_SANDBOX_ENDPOINT env var → legacy REST polling (deprecated)
* 4. Fallback → local node-pty
*
* @issue #657 — agentic-sandbox PTY transport
*/
export async function spawnPty(session, command, args, opts = {}) {
// Phase 2a: explicit management WS endpoint
if (opts.wsEndpoint) {
const agentId = opts.agentId || process.env.AIWG_SANDBOX_AGENT_ID || 'agent-01';
// Look up advertised capabilities for this endpoint so spawnSandboxWsPty
// can capability-gate the replay-on-attach handshake (#1144).
let capabilities;
try {
const { sandboxRegistry } = await import('./sandbox-registry.js');
const match = sandboxRegistry.list().find((s) => s.wsEndpoint === opts.wsEndpoint);
capabilities = match?.wsCapabilities;
}
catch { /* registry not available — proceed without capability gating */ }
await spawnSandboxWsPty(session, agentId, opts.wsEndpoint, { ...opts, capabilities });
return;
}
// Phase 2b: auto-detect — use first connected sandbox from registry
try {
const { sandboxRegistry } = await import('./sandbox-registry.js');
const sandboxes = sandboxRegistry.list();
const connected = sandboxes.find((s) => s.connected && s.wsEndpoint);
if (connected) {
// #1146: refuse silent fallback when multiple ready agents exist and the
// caller has not chosen one. Single-agent case still auto-selects.
const explicit = opts.agentId || process.env.AIWG_SANDBOX_AGENT_ID;
const ready = connected.agents.filter((a) => a.status === 'ready');
if (!explicit && ready.length > 1) {
const ids = ready.map((a) => a.agentId).join(', ');
throw new Error(`Sandbox '${connected.name}' has ${ready.length} ready agents (${ids}); ` +
`pass ?agent=<agentId> on /ws/pty/:sessionId or set AIWG_SANDBOX_AGENT_ID.`);
}
const agentId = explicit
|| ready[0]?.agentId
|| connected.agents[0]?.agentId
|| 'agent-01';
await spawnSandboxWsPty(session, agentId, connected.wsEndpoint, {
...opts,
capabilities: connected.wsCapabilities,
});
return;
}
}
catch (err) {
// #1146: if we threw above to demand explicit selection, propagate it.
if (err instanceof Error && err.message.startsWith('Sandbox '))
throw err;
/* registry not available — fall through to local PTY */
}
// Phase 2c: legacy REST-based sandbox (AIWG_SANDBOX_ENDPOINT env var — deprecated)
const sandboxEndpoint = opts.sandboxEndpoint || process.env.AIWG_SANDBOX_ENDPOINT;
if (sandboxEndpoint) {
await spawnSandboxPty(session, command, args, {
...opts,
sandboxEndpoint,
agentId: opts.agentId || process.env.AIWG_SANDBOX_AGENT_ID || 'agent-01',
});
return;
}
// Phase 1: local exec via node-pty
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ptyMod;
try {
ptyMod = await (new Function('m', 'return import(m)'))('node-pty');
}
catch {
throw new Error('node-pty is required for PTY sessions. Install it: npm install node-pty');
}
const pty = ptyMod.spawn(command, args, {
name: 'xterm-256color',
cols: opts.cols ?? 120,
rows: opts.rows ?? 30,
cwd: opts.cwd ?? process.cwd(),
env: process.env,
});
session.pty = pty;
pty.onData((data) => {
registry.appendOutput(session.id, data);
registry.broadcast(session.id, { type: 'data', payload: data });
});
pty.onExit(({ exitCode }) => {
session.exited = true;
registry.broadcast(session.id, { type: 'exit', code: exitCode });
});
}
/**
* Spawn a PTY session bridged to an agentic-sandbox management WebSocket.
*
* Connects to the sandbox's management WS server at wsEndpoint, subscribes to
* the target agent, starts an interactive shell, then multicasts PTY output to
* all browser clients via the existing PtySessionRegistry broadcast channel.
*
* The sandbox uses an OutputAggregator that broadcasts all output for an agent_id
* to every subscribed WS connection. Multiple aiwg bridges sharing the same PTY
* session receive identical output streams — no extra overhead on the sandbox side.
* (#903: secondary start_shell for an existing session returns the same command_id
* as the primary — the PTY is ref-counted and survives until the last subscriber
* disconnects. The output filter `command_id === commandId` remains correct in all
* cases whether this is the first or a subsequent attach.)
*
* After shell_started, list_sessions is issued to resolve the human-readable
* session_name (e.g. "main") needed for kill_session. (#901)
*
* If the WS connection drops after handshake, the bridge reconnects with exponential
* backoff rather than marking the session exited. The tmux session on the VM is
* preserved as long as at least one subscriber is attached. (#902)
*
* Future: once aiwg-serve needs operator control (pause/inspect/hand-off), use the
* formal session protocol keyed on session_id from list_sessions:
* JoinSession { session_id, role: "controller" | "observer" }
* SessionInput { session_id, data }
* SessionResize { session_id, cols, rows }
* RequestControl / ReleaseControl
* This enables role-gated stdin and replay from a sequence number. (#904)
*
* @issue #657
*/
async function spawnSandboxWsPty(session, agentId, wsEndpoint, opts) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wsMod;
try {
wsMod = await (new Function('m', 'return import(m)'))('ws');
}
catch {
try {
// @ts-expect-error Optional runtime dependency; this workspace does not ship @types/ws.
wsMod = await import('ws');
}
catch {
throw new Error('ws package required for sandbox PTY bridge. Run: npm install ws');
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WS = wsMod.WebSocket ?? wsMod.default?.WebSocket ?? wsMod.default;
if (typeof WS !== 'function') {
throw new Error('Could not resolve WebSocket constructor from ws package');
}
const cols = opts.cols ?? 120;
const rows = opts.rows ?? 30;
// Replay-on-attach negotiation (#1144). Three states:
// "advertised" — banner explicitly lists join_session + replay_buffer.
// "probe" — no banner present; we'll send join_session opportunistically
// and watch for an "unknown message" / "not supported" error
// to demote to "unsupported" if the sandbox can't handle it.
// "unsupported"— banner advertises capabilities but join_session is absent
// (rare — would mean a partial banner). Skip entirely.
//
// Default behavior is opportunistic: if the operator hasn't supplied a
// capability map, or it lists no client messages, we still try. This keeps
// the replay path useful on sandboxes that support join_session without
// shipping the server-hello banner from agentic-sandbox#190.
let replayMode;
if (!opts.capabilities) {
replayMode = 'probe';
}
else if (opts.capabilities.supported_client_messages.includes('join_session') &&
opts.capabilities.features.includes('replay_buffer')) {
replayMode = 'advertised';
}
else if (opts.capabilities.supported_client_messages.length === 0 &&
opts.capabilities.features.length === 0) {
// Sandbox supplied an empty banner — treat as no-banner-yet, probe.
replayMode = 'probe';
}
else {
// Banner present but missing the bits we care about — don't probe; the
// sandbox has explicitly told us it doesn't support replay.
replayMode = 'unsupported';
}
console.info(`[pty-bridge] sandbox WS ${wsEndpoint} (agent=${agentId}): replay ${replayMode === 'advertised'
? 'enabled (banner)'
: replayMode === 'probe'
? 'enabled (opportunistic — no banner advertised)'
: 'disabled (banner explicitly omits join_session/replay_buffer)'}`);
return new Promise((resolve, reject) => {
let commandId = null;
// session_name resolved via list_sessions after shell_started (#901)
// The kill_session handler looks up by session_name, not command_id.
let sessionName = null;
let onDataCb = null;
let onExitCb = null;
let settled = false;
let reconnectAttempt = 0;
const MAX_RECONNECT = 8;
// #1144: persist last seen sequence number so reconnects resume from gap point
let lastSeq = 0;
// #1144: prepend a single xterm full-reset before the first replay frame so
// the renderer's cursor/screen state agrees with tmux on its starting point.
// Without the reset, mid-conversation bytes overlap whatever was on screen
// and produce the alignment glitch described in #1144.
let needsReplayReset = false;
// sock is reassigned on each reconnect; sendMsg always uses the current one
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sock = null;
const settle = (err) => {
if (settled)
return;
settled = true;
if (err)
reject(err);
else
resolve();
};
const sendMsg = (msg) => {
if (sock?.readyState === 1 /* OPEN */) {
sock.send(JSON.stringify(msg));
}
};
function connect() {
sock = new WS(wsEndpoint);
sock.on('open', () => {
// Subscribe then start (or re-attach to) the interactive shell.
// After sandbox#ce8e600, start_shell for an existing session returns
// the same command_id (idempotent attach) — no new PTY is spawned. (#903)
sendMsg({ type: 'subscribe', agent_id: agentId });
sendMsg({ type: 'start_shell', agent_id: agentId, cols, rows });
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sock.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
}
catch {
return;
}
// Shell handshake — record command_id for all subsequent I/O routing.
// On reconnect, the server returns the same command_id (idempotent). (#903)
if (msg['type'] === 'shell_started' && msg['agent_id'] === agentId) {
commandId = msg['command_id'];
// Resolve the outer promise on first shell_started only
settle();
// Request session list to resolve the session_name needed for kill (#901)
sendMsg({ type: 'list_sessions', agent_id: agentId });
// #1144: ask the sandbox to replay buffered output so a late attach
// sees existing session history. On the initial attach lastSeq is 0
// (full replay); on reconnect lastSeq is the last frame we saw, so
// we resume from the gap rather than re-rendering history.
if (replayMode !== 'unsupported') {
needsReplayReset = true;
sendMsg({
type: 'join_session',
agent_id: agentId,
command_id: commandId,
replay_from: lastSeq,
});
}
return;
}
// Probe-mode error handling (#1144): if the sandbox doesn't recognize
// the join_session message, demote to 'unsupported' so the renderer
// doesn't keep waiting for a replay reset that's never going to come,
// and log so the operator can see why replay didn't take effect.
if (msg['type'] === 'error') {
const errMsg = String(msg['message'] ?? '').toLowerCase();
const looksLikeUnsupported = errMsg.includes('unknown message') ||
errMsg.includes('unsupported') ||
errMsg.includes('not supported') ||
errMsg.includes('session not found') ||
errMsg.includes('unknown type');
if (looksLikeUnsupported &&
(replayMode === 'probe' || replayMode === 'advertised')) {
console.warn(`[pty-bridge] sandbox declined join_session for agent=${agentId}: "${msg['message']}" — demoting to unsupported, falling back to live-only`);
replayMode = 'unsupported';
// Cancel the pending reset — there will be no replay frame to
// prepend `\x1bc` to.
needsReplayReset = false;
}
// Other error types are forwarded as-is to the existing handler
// chain (no action here — pre-existing behavior).
return;
}
// session_list response: find our session by command_id, store session_name (#901)
if (msg['type'] === 'session_list' && msg['agent_id'] === agentId) {
const sessions = msg['sessions'];
const match = sessions?.find((s) => s.command_id === commandId);
if (match)
sessionName = match.session_name;
return;
}
// PTY output from the sandbox broadcast channel → forward to browser
if (msg['type'] === 'output' &&
msg['agent_id'] === agentId &&
msg['command_id'] === commandId &&
(msg['stream'] === 'stdout' || msg['stream'] === undefined) &&
msg['data']) {
let data = typeof msg['data'] === 'string' ? msg['data'] : String(msg['data']);
// #1144: track sandbox-supplied seq so reconnects can resume from
// the gap point rather than replaying from the buffer head.
const seq = msg['seq'];
if (typeof seq === 'number' && seq > lastSeq)
lastSeq = seq;
// #1144: emit a full xterm reset (`\x1bc`) ahead of the first frame
// following a replay request so the renderer agrees with tmux on
// the starting state. Eliminates the alignment glitch where
// mid-session bytes overlap the prior screen content.
if (needsReplayReset) {
data = '\x1bc' + data;
needsReplayReset = false;
}
onDataCb?.(data);
return;
}
// Session ended on sandbox side (explicit kill or process exit)
if ((msg['type'] === 'session_killed' || msg['type'] === 'session_detached') &&
msg['agent_id'] === agentId) {
onExitCb?.({ exitCode: msg['exit_code'] ?? 0 });
}
});
sock.on('error', (_err) => {
// If not yet settled, the 'close' event will follow and settle with an error.
// After settlement, let 'close' drive reconnection.
});
sock.on('close', () => {
if (!settled) {
// Closed before shell_started — fatal for the initial connect
settle(new Error(`Sandbox WS closed before shell_started (agent: ${agentId}, endpoint: ${wsEndpoint})`));
return;
}
if (session.exited)
return; // already torn down intentionally
// Unexpected mid-session disconnect — reconnect with exponential backoff. (#902)
// The tmux session on the VM survives as long as we reconnect before the last
// subscriber drops (sandbox ref-counts WS subscribers per PTY).
if (reconnectAttempt < MAX_RECONNECT) {
reconnectAttempt++;
const delay = Math.min(1_000 * Math.pow(2, reconnectAttempt), 30_000);
setTimeout(connect, delay);
}
else {
// Exhausted retries — treat as session exit
onExitCb?.({ exitCode: 1 });
}
});
// 15 s timeout for the initial shell handshake only (not applied on reconnects)
if (!settled) {
const timeout = setTimeout(() => settle(new Error(`Timed out waiting for shell_started from agent ${agentId} at ${wsEndpoint}`)), 15_000);
if (timeout.unref) {
timeout.unref();
}
}
}
// PtyLike wrapper: routes browser I/O back through the sandbox management WS
const sandboxPty = {
write(data) {
if (!commandId)
return;
sendMsg({ type: 'send_input', agent_id: agentId, command_id: commandId, data });
},
resize(c, r) {
if (!commandId)
return;
sendMsg({ type: 'pty_resize', agent_id: agentId, command_id: commandId, cols: c, rows: r });
},
kill(_signal) {
// Kill by session_name — the sandbox KillSession handler looks up by name,
// not by command_id. session_name is resolved from list_sessions. (#901)
if (sessionName) {
sendMsg({ type: 'kill_session', agent_id: agentId, session_name: sessionName });
}
sock?.close();
},
onData(cb) { onDataCb = cb; },
onExit(cb) { onExitCb = cb; },
};
session.pty = sandboxPty;
// Wire output → broadcast pipeline (mirrors the local PTY pattern)
sandboxPty.onData((data) => {
registry.appendOutput(session.id, data);
registry.broadcast(session.id, { type: 'data', payload: data });
});
sandboxPty.onExit(({ exitCode }) => {
session.exited = true;
registry.broadcast(session.id, { type: 'exit', code: exitCode });
});
connect();
});
}
/**
* Spawn a PTY session on a remote agentic-sandbox instance.
* Submits a task and polls for log output via REST.
*
* @deprecated Use spawnSandboxWsPty (management WebSocket) instead.
* The REST polling approach requires a /api/v1/tasks endpoint that the
* agentic-sandbox management server does not expose. Left as fallback for
* custom sandbox implementations that implement the task REST API.
* @issue #657
*/
async function spawnSandboxPty(session, command, args, opts) {
const endpoint = opts.sandboxEndpoint.replace(/\/$/, '');
const cmdLine = [command, ...args].join(' ');
// Submit task manifest
const manifest = {
manifest_yaml: [
'version: "1"',
'kind: Task',
'metadata:',
` name: "pty-${session.id}"`,
' labels:',
' aiwg_transport: pty',
` aiwg_session: "${session.id}"`,
'claude:',
` prompt: "${cmdLine}"`,
' headless: true',
' skip_permissions: true',
'vm:',
' profile: agentic-dev',
].join('\n'),
};
const resp = await fetch(`${endpoint}/api/v1/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(manifest),
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(`Sandbox task submission failed: ${resp.status} ${body}`);
}
const result = await resp.json();
const taskId = result.task_id;
// Create a PtyLike wrapper that routes I/O through the sandbox REST API
let logOffset = 0;
let stopped = false;
const sandboxPty = {
write(data) {
if (stopped)
return;
fetch(`${endpoint}/api/v1/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stdin: data }),
}).catch(() => { });
},
resize(_cols, _rows) {
// Resize handled by browser WS direct connection to sandbox :8121
},
kill(_signal) {
if (stopped)
return;
stopped = true;
fetch(`${endpoint}/api/v1/tasks/${taskId}`, { method: 'DELETE' })
.catch(() => { });
},
onData(callback) {
// Poll for log output
const timer = setInterval(async () => {
if (stopped) {
clearInterval(timer);
return;
}
try {
const logResp = await fetch(`${endpoint}/api/v1/tasks/${taskId}/logs?offset=${logOffset}`);
if (!logResp.ok) {
if (logResp.status === 404) {
stopped = true;
clearInterval(timer);
}
return;
}
const text = await logResp.text();
if (text.length > 0) {
logOffset += text.length;
callback(text);
}
}
catch { /* retry next poll */ }
}, 500);
},
onExit(callback) {
// Poll for task completion
const timer = setInterval(async () => {
if (stopped) {
clearInterval(timer);
return;
}
try {
const statusResp = await fetch(`${endpoint}/api/v1/tasks/${taskId}`);
if (!statusResp.ok)
return;
const task = await statusResp.json();
if (['completed', 'failed', 'cancelled'].includes(task.state)) {
stopped = true;
clearInterval(timer);
callback({ exitCode: task.state === 'completed' ? 0 : 1 });
}
}
catch { /* retry */ }
}, 2000);
},
};
session.pty = sandboxPty;
sandboxPty.onData((data) => {
registry.appendOutput(session.id, data);
registry.broadcast(session.id, { type: 'data', payload: data });
});
sandboxPty.onExit(({ exitCode }) => {
session.exited = true;
registry.broadcast(session.id, { type: 'exit', code: exitCode });
});
}
// ============================================================
// WebSocket Connection Handler
// ============================================================
let clientCounter = 0;
/**
* Handle a new WebSocket connection for a PTY session.
*
* @param sessionId - PTY session ID from the URL path
* @param ws - WebSocket-like interface
* @param command - Command to spawn (default: 'aiwg')
* @param args - Command arguments (default: ['mc', 'watch'])
* @param cwd - Working directory
* @param wsEndpoint - Optional: sandbox management WS URL for explicit agent targeting
* @param agentId - Optional: sandbox agent ID to target (requires wsEndpoint)
*/
export async function handlePtyConnection(sessionId, ws, command = 'aiwg', cmdArgs = ['mc', 'watch'], cwd, wsEndpoint, agentId) {
const clientId = `client-${++clientCounter}`;
let session = registry.get(sessionId);
if (!session) {
// New session — create and spawn PTY
session = registry.create(sessionId);
registry.addClient(sessionId, clientId, ws);
try {
await spawnPty(session, command, cmdArgs, { cwd, wsEndpoint, agentId });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ws.send(JSON.stringify({ type: 'error', message: msg }));
ws.close(1011, 'PTY spawn failed');
registry.delete(sessionId);
return;
}
}
else if (!session.exited) {
// Reconnect to existing session — replay buffer
registry.addClient(sessionId, clientId, ws);
if (session.outputBuffer) {
// Trim replay to start from the last full-screen erase so that tmux's
// screen-init sequences (cursor moves, status-bar paint) from before the
// erase don't render as literal garbage in a fresh xterm.js context.
// Everything before \x1b[2J would be cleared by the erase anyway;
// everything after is the session content tmux redrew (MOTD, history, etc).
// If no erase is found, replay the whole buffer unchanged.
const ERASE = '\x1b[2J';
const lastErase = session.outputBuffer.lastIndexOf(ERASE);
const replay = lastErase !== -1 ? session.outputBuffer.slice(lastErase) : session.outputBuffer;
ws.send(JSON.stringify({ type: 'data', payload: replay }));
}
}
else {
// Session exited — inform client
ws.send(JSON.stringify({ type: 'exit', code: 0 }));
ws.close(1000, 'Session already exited');
return;
}
// Handle incoming messages from browser
const onMessage = (rawData) => {
let msg;
try {
msg = JSON.parse(rawData);
}
catch {
return; // ignore malformed frames
}
const s = registry.get(sessionId);
if (!s?.pty)
return;
if (msg.type === 'data' && msg.payload !== undefined) {
s.pty.write(msg.payload);
}
else if (msg.type === 'resize' && msg.cols && msg.rows) {
s.pty.resize(msg.cols, msg.rows);
}
else if (msg.type === 'close') {
s.pty.kill();
}
};
const onClose = () => {
registry.removeClient(sessionId, clientId);
};
return Promise.resolve().then(() => {
// Return handlers for integration with WebSocket framework
ws._onMessage = onMessage;
ws._onClose = onClose;
});
}
// ============================================================
// Hono WebSocket Factory
// ============================================================
/**
* Create a Hono-compatible WebSocket event object for the PTY route.
*
* Used with `upgradeWebSocket` from `@hono/node-server/ws`:
*
* ```ts
* app.get('/ws/pty/:sessionId', upgradeWebSocket((c) => createPtyWsHandler(c)));
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createPtyWsHandler(c) {
const sessionId = c.req.param('sessionId') ?? 'default';
const q = c.req.query();
const command = q.command ?? 'aiwg';
const cmdArgs = q.args ? q.args.split(',') : ['mc', 'watch'];
const cwd = q.cwd;
// Explicit sandbox targeting (optional — auto-detected from registry when absent)
const wsEndpoint = q.wsEndpoint;
const agentId = q.agentId;
let wsRef = null;
return {
onOpen(_evt, ws) {
wsRef = ws;
handlePtyConnection(sessionId, ws, command, cmdArgs, cwd, wsEndpoint, agentId).catch((err) => {
ws.send(JSON.stringify({ type: 'error', message: String(err) }));
ws.close(1011);
});
},
onMessage(evt) {
wsRef?._onMessage?.(evt.data);
},
onClose() {
wsRef?._onClose?.();
wsRef = null;
},
onError(err) {
console.error(`[pty-bridge] WebSocket error on session ${sessionId}:`, err);
},
};
}
//# sourceMappingURL=pty-bridge.js.map