diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 16996fb..1602da2 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -11,7 +11,6 @@ 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 @@ -73,48 +72,20 @@ 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') - .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.`, + `\nMessage mode: render the conversation, follow it live, and send lines through +the session inbox — single-writer-safe and usable headless / inside a sandbox.`, ) - .action(async (sessionId: string | undefined, opts: { backlog: boolean; raw?: boolean }) => { + .action(async (sessionId: string | undefined, opts: { backlog: boolean }) => { await runAction(async () => { 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 { +export async function runConnect(sessionId: string, backlog: boolean): Promise { const api = new ApiClient() const token = requireToken() const wsBase = resolveWsBase(resolveApiBase()) diff --git a/src/commands/session.tsx b/src/commands/session.tsx index a79fca1..eafccb8 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, runConnectRaw } from './connect' +import { registerConnect, runConnect } from './connect' import { formatStepLine, oneLine, stepText } from '../lib/steps' import { ApiError } from '../lib/api' import { resolveToken } from '../lib/config' @@ -114,7 +114,7 @@ export function registerSession(program: Command): void { .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)", + 'after starting, wait for the sandbox and connect: view the conversation, follow it live, and send messages', ) .option('--json', 'output raw JSON') .action( @@ -663,10 +663,11 @@ 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 +// `start --connect`: wait for the session's sandbox to come live, then drop into +// the semantic connect (render the conversation, follow it live, send messages +// through the inbox — the same as `session connect`). 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. +// so there is nothing to connect to. const CONNECT_READY_TIMEOUT_SECONDS = 120 export async function startConnect(api: ApiClient, sessionId: string): Promise { @@ -683,8 +684,8 @@ export async function startConnect(api: ApiClient, sessionId: string): Promise deadline) { console.log( - `\ntimed out waiting for the sandbox; once it is running, attach with:\n` + - ` agent session connect --raw ${sessionId}`, + `\ntimed out waiting for the sandbox; once it is running, connect with:\n` + + ` agent session connect ${sessionId}`, ) process.exitCode = 1 return @@ -693,7 +694,7 @@ export async function startConnect(api: ApiClient, sessionId: string): PromiseAPI 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 deleted file mode 100644 index 5dccd11..0000000 --- a/test/terminal.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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) - }) -})