diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 044779a..16996fb 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -11,6 +11,7 @@ import { StreamUnavailableError, type StreamFrame, } from '../lib/ws' +import { attachTerminal, describeTerminalClose, isCleanClose } from '../lib/terminal' import type { AgentSession } from '../lib/types' // `agent session connect [sessionId]` — the terminal window into a cloud @@ -72,13 +73,47 @@ export function registerConnect(session: Command): void { 'Connect to a cloud session: view the conversation, follow it live, and send messages', ) .option('--no-backlog', 'skip printing the stored transcript on open') - .action(async (sessionId: string | undefined, opts: { backlog: boolean }) => { + .option( + '--raw', + "attach a raw PTY to the agent's live terminal (the pixel-perfect TUI; needs an interactive terminal)", + ) + .addHelpText( + 'after', + `\nDefault (message mode): render the conversation, follow it live, and send lines +through the session inbox — single-writer-safe and usable headless / inside a +sandbox. --raw takes over your terminal and drives the agent's live TUI +directly (detach with ${'Ctrl-]'}); it needs a running sandbox and a real TTY.`, + ) + .action(async (sessionId: string | undefined, opts: { backlog: boolean; raw?: boolean }) => { await runAction(async () => { - await runConnect(resolveConnectSessionId(sessionId), opts.backlog) + const id = resolveConnectSessionId(sessionId) + if (opts.raw) { + await runConnectRaw(id) + return + } + await runConnect(id, opts.backlog) }) }) } +// The raw-PTY attach: take over the terminal and bridge it to the session's +// live ttyd (documents/eng/INTERACTIVE_SESSIONS.md §5). Shared by `connect +// --raw` and `session start --connect`. Assumes the sandbox is live; the +// backend closes with a curated reason (surfaced below) when it isn't. +export async function runConnectRaw(sessionId: string): Promise { + const token = requireToken() + const wsBase = resolveWsBase(resolveApiBase()) + const result = await attachTerminal({ token, sessionId, wsBase }) + // The bridge has restored the terminal; report on a fresh line. + process.stdout.write('\r\n') + if (isCleanClose(result.code)) { + console.log('detached — the session keeps running (reconnect with the same command)') + return + } + console.log(`✗ ${describeTerminalClose(result.code, result.reason)}`) + process.exitCode = 1 +} + async function runConnect(sessionId: string, backlog: boolean): Promise { const api = new ApiClient() const token = requireToken() diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 7bcc3a9..a79fca1 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -57,7 +57,7 @@ import { type SyncOutcome, } from '../lib/laptop' import { openBrowser } from '../lib/auth' -import { registerConnect } from './connect' +import { registerConnect, runConnectRaw } from './connect' import { formatStepLine, oneLine, stepText } from '../lib/steps' import { ApiError } from '../lib/api' import { resolveToken } from '../lib/config' @@ -112,6 +112,10 @@ export function registerSession(program: Command): void { {} as Record, ) .option('-w, --watch', 'stream the session live until it reaches a terminal status') + .option( + '--connect', + "after starting, wait for the sandbox and attach a raw PTY to the agent's live terminal (needs an interactive terminal)", + ) .option('--json', 'output raw JSON') .action( async (opts: { @@ -123,6 +127,7 @@ export function registerSession(program: Command): void { prompt?: string metadata: Record watch?: boolean + connect?: boolean json?: boolean }) => { await runAction(async () => { @@ -135,6 +140,14 @@ export function registerSession(program: Command): void { if (sources.length > 1) { throw new Error('provide only one of --config / --config-file / --template') } + // --connect takes over the terminal, so it can't pair with the + // NDJSON stream (--json) or the read-only watcher (--watch). + if (opts.connect && opts.json) { + throw new Error('--connect is interactive and cannot be combined with --json') + } + if (opts.connect && opts.watch) { + throw new Error('provide only one of --connect / --watch') + } const req: StartAgentSessionRequest = { metadata: opts.metadata, } @@ -151,6 +164,13 @@ export function registerSession(program: Command): void { const api = new ApiClient() const session = await api.startAgentSession(req) + if (opts.connect) { + console.log(`✓ started session ${session.id} (${session.status})`) + await printSessionUrl(api, session.id) + await startConnect(api, session.id) + return + } + if (opts.watch) { if (!opts.json) { console.log(`✓ started session ${session.id}`) @@ -643,6 +663,39 @@ export function registerSession(program: Command): void { ) } +// `start --connect`: wait for the session's sandbox to come live, then attach a +// raw PTY (the same bridge as `session connect --raw`). A terminal status before +// RUNNING means the session never got a sandbox (e.g. a preflight/budget gate), +// so there is nothing to attach to. +const CONNECT_READY_TIMEOUT_SECONDS = 120 + +export async function startConnect(api: ApiClient, sessionId: string): Promise { + process.stdout.write('waiting for the sandbox') + const deadline = Date.now() + CONNECT_READY_TIMEOUT_SECONDS * 1000 + for (;;) { + const s = await api.getAgentSession(sessionId) + if (s.status === 'running') break + if (TERMINAL_STATUSES.has(s.status)) { + const reason = s.status_reason ? ` — ${s.status_reason}` : '' + console.log(`\nsession ${s.status} before it became connectable${reason}`) + process.exitCode = 1 + return + } + if (Date.now() > deadline) { + console.log( + `\ntimed out waiting for the sandbox; once it is running, attach with:\n` + + ` agent session connect --raw ${sessionId}`, + ) + process.exitCode = 1 + return + } + process.stdout.write('.') + await sleep(1000) + } + process.stdout.write(' ready\n') + await runConnectRaw(sessionId) +} + // `--watch` entry point: stream the session's output live over WebSocket, and // fall back to REST status polling if streaming is unavailable (e.g. a // backend without the endpoint). Identical UX either way — the same flag diff --git a/src/lib/terminal.ts b/src/lib/terminal.ts new file mode 100644 index 0000000..986da5a --- /dev/null +++ b/src/lib/terminal.ts @@ -0,0 +1,230 @@ +import WebSocket, { type RawData } from 'ws' +import { USER_AGENT } from './constants' + +// The raw-PTY attach behind `agent session connect --raw` and `agent session +// start --connect`: bridge the sandbox's ttyd terminal (a WebSocket over the +// agent's live tmux) to this local terminal, so the agent's pixel-perfect +// Claude Code TUI runs inside your shell. +// +// This is a pure /v1 client. It opens WS /v1/sessions/{id}/terminal with the +// ordinary Bearer login token; the API authenticates it, mints the Modal +// connect token server-side, and relays ttyd byte-for-byte (see the backend's +// session_terminal.py). The CLI never sees a sandbox URL, a Modal token, or a +// cookie — it only speaks ttyd's wire protocol over an already-authenticated +// socket. +// +// Contrast `agent session connect` (no --raw): that is the message-based line +// composer (POST /v1/sessions/{id}/messages) — single-writer-safe and usable +// headless / from inside a sandbox. The raw attach needs a real local TTY and +// is a second writer into the tmux, so it races the worker's inbox send-keys +// until the exclusive write-baton ships (INTERACTIVE_SESSIONS.md §6; accepted +// pre-GA). + +// ttyd's WebSocket subprotocol (v1.7.x). Both legs — CLI<->API and API<->ttyd +// — negotiate it; the backend echoes it upstream. +export const TTYD_SUBPROTOCOL = 'tty' + +// ttyd wire protocol command bytes (v1.7.x, protocol.c). The first byte of +// every framed message is the command; the rest is its payload. +const CLIENT_INPUT = '0' // stdin bytes -> PTY +const CLIENT_RESIZE = '1' // JSON {columns, rows} +const SERVER_OUTPUT = '0' // PTY bytes -> stdout +const SERVER_SET_TITLE = '1' +const SERVER_SET_PREFERENCES = '2' + +// The detach key: Ctrl-] (GS, 0x1d) — the telnet escape, effectively never +// used inside a TUI, so it's a safe "let go of the terminal" chord. Ctrl-C is +// deliberately NOT a detach: it passes through to the agent (raw mode), so the +// remote sees the interrupt. +export const DETACH_KEY_CODE = 0x1d +export const DETACH_KEY_LABEL = 'Ctrl-]' + +// Close codes mirrored from session_terminal.py — the protocol contract. The +// 45xx codes are application-defined (RFC 6455 §7.4.2) so we can print the +// right guidance instead of a bare number. +export const WS_CLOSE_NORMAL = 1000 +export const WS_CLOSE_AUTH_FAILED = 1008 +export const WS_CLOSE_SERVER_ERROR = 1011 +export const WS_CLOSE_SANDBOX_UNAVAILABLE = 4409 +export const WS_CLOSE_ACCESS_REVOKED = 4403 + +// ------------------------------ pure helpers ------------------------------- + +export function buildTerminalUrl(wsBase: string, sessionId: string): string { + return `${wsBase.replace(/\/+$/, '')}/v1/sessions/${encodeURIComponent(sessionId)}/terminal` +} + +// ttyd's first client message is the init/auth JSON (no command byte): an empty +// AuthToken (the API is the perimeter; ttyd runs without --credential) plus the +// initial window size. +export function ttydInitMessage(columns: number, rows: number): Buffer { + return Buffer.from(JSON.stringify({ AuthToken: '', columns, rows })) +} + +export function ttydInputMessage(data: Buffer): Buffer { + return Buffer.concat([Buffer.from(CLIENT_INPUT), data]) +} + +export function ttydResizeMessage(columns: number, rows: number): Buffer { + return Buffer.concat([Buffer.from(CLIENT_RESIZE), Buffer.from(JSON.stringify({ columns, rows }))]) +} + +export type TtydServerMessage = + | { kind: 'output'; data: Buffer } + | { kind: 'title' | 'preferences' | 'unknown' } + +// Split a server frame into its command + payload. Only OUTPUT carries terminal +// bytes; title/preferences are cosmetic and ignored by a raw attach. +export function parseTtydServerMessage(buf: Buffer): TtydServerMessage { + if (buf.length === 0) return { kind: 'unknown' } + const command = String.fromCharCode(buf[0]) + switch (command) { + case SERVER_OUTPUT: + return { kind: 'output', data: buf.subarray(1) } + case SERVER_SET_TITLE: + return { kind: 'title' } + case SERVER_SET_PREFERENCES: + return { kind: 'preferences' } + default: + return { kind: 'unknown' } + } +} + +// Human-readable one-liner for a terminal close, keyed on the code the backend +// sent (and its reason string, when it curated one). +export function describeTerminalClose(code: number, reason: string): string { + switch (code) { + case WS_CLOSE_NORMAL: + return 'terminal closed' + case WS_CLOSE_AUTH_FAILED: + return 'not authorized to attach to this session' + case WS_CLOSE_ACCESS_REVOKED: + return 'access to this session ended' + case WS_CLOSE_SANDBOX_UNAVAILABLE: + return ( + reason || + 'the session sandbox is not running — send it a message to wake it, then reconnect' + ) + case WS_CLOSE_SERVER_ERROR: + return reason ? `terminal server error: ${reason}` : 'terminal server error' + default: + return reason ? `connection closed (${code}): ${reason}` : `connection closed (code ${code})` + } +} + +// True for a close that ended the attach cleanly (normal close = the user +// detached or the agent's tmux ended). Everything else is a failure the caller +// should exit non-zero on. +export function isCleanClose(code: number): boolean { + return code === WS_CLOSE_NORMAL +} + +function toBuffer(data: RawData): Buffer { + if (Buffer.isBuffer(data)) return data + if (Array.isArray(data)) return Buffer.concat(data) + return Buffer.from(data as ArrayBuffer) +} + +// ------------------------------- the bridge -------------------------------- + +export interface AttachResult { + code: number + reason: string +} + +export interface AttachOptions { + token: string + sessionId: string + wsBase: string + // Streams + signal handlers, injectable for tests; default to the process's. + stdin?: NodeJS.ReadStream + stdout?: NodeJS.WriteStream +} + +// Attach this terminal to the session's ttyd over the API relay. Resolves with +// the close code/reason when the socket ends (user detach, agent exit, or a +// server-side revoke). Requires an interactive TTY: it takes over raw stdin. +export function attachTerminal(opts: AttachOptions): Promise { + const stdin = opts.stdin ?? process.stdin + const stdout = opts.stdout ?? process.stdout + + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + return Promise.reject( + new Error( + 'connect --raw needs an interactive terminal (a real TTY). ' + + 'Use `agent session connect` (message mode) for headless/scripted use.', + ), + ) + } + + const url = buildTerminalUrl(opts.wsBase, opts.sessionId) + const ws = new WebSocket(url, [TTYD_SUBPROTOCOL], { + headers: { authorization: `Bearer ${opts.token}`, 'user-agent': USER_AGENT }, + }) + + return new Promise((resolve, reject) => { + let settled = false + let rawEngaged = false + + const cols = (): number => stdout.columns ?? 80 + const rows = (): number => stdout.rows ?? 24 + + const onStdin = (chunk: Buffer): void => { + // A lone detach chord releases the terminal; anything else (including + // Ctrl-C) is forwarded to the agent verbatim. + if (chunk.length === 1 && chunk[0] === DETACH_KEY_CODE) { + ws.close(WS_CLOSE_NORMAL) + return + } + ws.send(ttydInputMessage(chunk)) + } + const onResize = (): void => { + if (ws.readyState === WebSocket.OPEN) ws.send(ttydResizeMessage(cols(), rows())) + } + + const engageRaw = (): void => { + if (rawEngaged) return + rawEngaged = true + stdin.setRawMode(true) + stdin.resume() + stdin.on('data', onStdin) + process.on('SIGWINCH', onResize) + } + const restore = (): void => { + if (!rawEngaged) return + rawEngaged = false + stdin.removeListener('data', onStdin) + process.removeListener('SIGWINCH', onResize) + stdin.setRawMode(false) + stdin.pause() + } + + const finish = (result: AttachResult): void => { + if (settled) return + settled = true + restore() + resolve(result) + } + + ws.on('open', () => { + ws.send(ttydInitMessage(cols(), rows())) + stdout.write(`── attached (detach: ${DETACH_KEY_LABEL}) ──\r\n`) + engageRaw() + }) + ws.on('message', (data: RawData) => { + const message = parseTtydServerMessage(toBuffer(data)) + if (message.kind === 'output') stdout.write(message.data) + }) + ws.on('close', (code: number, reasonBuf: Buffer) => { + finish({ code, reason: reasonBuf.toString() }) + }) + ws.on('error', (err: Error) => { + if (settled) return + settled = true + restore() + // A handshake rejection (e.g. HTTP 401/403 before upgrade) surfaces here; + // the close handler won't fire, so reject with the transport error. + reject(err) + }) + }) +} diff --git a/test/terminal.test.ts b/test/terminal.test.ts new file mode 100644 index 0000000..5dccd11 --- /dev/null +++ b/test/terminal.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { + buildTerminalUrl, + describeTerminalClose, + DETACH_KEY_CODE, + isCleanClose, + parseTtydServerMessage, + ttydInitMessage, + ttydInputMessage, + ttydResizeMessage, + WS_CLOSE_ACCESS_REVOKED, + WS_CLOSE_AUTH_FAILED, + WS_CLOSE_NORMAL, + WS_CLOSE_SANDBOX_UNAVAILABLE, + WS_CLOSE_SERVER_ERROR, +} from '../src/lib/terminal' + +describe('buildTerminalUrl', () => { + it('builds the /v1 terminal WebSocket path and trims a trailing slash', () => { + expect(buildTerminalUrl('wss://api.ellipsis.dev', 'session_abc')).toBe( + 'wss://api.ellipsis.dev/v1/sessions/session_abc/terminal', + ) + expect(buildTerminalUrl('ws://localhost:5000/', 'session_abc')).toBe( + 'ws://localhost:5000/v1/sessions/session_abc/terminal', + ) + }) + + it('url-encodes the session id', () => { + expect(buildTerminalUrl('wss://api.ellipsis.dev', 'a/b c')).toBe( + 'wss://api.ellipsis.dev/v1/sessions/a%2Fb%20c/terminal', + ) + }) +}) + +describe('ttyd client frames', () => { + it('init message is bare JSON with empty AuthToken and the window size', () => { + const parsed = JSON.parse(ttydInitMessage(120, 40).toString()) + expect(parsed).toEqual({ AuthToken: '', columns: 120, rows: 40 }) + }) + + it('input message is command byte "0" followed by the raw bytes', () => { + const frame = ttydInputMessage(Buffer.from('ls\r')) + expect(String.fromCharCode(frame[0])).toBe('0') + expect(frame.subarray(1).toString()).toBe('ls\r') + }) + + it('input message preserves arbitrary binary (e.g. control bytes) verbatim', () => { + const raw = Buffer.from([0x03, 0x1b, 0x5b, 0x41]) // Ctrl-C, ESC [ A + const frame = ttydInputMessage(raw) + expect(frame[0]).toBe('0'.charCodeAt(0)) + expect(frame.subarray(1).equals(raw)).toBe(true) + }) + + it('resize message is command byte "1" followed by columns/rows JSON', () => { + const frame = ttydResizeMessage(80, 24) + expect(String.fromCharCode(frame[0])).toBe('1') + expect(JSON.parse(frame.subarray(1).toString())).toEqual({ columns: 80, rows: 24 }) + }) +}) + +describe('parseTtydServerMessage', () => { + it('splits an OUTPUT frame into its payload bytes', () => { + const msg = parseTtydServerMessage(Buffer.concat([Buffer.from('0'), Buffer.from('hi there')])) + expect(msg.kind).toBe('output') + if (msg.kind === 'output') expect(msg.data.toString()).toBe('hi there') + }) + + it('classifies title and preferences frames (no payload surfaced)', () => { + expect(parseTtydServerMessage(Buffer.from('1title')).kind).toBe('title') + expect(parseTtydServerMessage(Buffer.from('2{}')).kind).toBe('preferences') + }) + + it('treats an empty or unknown-command frame as unknown', () => { + expect(parseTtydServerMessage(Buffer.alloc(0)).kind).toBe('unknown') + expect(parseTtydServerMessage(Buffer.from('9x')).kind).toBe('unknown') + }) +}) + +describe('describeTerminalClose / isCleanClose', () => { + it('only a normal close is clean', () => { + expect(isCleanClose(WS_CLOSE_NORMAL)).toBe(true) + expect(isCleanClose(WS_CLOSE_SANDBOX_UNAVAILABLE)).toBe(false) + expect(isCleanClose(WS_CLOSE_ACCESS_REVOKED)).toBe(false) + }) + + it('maps each close code to actionable guidance', () => { + expect(describeTerminalClose(WS_CLOSE_AUTH_FAILED, '')).toMatch(/not authorized/i) + expect(describeTerminalClose(WS_CLOSE_ACCESS_REVOKED, '')).toMatch(/access .* ended/i) + expect(describeTerminalClose(WS_CLOSE_SERVER_ERROR, '')).toMatch(/server error/i) + }) + + it('prefers the server-sent reason for a sandbox-unavailable close', () => { + expect(describeTerminalClose(WS_CLOSE_SANDBOX_UNAVAILABLE, 'wake it first')).toBe('wake it first') + // ...and falls back to canned guidance when the server sent no reason. + expect(describeTerminalClose(WS_CLOSE_SANDBOX_UNAVAILABLE, '')).toMatch(/not running/i) + }) + + it('renders an unknown code with whatever reason arrived', () => { + expect(describeTerminalClose(4999, 'weird')).toBe('connection closed (4999): weird') + expect(describeTerminalClose(4999, '')).toBe('connection closed (code 4999)') + }) +}) + +describe('detach key', () => { + it('is Ctrl-] (GS, 0x1d)', () => { + expect(DETACH_KEY_CODE).toBe(0x1d) + }) +})