From d8399d2edc90432dc7630556e219d98be20c3607 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:29:53 +0000 Subject: [PATCH 01/38] Add Week 1 protocol config and storage layers --- src/config/defaults.ts | 9 ++ src/config/resolveConfig.ts | 31 ++++ src/index.ts | 32 +++++ src/protocol/errors.ts | 82 +++++++++++ src/protocol/messages.ts | 191 +++++++++++++++++++++++++ src/protocol/schemas.ts | 44 ++++++ src/storage/home.ts | 39 +++++ src/storage/manifests.ts | 134 +++++++++++++++++ src/storage/sessionPaths.ts | 71 +++++++++ test/unit/protocol/messages.test.ts | 169 ++++++++++++++++++++++ test/unit/storage/sessionPaths.test.ts | 125 ++++++++++++++++ 11 files changed, 927 insertions(+) create mode 100644 src/config/defaults.ts create mode 100644 src/config/resolveConfig.ts create mode 100644 src/protocol/errors.ts create mode 100644 src/protocol/messages.ts create mode 100644 src/protocol/schemas.ts create mode 100644 src/storage/home.ts create mode 100644 src/storage/manifests.ts create mode 100644 src/storage/sessionPaths.ts create mode 100644 test/unit/protocol/messages.test.ts create mode 100644 test/unit/storage/sessionPaths.test.ts diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..9fa09c0 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,9 @@ +import process from 'node:process'; + +export const DEFAULT_COLS = 80; +export const DEFAULT_ROWS = 24; +export const DEFAULT_SHELL = process.env.SHELL ?? '/bin/sh'; + +export const SOCKET_FILENAME = 'host.sock'; +export const MANIFEST_FILENAME = 'session.json'; +export const EVENT_LOG_FILENAME = 'events.jsonl'; diff --git a/src/config/resolveConfig.ts b/src/config/resolveConfig.ts new file mode 100644 index 0000000..b17c04b --- /dev/null +++ b/src/config/resolveConfig.ts @@ -0,0 +1,31 @@ +import { + DEFAULT_COLS, + DEFAULT_ROWS, + DEFAULT_SHELL, + EVENT_LOG_FILENAME, + MANIFEST_FILENAME, + SOCKET_FILENAME, +} from './defaults.js'; +import { resolveHome } from '../storage/home.js'; + +export interface AgentTerminalConfig { + readonly home: string; + readonly cols: number; + readonly rows: number; + readonly shell: string; + readonly socketFilename: string; + readonly manifestFilename: string; + readonly eventLogFilename: string; +} + +export function resolveConfig(): Readonly { + return Object.freeze({ + home: resolveHome(), + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + shell: DEFAULT_SHELL, + socketFilename: SOCKET_FILENAME, + manifestFilename: MANIFEST_FILENAME, + eventLogFilename: EVENT_LOG_FILENAME, + }); +} diff --git a/src/index.ts b/src/index.ts index 36fd7dc..f3756cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,35 @@ export type { CommandErrorEnvelope, CommandSuccessEnvelope, } from './protocol/envelope.js'; +export type { + DestroyParams, + DestroyResult, + InspectParams, + InspectResult, + PasteParams, + PasteResult, + ResizeParams, + ResizeResult, + RpcError, + RpcErrorResponse, + RpcMethod, + RpcRequest, + RpcResponse, + RpcSuccessResponse, + SendKeysParams, + SendKeysResult, + SignalParams, + SignalResult, + TypeParams, + TypeResult, + WaitParams, + WaitResult, +} from './protocol/messages.js'; +export type { + EventRecord, + EventType, + SessionRecord, + SessionStatus, +} from './protocol/schemas.js'; +export type { ProtocolErrorCode } from './protocol/errors.js'; +export type { AgentTerminalConfig } from './config/resolveConfig.js'; diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts new file mode 100644 index 0000000..086e511 --- /dev/null +++ b/src/protocol/errors.ts @@ -0,0 +1,82 @@ +import type { CliError } from '../cli/errors.js'; + +import { CliError as CliErrorClass } from '../cli/errors.js'; + +export const ERROR_CODES = { + SESSION_NOT_FOUND: 'SESSION_NOT_FOUND', + SESSION_NOT_RUNNING: 'SESSION_NOT_RUNNING', + SESSION_ALREADY_DESTROYED: 'SESSION_ALREADY_DESTROYED', + HOST_UNREACHABLE: 'HOST_UNREACHABLE', + HOST_TIMEOUT: 'HOST_TIMEOUT', + INVALID_SESSION_ID: 'INVALID_SESSION_ID', + INVALID_DIMENSIONS: 'INVALID_DIMENSIONS', + INVALID_SIGNAL: 'INVALID_SIGNAL', + INVALID_KEYS: 'INVALID_KEYS', + INVALID_DURATION: 'INVALID_DURATION', + STORAGE_READ_ERROR: 'STORAGE_READ_ERROR', + STORAGE_WRITE_ERROR: 'STORAGE_WRITE_ERROR', + MANIFEST_VALIDATION_ERROR: 'MANIFEST_VALIDATION_ERROR', + RPC_ERROR: 'RPC_ERROR', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ProtocolErrorCode = + (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +export const DEFAULT_ERROR_MESSAGES: Record = { + [ERROR_CODES.SESSION_NOT_FOUND]: 'Session not found.', + [ERROR_CODES.SESSION_NOT_RUNNING]: 'Session is not running.', + [ERROR_CODES.SESSION_ALREADY_DESTROYED]: 'Session is already destroyed.', + [ERROR_CODES.HOST_UNREACHABLE]: 'Session host is unreachable.', + [ERROR_CODES.HOST_TIMEOUT]: 'Session host timed out.', + [ERROR_CODES.INVALID_SESSION_ID]: 'Session ID is invalid.', + [ERROR_CODES.INVALID_DIMENSIONS]: 'Terminal dimensions are invalid.', + [ERROR_CODES.INVALID_SIGNAL]: 'Signal is invalid.', + [ERROR_CODES.INVALID_KEYS]: 'Key sequence is invalid.', + [ERROR_CODES.INVALID_DURATION]: 'Duration value is invalid.', + [ERROR_CODES.STORAGE_READ_ERROR]: 'Failed to read session storage.', + [ERROR_CODES.STORAGE_WRITE_ERROR]: 'Failed to write session storage.', + [ERROR_CODES.MANIFEST_VALIDATION_ERROR]: 'Session manifest is invalid.', + [ERROR_CODES.RPC_ERROR]: 'RPC request failed.', + [ERROR_CODES.INTERNAL_ERROR]: 'Internal error.', +}; + +const DEFAULT_RETRYABLE_CODES: ReadonlySet = new Set([ + ERROR_CODES.HOST_UNREACHABLE, + ERROR_CODES.HOST_TIMEOUT, + ERROR_CODES.RPC_ERROR, +]); + +export interface MakeCliErrorOptions { + message?: string; + retryable?: boolean; + details?: Record; + cause?: unknown; +} + +export function makeCliError( + code: ProtocolErrorCode, + overrides: MakeCliErrorOptions = {}, +): CliError { + const options: { + retryable?: boolean; + details?: Record; + cause?: unknown; + } = { + retryable: overrides.retryable ?? DEFAULT_RETRYABLE_CODES.has(code), + }; + + if (overrides.details !== undefined) { + options.details = overrides.details; + } + + if (overrides.cause !== undefined) { + options.cause = overrides.cause; + } + + return new CliErrorClass( + code, + overrides.message ?? DEFAULT_ERROR_MESSAGES[code], + options, + ); +} diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts new file mode 100644 index 0000000..b171e9d --- /dev/null +++ b/src/protocol/messages.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; + +import { SessionRecordSchema } from './schemas.js'; + +const EmptyObjectSchema = z.object({}).strict(); +const NonEmptyStringSchema = z.string().min(1); +const DurationSchema = z.number().int().nonnegative(); + +export const RpcRequestSchema = z + .object({ + id: NonEmptyStringSchema, + method: NonEmptyStringSchema, + params: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); +export type RpcRequest = z.infer; + +export const RpcErrorSchema = z + .object({ + code: NonEmptyStringSchema, + message: NonEmptyStringSchema, + }) + .strict(); +export type RpcError = z.infer; + +export const RpcSuccessResponseSchema = z + .object({ + id: NonEmptyStringSchema, + ok: z.literal(true), + result: z.unknown(), + }) + .strict(); +export type RpcSuccessResponse = z.infer; + +export const RpcErrorResponseSchema = z + .object({ + id: NonEmptyStringSchema, + ok: z.literal(false), + error: RpcErrorSchema, + }) + .strict(); +export type RpcErrorResponse = z.infer; + +export const RpcResponseSchema = z.discriminatedUnion('ok', [ + RpcSuccessResponseSchema, + RpcErrorResponseSchema, +]); +export type RpcResponse = z.infer; + +export const InspectParamsSchema = EmptyObjectSchema; +export type InspectParams = z.infer; + +export const InspectResultSchema = z + .object({ + session: SessionRecordSchema, + }) + .strict(); +export type InspectResult = z.infer; + +export const TypeParamsSchema = z + .object({ + text: z.string(), + }) + .strict(); +export type TypeParams = z.infer; + +export const TypeResultSchema = EmptyObjectSchema; +export type TypeResult = z.infer; + +export const PasteParamsSchema = z + .object({ + text: z.string(), + }) + .strict(); +export type PasteParams = z.infer; + +export const PasteResultSchema = EmptyObjectSchema; +export type PasteResult = z.infer; + +export const SendKeysParamsSchema = z + .object({ + keys: z.array(NonEmptyStringSchema).min(1), + }) + .strict(); +export type SendKeysParams = z.infer; + +export const SendKeysResultSchema = EmptyObjectSchema; +export type SendKeysResult = z.infer; + +export const ResizeParamsSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); +export type ResizeParams = z.infer; + +export const ResizeResultSchema = EmptyObjectSchema; +export type ResizeResult = z.infer; + +export const SignalParamsSchema = z + .object({ + signal: NonEmptyStringSchema, + }) + .strict(); +export type SignalParams = z.infer; + +export const SignalResultSchema = EmptyObjectSchema; +export type SignalResult = z.infer; + +export const WaitParamsSchema = z + .object({ + exit: z.boolean().optional(), + idleMs: DurationSchema.optional(), + timeoutMs: DurationSchema.optional(), + }) + .strict(); +export type WaitParams = z.infer; + +export const WaitResultSchema = z + .object({ + exitCode: z.number().int().optional(), + timedOut: z.boolean(), + }) + .strict(); +export type WaitResult = z.infer; + +export const DestroyParamsSchema = z + .object({ + force: z.boolean().optional(), + }) + .strict(); +export type DestroyParams = z.infer; + +export const DestroyResultSchema = EmptyObjectSchema; +export type DestroyResult = z.infer; + +const RPC_METHODS = [ + 'inspect', + 'type', + 'paste', + 'sendKeys', + 'resize', + 'signal', + 'wait', + 'destroy', +] as const; + +export const RpcMethodSchema = z.enum(RPC_METHODS); +export type RpcMethod = z.infer; + +export const RpcMethodSchemas = { + inspect: { + params: InspectParamsSchema, + result: InspectResultSchema, + }, + type: { + params: TypeParamsSchema, + result: TypeResultSchema, + }, + paste: { + params: PasteParamsSchema, + result: PasteResultSchema, + }, + sendKeys: { + params: SendKeysParamsSchema, + result: SendKeysResultSchema, + }, + resize: { + params: ResizeParamsSchema, + result: ResizeResultSchema, + }, + signal: { + params: SignalParamsSchema, + result: SignalResultSchema, + }, + wait: { + params: WaitParamsSchema, + result: WaitResultSchema, + }, + destroy: { + params: DestroyParamsSchema, + result: DestroyResultSchema, + }, +} as const satisfies Record< + RpcMethod, + { + params: z.ZodType; + result: z.ZodType; + } +>; diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts new file mode 100644 index 0000000..44488fe --- /dev/null +++ b/src/protocol/schemas.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +export const SessionStatusSchema = z.enum(['running', 'exiting', 'exited']); +export type SessionStatus = z.infer; + +export const SessionRecordSchema = z + .object({ + version: z.literal(1), + sessionId: z.string(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + status: SessionStatusSchema, + command: z.array(z.string()).min(1), + cwd: z.string(), + cols: z.number().int().positive(), + rows: z.number().int().positive(), + hostPid: z.number().int().positive().nullable(), + childPid: z.number().int().positive().nullable(), + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(); +export type SessionRecord = z.infer; + +export const EventTypeSchema = z.enum([ + 'output', + 'input_text', + 'input_paste', + 'input_keys', + 'resize', + 'signal', + 'exit', +]); +export type EventType = z.infer; + +export const EventRecordSchema = z + .object({ + seq: z.number().int().nonnegative(), + ts: z.iso.datetime(), + type: EventTypeSchema, + payload: z.unknown(), + }) + .strict(); +export type EventRecord = z.infer; diff --git a/src/storage/home.ts b/src/storage/home.ts new file mode 100644 index 0000000..6f362b1 --- /dev/null +++ b/src/storage/home.ts @@ -0,0 +1,39 @@ +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, normalize } from 'node:path'; +import process from 'node:process'; + +import { invariant } from '../util/assert.js'; + +const DEFAULT_HOME_DIRECTORY_NAME = '.agent-terminal'; + +export function resolveHome(): string { + const configuredHome = process.env.AGENT_TERMINAL_HOME; + + if (configuredHome !== undefined) { + invariant(configuredHome.length > 0, 'AGENT_TERMINAL_HOME must not be empty'); + invariant( + isAbsolute(configuredHome), + 'AGENT_TERMINAL_HOME must be an absolute path', + ); + + return normalize(configuredHome); + } + + const resolvedHome = normalize(join(homedir(), DEFAULT_HOME_DIRECTORY_NAME)); + + invariant( + isAbsolute(resolvedHome), + 'resolved agent-terminal home must be absolute', + ); + + return resolvedHome; +} + +export async function ensureHome(): Promise { + const home = resolveHome(); + + await mkdir(home, { recursive: true }); + + return home; +} diff --git a/src/storage/manifests.ts b/src/storage/manifests.ts new file mode 100644 index 0000000..15f1961 --- /dev/null +++ b/src/storage/manifests.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; +import { + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { dirname, isAbsolute } from 'node:path'; + +import { + ERROR_CODES, + makeCliError, +} from '../protocol/errors.js'; +import { + SessionRecordSchema, +} from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; +import type { SessionRecord } from '../protocol/schemas.js'; + +interface NodeError { + code?: string; +} + +function assertAbsoluteManifestPath(path: string): void { + invariant(path.length > 0, 'manifest path must be a non-empty string'); + invariant(isAbsolute(path), 'manifest path must be absolute'); +} + +function isEnoentError(error: unknown): error is Error & NodeError { + return ( + error instanceof Error && + 'code' in error && + (error as NodeError).code === 'ENOENT' + ); +} + +function validateManifestData( + path: string, + data: unknown, +): SessionRecord { + const parsedManifest = SessionRecordSchema.safeParse(data); + + if (parsedManifest.success) { + return parsedManifest.data; + } + + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Session manifest validation failed for ${path}.`, + details: { + path, + issues: parsedManifest.error.issues, + }, + }); +} + +function parseManifestJson(path: string, rawManifest: string): SessionRecord { + try { + return validateManifestData(path, JSON.parse(rawManifest) as unknown); + } catch (error) { + if (error instanceof SyntaxError) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Session manifest contains invalid JSON at ${path}.`, + details: { path }, + cause: error, + }); + } + + throw error; + } +} + +async function readManifestInternal( + path: string, + allowMissing: boolean, +): Promise { + assertAbsoluteManifestPath(path); + + let rawManifest: string; + try { + rawManifest = await readFile(path, 'utf8'); + } catch (error) { + if (allowMissing && isEnoentError(error)) { + return null; + } + + throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: `Failed to read session manifest at ${path}.`, + details: { path }, + cause: error, + }); + } + + return parseManifestJson(path, rawManifest); +} + +export async function readManifest(path: string): Promise { + const manifest = await readManifestInternal(path, false); + + invariant(manifest !== null, 'readManifest must return a manifest record'); + + return manifest; +} + +export async function readManifestIfExists( + path: string, +): Promise { + return readManifestInternal(path, true); +} + +export async function writeManifest( + path: string, + record: SessionRecord, +): Promise { + assertAbsoluteManifestPath(path); + + const validatedRecord = validateManifestData(path, record); + const serializedManifest = `${JSON.stringify(validatedRecord, null, 2)}\n`; + const manifestDirectory = dirname(path); + const temporaryPath = `${path}.tmp-${randomUUID()}`; + + try { + await mkdir(manifestDirectory, { recursive: true }); + await writeFile(temporaryPath, serializedManifest, 'utf8'); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { + message: `Failed to write session manifest at ${path}.`, + details: { path }, + cause: error, + }); + } +} diff --git a/src/storage/sessionPaths.ts b/src/storage/sessionPaths.ts new file mode 100644 index 0000000..5ff6a70 --- /dev/null +++ b/src/storage/sessionPaths.ts @@ -0,0 +1,71 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; + +import { + EVENT_LOG_FILENAME, + MANIFEST_FILENAME, + SOCKET_FILENAME, +} from '../config/defaults.js'; +import { invariant } from '../util/assert.js'; + +function assertNonEmptyString( + value: string, + label: string, +): asserts value is string { + invariant(value.length > 0, `${label} must be a non-empty string`); +} + +function assertAbsolutePath(pathValue: string, label: string): void { + assertNonEmptyString(pathValue, label); + invariant(isAbsolute(pathValue), `${label} must be an absolute path`); +} + +function assertSessionId(sessionId: string): void { + assertNonEmptyString(sessionId, 'sessionId'); + invariant(sessionId !== '.', 'sessionId must not be "."'); + invariant(sessionId !== '..', 'sessionId must not be ".."'); + invariant( + !sessionId.includes('/') && !sessionId.includes('\\'), + 'sessionId must not contain path separators', + ); +} + +export function sessionDir(home: string, sessionId: string): string { + assertAbsolutePath(home, 'home'); + assertSessionId(sessionId); + + const sessionsRoot = resolve(home, 'sessions'); + const resolvedSessionDirectory = resolve(sessionsRoot, sessionId); + + invariant( + dirname(resolvedSessionDirectory) === sessionsRoot, + 'session directory must stay within the sessions root', + ); + + return resolvedSessionDirectory; +} + +function childPath(sessionDirectory: string, filename: string): string { + assertAbsolutePath(sessionDirectory, 'sessionDir'); + + const normalizedSessionDirectory = resolve(sessionDirectory); + const child = resolve(normalizedSessionDirectory, filename); + + invariant( + dirname(child) === normalizedSessionDirectory, + `${filename} must stay within the session directory`, + ); + + return child; +} + +export function manifestPath(sessionDirectory: string): string { + return childPath(sessionDirectory, MANIFEST_FILENAME); +} + +export function eventLogPath(sessionDirectory: string): string { + return childPath(sessionDirectory, EVENT_LOG_FILENAME); +} + +export function socketPath(sessionDirectory: string): string { + return childPath(sessionDirectory, SOCKET_FILENAME); +} diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts new file mode 100644 index 0000000..3162b4a --- /dev/null +++ b/test/unit/protocol/messages.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { + DestroyParamsSchema, + InspectResultSchema, + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + SendKeysParamsSchema, + WaitResultSchema, +} from '../../../src/protocol/messages.js'; +import { + EventRecordSchema, + SessionRecordSchema, +} from '../../../src/protocol/schemas.js'; + +function createSessionRecord() { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running' as const, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +describe('protocol schemas', () => { + it('accepts a valid session record', () => { + const result = SessionRecordSchema.safeParse(createSessionRecord()); + + expect(result.success).toBe(true); + }); + + it('rejects a session record with invalid dimensions', () => { + const result = SessionRecordSchema.safeParse({ + ...createSessionRecord(), + cols: 0, + }); + + expect(result.success).toBe(false); + }); + + it('accepts a valid event record', () => { + const result = EventRecordSchema.safeParse({ + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: { cols: 120, rows: 40 }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects an event record with a negative sequence', () => { + const result = EventRecordSchema.safeParse({ + seq: -1, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: {}, + }); + + expect(result.success).toBe(false); + }); +}); + +describe('RPC message schemas', () => { + it('accepts a base RPC request', () => { + const result = RpcRequestSchema.safeParse({ + id: 'request-1', + method: 'resize', + params: { cols: 80, rows: 24 }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects a request with a non-object params payload', () => { + const result = RpcRequestSchema.safeParse({ + id: 'request-1', + method: 'resize', + params: 'bad', + }); + + expect(result.success).toBe(false); + }); + + it('accepts success and error responses', () => { + expect( + RpcResponseSchema.safeParse({ + id: 'request-1', + ok: true, + result: {}, + }).success, + ).toBe(true); + expect( + RpcResponseSchema.safeParse({ + id: 'request-1', + ok: false, + error: { + code: 'HOST_TIMEOUT', + message: 'Session host timed out.', + }, + }).success, + ).toBe(true); + }); + + it('rejects an error response without a message', () => { + const result = RpcResponseSchema.safeParse({ + id: 'request-1', + ok: false, + error: { + code: 'HOST_TIMEOUT', + }, + }); + + expect(result.success).toBe(false); + }); + + it('validates inspect results against the session schema', () => { + const result = InspectResultSchema.safeParse({ + session: createSessionRecord(), + }); + + expect(result.success).toBe(true); + }); + + it('rejects empty key arrays for sendKeys', () => { + const result = SendKeysParamsSchema.safeParse({ + keys: [], + }); + + expect(result.success).toBe(false); + }); + + it('rejects invalid wait result exit codes', () => { + const result = WaitResultSchema.safeParse({ + exitCode: 2.5, + timedOut: false, + }); + + expect(result.success).toBe(false); + }); + + it('accepts destroy params with an optional force flag', () => { + expect(DestroyParamsSchema.safeParse({}).success).toBe(true); + expect(DestroyParamsSchema.safeParse({ force: true }).success).toBe(true); + }); + + it('exposes method schemas for every Week 1 RPC method', () => { + expect(Object.keys(RpcMethodSchemas)).toEqual([ + 'inspect', + 'type', + 'paste', + 'sendKeys', + 'resize', + 'signal', + 'wait', + 'destroy', + ]); + }); +}); diff --git a/test/unit/storage/sessionPaths.test.ts b/test/unit/storage/sessionPaths.test.ts new file mode 100644 index 0000000..ae27734 --- /dev/null +++ b/test/unit/storage/sessionPaths.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { + manifestPath, + eventLogPath, + sessionDir, + socketPath, +} from '../../../src/storage/sessionPaths.js'; +import { + readManifest, + readManifestIfExists, + writeManifest, +} from '../../../src/storage/manifests.js'; + +function createSessionRecord(): SessionRecord { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe('session paths', () => { + it('builds session-specific absolute paths', () => { + const home = '/tmp/agent-terminal-home'; + const directory = sessionDir(home, 'session-01'); + + expect(directory).toBe('/tmp/agent-terminal-home/sessions/session-01'); + expect(manifestPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/session.json', + ); + expect(eventLogPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/events.jsonl', + ); + expect(socketPath(directory)).toBe( + '/tmp/agent-terminal-home/sessions/session-01/host.sock', + ); + }); + + it('asserts on invalid path helper inputs', () => { + expect(() => sessionDir('', 'session-01')).toThrow( + /home must be a non-empty string/u, + ); + expect(() => sessionDir('relative/home', 'session-01')).toThrow( + /home must be an absolute path/u, + ); + expect(() => sessionDir('/tmp/home', '')).toThrow( + /sessionId must be a non-empty string/u, + ); + expect(() => sessionDir('/tmp/home', '../oops')).toThrow( + /path separators/u, + ); + expect(() => manifestPath('relative/path')).toThrow( + /sessionDir must be an absolute path/u, + ); + }); +}); + +describe('manifest storage', () => { + it('writes and reads manifests with validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'session-01')); + const record = createSessionRecord(); + + await writeManifest(path, record); + + const roundTripped = await readManifest(path); + + expect(roundTripped).toEqual(record); + }); + + it('returns null when a manifest does not exist', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'missing-session')); + + await expect(readManifestIfExists(path)).resolves.toBeNull(); + }); + + it('rejects invalid manifest contents during reads', async () => { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + temporaryDirectories.push(home); + const path = manifestPath(sessionDir(home, 'session-01')); + + await writeManifest(path, createSessionRecord()); + await writeFile( + path, + JSON.stringify({ + ...createSessionRecord(), + rows: 0, + }), + 'utf8', + ); + + await expect(readManifest(path)).rejects.toMatchObject({ + code: 'MANIFEST_VALIDATION_ERROR', + }); + }); +}); From c48b0eee1712a04f36c085238947ce8955ed902e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:28:09 +0000 Subject: [PATCH 02/38] Add Week 1 CLI command stubs --- src/cli/commands/create.ts | 24 +++++ src/cli/commands/destroy.ts | 22 ++++ src/cli/commands/inspect.ts | 27 +++++ src/cli/commands/list.ts | 25 +++++ src/cli/commands/paste.ts | 21 ++++ src/cli/commands/resize.ts | 23 ++++ src/cli/commands/send-keys.ts | 21 ++++ src/cli/commands/signal.ts | 22 ++++ src/cli/commands/type.ts | 21 ++++ src/cli/commands/wait.ts | 24 +++++ src/cli/main.ts | 195 +++++++++++++++++++++++++++++++++- 11 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands/create.ts create mode 100644 src/cli/commands/destroy.ts create mode 100644 src/cli/commands/inspect.ts create mode 100644 src/cli/commands/list.ts create mode 100644 src/cli/commands/paste.ts create mode 100644 src/cli/commands/resize.ts create mode 100644 src/cli/commands/send-keys.ts create mode 100644 src/cli/commands/signal.ts create mode 100644 src/cli/commands/type.ts create mode 100644 src/cli/commands/wait.ts diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts new file mode 100644 index 0000000..a34808e --- /dev/null +++ b/src/cli/commands/create.ts @@ -0,0 +1,24 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface CreateResult { + sessionId: string; +} + +interface CommandOptions { + json: boolean; + command: string[]; + shellCommand: string; + cwd: string; + cols: number; + rows: number; +} + +export async function runCreateCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'create command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/destroy.ts b/src/cli/commands/destroy.ts new file mode 100644 index 0000000..a726c6a --- /dev/null +++ b/src/cli/commands/destroy.ts @@ -0,0 +1,22 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface DestroyResult { + sessionId: string; + destroyed: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + force: boolean; +} + +export async function runDestroyCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'destroy command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts new file mode 100644 index 0000000..24610e1 --- /dev/null +++ b/src/cli/commands/inspect.ts @@ -0,0 +1,27 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface InspectResult { + session: { + sessionId: string; + status: string; + command: string[]; + createdAt: string; + exitedAt?: string; + exitCode?: number; + }; +} + +interface CommandOptions { + json: boolean; + sessionId: string; +} + +export async function runInspectCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'inspect command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts new file mode 100644 index 0000000..28eb56c --- /dev/null +++ b/src/cli/commands/list.ts @@ -0,0 +1,25 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface ListResult { + sessions: Array<{ + sessionId: string; + status: string; + command: string[]; + createdAt: string; + }>; +} + +interface CommandOptions { + json: boolean; + all: boolean; +} + +export async function runListCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'list command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts new file mode 100644 index 0000000..7f44634 --- /dev/null +++ b/src/cli/commands/paste.ts @@ -0,0 +1,21 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface PasteResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + text: string; +} + +export async function runPasteCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'paste command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts new file mode 100644 index 0000000..cca5ee2 --- /dev/null +++ b/src/cli/commands/resize.ts @@ -0,0 +1,23 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface ResizeResult { + cols: number; + rows: number; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + cols: number; + rows: number; +} + +export async function runResizeCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'resize command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts new file mode 100644 index 0000000..fdb5b0a --- /dev/null +++ b/src/cli/commands/send-keys.ts @@ -0,0 +1,21 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface SendKeysResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + keys: string[]; +} + +export async function runSendKeysCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'send-keys command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts new file mode 100644 index 0000000..63ade97 --- /dev/null +++ b/src/cli/commands/signal.ts @@ -0,0 +1,22 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface SignalResult { + signal: string; + delivered: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + signal: string; +} + +export async function runSignalCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'signal command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts new file mode 100644 index 0000000..d939840 --- /dev/null +++ b/src/cli/commands/type.ts @@ -0,0 +1,21 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface TypeResult { + [key: string]: never; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + text: string; +} + +export async function runTypeCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'type command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts new file mode 100644 index 0000000..14a41ac --- /dev/null +++ b/src/cli/commands/wait.ts @@ -0,0 +1,24 @@ +import { CliError } from '../errors.js'; +import { emitSuccess } from '../output.js'; + +export interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +interface CommandOptions { + json: boolean; + sessionId: string; + waitForExit: boolean; + idleMs: number | undefined; + timeout: number | undefined; +} + +export async function runWaitCommand(options: CommandOptions): Promise { + void emitSuccess; + await Promise.resolve(); + + throw new CliError('NOT_IMPLEMENTED', 'wait command is not yet implemented', { + details: { options }, + }); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a0d3142..c2db0fb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,12 +1,28 @@ #!/usr/bin/env node +import process from 'node:process'; + import { Command } from 'commander'; -import { CliError } from './errors.js'; +import { runCreateCommand } from './commands/create.js'; +import { runDestroyCommand } from './commands/destroy.js'; import { runDoctorCommand } from './commands/doctor.js'; +import { runInspectCommand } from './commands/inspect.js'; +import { runListCommand } from './commands/list.js'; +import { runPasteCommand } from './commands/paste.js'; +import { runResizeCommand } from './commands/resize.js'; +import { runSendKeysCommand } from './commands/send-keys.js'; +import { runSignalCommand } from './commands/signal.js'; +import { runTypeCommand } from './commands/type.js'; import { runVersionCommand } from './commands/version.js'; +import { runWaitCommand } from './commands/wait.js'; +import { CliError } from './errors.js'; import { emitFailure } from './output.js'; +function parseIntegerOption(value: string): number { + return Number.parseInt(value, 10); +} + async function main(): Promise { const program = new Command() .name('agent-terminal') @@ -29,6 +45,183 @@ async function main(): Promise { await runDoctorCommand(options); }); + // --- Session lifecycle --- + program + .command('create [command...]') + .description('Create a session') + .option( + '--command ', + 'Command string to run (defaults to the user shell)', + process.env.SHELL ?? process.env.ComSpec ?? 'sh', + ) + .option('--cwd ', 'Working directory', process.cwd()) + .option('--cols ', 'Initial columns', parseIntegerOption, 80) + .option('--rows ', 'Initial rows', parseIntegerOption, 24) + .option('--json', 'Emit a JSON command envelope', false) + .action( + async ( + command: string[], + options: { + command: string; + cwd: string; + cols: number; + rows: number; + json: boolean; + }, + ) => { + await runCreateCommand({ + json: options.json, + command, + shellCommand: options.command, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); + }, + ); + + program + .command('list') + .description('List sessions') + .option('--all', 'Include exited sessions', false) + .option('--json', 'Emit a JSON command envelope', false) + .action(async (options: { all: boolean; json: boolean }) => { + await runListCommand(options); + }); + + program + .command('inspect ') + .description('Inspect a session') + .option('--json', 'Emit a JSON command envelope', false) + .action(async (sessionId: string, options: { json: boolean }) => { + await runInspectCommand({ + json: options.json, + sessionId, + }); + }); + + program + .command('destroy ') + .description('Destroy a session') + .option('--force', 'Skip graceful shutdown', false) + .option('--json', 'Emit a JSON command envelope', false) + .action(async (sessionId: string, options: { force: boolean; json: boolean }) => { + await runDestroyCommand({ + json: options.json, + sessionId, + force: options.force, + }); + }); + + // --- Session control --- + program + .command('type ') + .description('Type text into a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + async (sessionId: string, text: string, options: { json: boolean }) => { + await runTypeCommand({ + json: options.json, + sessionId, + text, + }); + }, + ); + + program + .command('paste ') + .description('Paste text into a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + async (sessionId: string, text: string, options: { json: boolean }) => { + await runPasteCommand({ + json: options.json, + sessionId, + text, + }); + }, + ); + + program + .command('send-keys ') + .description('Send keys to a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + async ( + sessionId: string, + keys: string[], + options: { json: boolean }, + ) => { + await runSendKeysCommand({ + json: options.json, + sessionId, + keys, + }); + }, + ); + + program + .command('resize ') + .description('Resize a session') + .requiredOption('--cols ', 'Columns', parseIntegerOption) + .requiredOption('--rows ', 'Rows', parseIntegerOption) + .option('--json', 'Emit a JSON command envelope', false) + .action( + async ( + sessionId: string, + options: { cols: number; rows: number; json: boolean }, + ) => { + await runResizeCommand({ + json: options.json, + sessionId, + cols: options.cols, + rows: options.rows, + }); + }, + ); + + program + .command('signal ') + .description('Send a signal to a session') + .option('--json', 'Emit a JSON command envelope', false) + .action( + async (sessionId: string, signal: string, options: { json: boolean }) => { + await runSignalCommand({ + json: options.json, + sessionId, + signal, + }); + }, + ); + + // --- Observation --- + program + .command('wait ') + .description('Wait for a session condition') + .option('--exit', 'Wait for process exit', false) + .option('--idle-ms ', 'Wait for output idle period', parseIntegerOption) + .option('--timeout ', 'Maximum wait time in milliseconds', parseIntegerOption) + .option('--json', 'Emit a JSON command envelope', false) + .action( + async ( + sessionId: string, + options: { + exit: boolean; + idleMs?: number; + timeout?: number; + json: boolean; + }, + ) => { + await runWaitCommand({ + json: options.json, + sessionId, + waitForExit: options.exit, + idleMs: options.idleMs, + timeout: options.timeout, + }); + }, + ); + await program.parseAsync(); } From 7bc39dd37ceb34c4e606965c665853fed22eedbc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:46:16 +0000 Subject: [PATCH 03/38] Add PTY creation wrapper --- src/pty/createPty.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/pty/createPty.ts diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts new file mode 100644 index 0000000..d1a1c86 --- /dev/null +++ b/src/pty/createPty.ts @@ -0,0 +1,24 @@ +import type { IPty } from 'node-pty'; +import { spawn } from 'node-pty'; +import { invariant } from '../util/assert.js'; + +export interface PtyOptions { + command: string[]; + cwd: string; + cols: number; + rows: number; +} + +export function createPty(options: PtyOptions): IPty { + const { command, cwd, cols, rows } = options; + + invariant(command.length > 0, 'PTY command must not be empty'); + invariant(Number.isInteger(cols) && cols > 0, 'PTY cols must be a positive integer'); + invariant(Number.isInteger(rows) && rows > 0, 'PTY rows must be a positive integer'); + + return spawn(command[0]!, command.slice(1), { + cwd, + cols, + rows, + }); +} From 20c89dc7e1f0faa0be3c3e31736c26753d860ade Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:46:18 +0000 Subject: [PATCH 04/38] Add in-memory session state machine --- src/host/sessionState.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/host/sessionState.ts diff --git a/src/host/sessionState.ts b/src/host/sessionState.ts new file mode 100644 index 0000000..dacc5a6 --- /dev/null +++ b/src/host/sessionState.ts @@ -0,0 +1,73 @@ +import type { SessionRecord } from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +export class SessionState { + readonly #record: SessionRecord; + + public constructor(initialRecord: SessionRecord) { + this.#record = { + ...initialRecord, + command: [...initialRecord.command], + }; + } + + public snapshot(): SessionRecord { + return { + ...this.#record, + command: [...this.#record.command], + }; + } + + public setHostPid(pid: number): void { + invariant(this.#record.status === 'running', 'Cannot set host PID unless session is running'); + invariant(this.#record.hostPid === null, 'Host PID has already been set'); + invariant(Number.isInteger(pid) && pid > 0, 'Host PID must be a positive integer'); + + this.#record.hostPid = pid; + this.touch(); + } + + public setChildPid(pid: number): void { + invariant(this.#record.status === 'running', 'Cannot set child PID unless session is running'); + invariant(this.#record.childPid === null, 'Child PID has already been set'); + invariant(Number.isInteger(pid) && pid > 0, 'Child PID must be a positive integer'); + + this.#record.childPid = pid; + this.touch(); + } + + public requestDestroy(): void { + invariant(this.#record.status === 'running', 'Cannot request destroy unless session is running'); + + this.#record.status = 'exiting'; + this.touch(); + } + + public recordExit(exitCode: number | null, exitSignal: string | null): void { + invariant( + this.#record.status === 'running' || this.#record.status === 'exiting', + 'Cannot record exit after session has exited', + ); + invariant( + this.#record.exitCode === null && this.#record.exitSignal === null, + 'Session exit has already been recorded', + ); + invariant( + exitCode === null || Number.isInteger(exitCode), + 'Exit code must be an integer or null', + ); + invariant( + exitSignal === null || typeof exitSignal === 'string', + 'Exit signal must be a string or null', + ); + + this.#record.exitCode = exitCode; + this.#record.exitSignal = exitSignal; + this.#record.status = 'exited'; + this.touch(); + } + + private touch(): void { + this.#record.updatedAt = new Date().toISOString(); + } +} From 5ac308cac7df6a2b4ceb75bccdef80e894755f62 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:46:39 +0000 Subject: [PATCH 05/38] Add append-only event log writer --- src/host/eventLog.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/host/eventLog.ts diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts new file mode 100644 index 0000000..84b05bc --- /dev/null +++ b/src/host/eventLog.ts @@ -0,0 +1,136 @@ +import { open, readFile } from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; + +import { z } from 'zod'; + +import { + EventRecordSchema, + type EventRecord, +} from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +const OutputEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type OutputEventPayload = z.infer; + +const ExitEventPayloadSchema = z + .object({ + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(); + +type ExitEventPayload = z.infer; + +type EventLogEventType = 'output' | 'exit'; +type EventLogPayload = OutputEventPayload | ExitEventPayload; + +function assertFilePath(filePath: string): void { + invariant(filePath.length > 0, 'filePath must be a non-empty string'); +} + +function validatePayload( + type: EventLogEventType, + payload: EventLogPayload, +): EventLogPayload { + switch (type) { + case 'output': { + const result = OutputEventPayloadSchema.safeParse(payload); + invariant(result.success, 'output payload must match schema'); + return result.data; + } + case 'exit': { + const result = ExitEventPayloadSchema.safeParse(payload); + invariant(result.success, 'exit payload must match schema'); + return result.data; + } + } +} + +function deriveNextSeq(content: string): number { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return 0; + } + + const lastLine = lines.at(-1); + invariant(lastLine !== undefined, 'event log must contain a last line'); + + let parsedLine: unknown; + try { + parsedLine = JSON.parse(lastLine); + } catch { + invariant(false, 'last event log line must be valid JSON'); + } + + const parsedRecord = EventRecordSchema.safeParse(parsedLine); + invariant(parsedRecord.success, 'last event log line must match EventRecordSchema'); + + const { seq } = parsedRecord.data; + invariant(Number.isInteger(seq), 'event log seq must be an integer'); + invariant(seq >= 0, 'event log seq must be non-negative'); + + return seq + 1; +} + +export class EventLog { + private constructor( + private readonly fileHandle: FileHandle, + private nextSeq: number, + private isClosed = false, + ) { + invariant(Number.isInteger(nextSeq), 'nextSeq must be an integer'); + invariant(nextSeq >= 0, 'nextSeq must be non-negative'); + } + + static async open(filePath: string): Promise { + assertFilePath(filePath); + + const fileHandle = await open(filePath, 'a'); + const fileStats = await fileHandle.stat(); + + let nextSeq = 0; + if (fileStats.size > 0) { + const existingContent = await readFile(filePath, 'utf8'); + nextSeq = deriveNextSeq(existingContent); + } + + return new EventLog(fileHandle, nextSeq); + } + + async append(type: 'output', payload: OutputEventPayload): Promise; + async append(type: 'exit', payload: ExitEventPayload): Promise; + async append(type: EventLogEventType, payload: EventLogPayload): Promise { + invariant(!this.isClosed, 'cannot append to a closed event log'); + + const validatedPayload = validatePayload(type, payload); + const record: EventRecord = { + seq: this.nextSeq, + ts: new Date().toISOString(), + type, + payload: validatedPayload, + }; + + const parsedRecord = EventRecordSchema.safeParse(record); + invariant(parsedRecord.success, 'event record must match EventRecordSchema'); + + await this.fileHandle.appendFile(`${JSON.stringify(parsedRecord.data)}\n`, 'utf8'); + this.nextSeq += 1; + } + + async close(): Promise { + invariant(!this.isClosed, 'event log is already closed'); + + await this.fileHandle.sync(); + await this.fileHandle.close(); + this.isClosed = true; + } +} From a2871e51233bb9d4de16bc65b968f03fb78d6ce5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:49:01 +0000 Subject: [PATCH 06/38] Add RPC client and server socket transport --- src/host/rpcClient.ts | 286 +++++++++++++++++++++++++++++++++ src/host/rpcServer.ts | 364 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 650 insertions(+) create mode 100644 src/host/rpcClient.ts create mode 100644 src/host/rpcServer.ts diff --git a/src/host/rpcClient.ts b/src/host/rpcClient.ts new file mode 100644 index 0000000..a35106b --- /dev/null +++ b/src/host/rpcClient.ts @@ -0,0 +1,286 @@ +import { randomUUID } from 'node:crypto'; +import net from 'node:net'; + +import { CliError } from '../cli/errors.js'; +import { + ERROR_CODES, + makeCliError, + type ProtocolErrorCode, +} from '../protocol/errors.js'; +import { + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + type RpcMethod, +} from '../protocol/messages.js'; +import { invariant } from '../util/assert.js'; + +const DEFAULT_TIMEOUT_MS = 5_000; +const HOST_UNREACHABLE_SOCKET_CODES = new Set([ + 'ECONNREFUSED', + 'ENOENT', + 'ECONNRESET', +]); + +function isKnownRpcMethod(method: string): method is RpcMethod { + return Object.hasOwn(RpcMethodSchemas, method); +} + +function isProtocolErrorCode(code: string): code is ProtocolErrorCode { + return Object.values(ERROR_CODES).includes(code as ProtocolErrorCode); +} + +function toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return fallback; +} + +function toTransportCliError( + error: unknown, + socketPath: string, + method: string, + timeoutMs: number, +): CliError { + if (error instanceof CliError) { + return error; + } + + if (error instanceof Error && 'code' in error) { + const errorCode = typeof error.code === 'string' ? error.code : undefined; + + if ( + errorCode !== undefined && + HOST_UNREACHABLE_SOCKET_CODES.has(errorCode) + ) { + return makeCliError(ERROR_CODES.HOST_UNREACHABLE, { + message: `Failed to reach RPC host at ${socketPath}.`, + details: { + method, + socketPath, + errno: errorCode, + }, + cause: error, + }); + } + } + + return makeCliError(ERROR_CODES.RPC_ERROR, { + message: toErrorMessage( + error, + `RPC request failed for method "${method}".`, + ), + details: { + method, + socketPath, + timeoutMs, + }, + cause: error, + }); +} + +function toResponseCliError(code: string, message: string): CliError { + if (isProtocolErrorCode(code)) { + return makeCliError(code, { message }); + } + + return new CliError(code, message); +} + +export async function sendRpc( + socketPath: string, + method: string, + params?: Record, + timeoutMs?: number, +): Promise { + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; + invariant( + Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs >= 0, + 'RPC timeout must be a non-negative finite number.', + ); + + const requestResult = RpcRequestSchema.safeParse({ + id: randomUUID(), + method, + params: params ?? {}, + }); + invariant( + requestResult.success, + 'Outbound RPC request must satisfy RpcRequestSchema.', + ); + + const request = requestResult.data; + + return await new Promise((resolve, reject) => { + const socket = net.connect({ path: socketPath }); + let settled = false; + let responseHandled = false; + let buffer = ''; + + const rejectWithCliError = (error: CliError): void => { + if (settled) { + return; + } + + settled = true; + socket.destroy(); + reject(error); + }; + + const rejectWithTransportError = (error: unknown): void => { + rejectWithCliError( + toTransportCliError(error, socketPath, method, effectiveTimeoutMs), + ); + }; + + const resolveWithResult = (result: unknown): void => { + if (settled) { + return; + } + + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.setEncoding('utf8'); + socket.setTimeout(effectiveTimeoutMs); + + socket.on('connect', () => { + socket.write(`${JSON.stringify(request)}\n`); + }); + + socket.on('timeout', () => { + rejectWithCliError( + makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `RPC request timed out after ${effectiveTimeoutMs}ms.`, + details: { + method, + socketPath, + timeoutMs: effectiveTimeoutMs, + }, + }), + ); + }); + + socket.on('error', (error) => { + rejectWithTransportError(error); + }); + + socket.on('data', (chunk: string) => { + if (responseHandled) { + return; + } + + buffer += chunk; + const newlineIndex = buffer.indexOf('\n'); + + if (newlineIndex < 0) { + return; + } + + responseHandled = true; + const line = buffer.slice(0, newlineIndex); + + try { + const rawResponse = JSON.parse(line) as unknown; + const responseResult = RpcResponseSchema.safeParse(rawResponse); + + if (!responseResult.success) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: 'RPC response failed schema validation.', + details: { + method, + socketPath, + }, + cause: responseResult.error, + }), + ); + return; + } + + const response = responseResult.data; + + if (response.id !== request.id) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC response id mismatch for method "${method}".`, + details: { + method, + socketPath, + expectedId: request.id, + actualId: response.id, + }, + }), + ); + return; + } + + if (response.ok) { + if (isKnownRpcMethod(method)) { + const resultResult = RpcMethodSchemas[method].result.safeParse( + response.result, + ); + + if (!resultResult.success) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC result failed validation for method "${method}".`, + details: { + method, + socketPath, + }, + cause: resultResult.error, + }), + ); + return; + } + + resolveWithResult(resultResult.data); + return; + } + + resolveWithResult(response.result); + return; + } + + rejectWithCliError( + toResponseCliError(response.error.code, response.error.message), + ); + } catch (error) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: toErrorMessage( + error, + `Failed to decode RPC response for method "${method}".`, + ), + details: { + method, + socketPath, + }, + cause: error, + }), + ); + } + }); + + socket.on('end', () => { + if (settled || responseHandled) { + return; + } + + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: `RPC connection closed before a complete response was received for method "${method}".`, + details: { + method, + socketPath, + }, + }), + ); + }); + }); +} diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts new file mode 100644 index 0000000..b54be10 --- /dev/null +++ b/src/host/rpcServer.ts @@ -0,0 +1,364 @@ +import { stat, unlink } from 'node:fs/promises'; +import net from 'node:net'; + +import { ERROR_CODES } from '../protocol/errors.js'; +import { + RpcMethodSchemas, + RpcRequestSchema, + RpcResponseSchema, + type RpcMethod, + type RpcResponse, +} from '../protocol/messages.js'; +import { invariant } from '../util/assert.js'; + +const UNKNOWN_REQUEST_ID = 'unknown'; + +export type MethodHandler = (params: unknown) => Promise; + +function isKnownRpcMethod(method: string): method is RpcMethod { + return Object.hasOwn(RpcMethodSchemas, method); +} + +function toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return fallback; +} + +async function socketPathExists(socketPath: string): Promise { + try { + await stat(socketPath); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +async function probeSocketLiveness(socketPath: string): Promise { + return await new Promise((resolve, reject) => { + const probe = net.connect({ path: socketPath }); + + probe.once('connect', () => { + probe.end(); + resolve(true); + }); + + probe.once('error', (error: NodeJS.ErrnoException) => { + probe.destroy(); + + if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') { + resolve(false); + return; + } + + reject(error); + }); + }); +} + +async function unlinkIfPresent(socketPath: string): Promise { + try { + await unlink(socketPath); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return; + } + + throw error; + } +} + +function extractRequestId(value: unknown): string { + if (value !== null && typeof value === 'object' && 'id' in value) { + const id = value.id; + + if (typeof id === 'string' && id.length > 0) { + return id; + } + } + + return UNKNOWN_REQUEST_ID; +} + +function buildErrorResponse(id: string, message: string): RpcResponse { + const response = { + id, + ok: false, + error: { + code: ERROR_CODES.RPC_ERROR, + message, + }, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC error response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + +function buildSuccessResponse(id: string, result: unknown): RpcResponse { + const response = { + id, + ok: true, + result, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC success response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + +export class RpcServer { + private readonly socketPath: string; + private readonly handlers: Readonly>; + private server: net.Server | null = null; + private closePromise: Promise | null = null; + + public constructor(socketPath: string, handlers: Record) { + invariant(socketPath.length > 0, 'RPC socket path must not be empty.'); + + this.socketPath = socketPath; + this.handlers = handlers; + } + + public async listen(): Promise { + invariant(this.server === null, 'RPC server is already listening.'); + + await this.removeStaleSocketIfNeeded(); + invariant( + !(await socketPathExists(this.socketPath)), + `RPC socket path must not exist before listen(): ${this.socketPath}`, + ); + + const server = net.createServer((socket) => { + this.handleConnection(socket); + }); + + server.on('error', () => { + // Keep server errors from becoming unhandled events after listen(). + }); + + this.server = server; + + try { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + reject(error); + }; + + server.once('error', onError); + server.listen(this.socketPath, () => { + server.off('error', onError); + resolve(); + }); + }); + } catch (error) { + this.server = null; + throw error; + } + } + + public async close(): Promise { + if (this.closePromise !== null) { + await this.closePromise; + return; + } + + const server = this.server; + this.server = null; + + if (server === null) { + await unlinkIfPresent(this.socketPath); + return; + } + + this.closePromise = (async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + + resolve(); + }); + }); + + await unlinkIfPresent(this.socketPath); + })(); + + try { + await this.closePromise; + } finally { + this.closePromise = null; + } + } + + private async removeStaleSocketIfNeeded(): Promise { + if (!(await socketPathExists(this.socketPath))) { + return; + } + + const socketIsLive = await probeSocketLiveness(this.socketPath); + invariant( + !socketIsLive, + `RPC socket already has a live listener: ${this.socketPath}`, + ); + + await unlinkIfPresent(this.socketPath); + } + + private handleConnection(socket: net.Socket): void { + socket.setEncoding('utf8'); + + let buffer = ''; + let handled = false; + + socket.on('error', () => { + socket.destroy(); + }); + + socket.on('data', (chunk: string) => { + if (handled) { + return; + } + + buffer += chunk; + const newlineIndex = buffer.indexOf('\n'); + + if (newlineIndex < 0) { + return; + } + + handled = true; + const line = buffer.slice(0, newlineIndex); + void this.processRequestLine(socket, line); + }); + + socket.on('end', () => { + if (handled) { + return; + } + + handled = true; + this.sendResponse( + socket, + buildErrorResponse( + UNKNOWN_REQUEST_ID, + buffer.length === 0 + ? 'RPC request ended before any data was received.' + : 'RPC request was not newline-delimited.', + ), + ); + }); + } + + private async processRequestLine( + socket: net.Socket, + line: string, + ): Promise { + let rawRequest: unknown; + + try { + rawRequest = JSON.parse(line) as unknown; + } catch (error) { + this.sendResponse( + socket, + buildErrorResponse( + UNKNOWN_REQUEST_ID, + toErrorMessage(error, 'Failed to parse RPC request JSON.'), + ), + ); + return; + } + + const requestResult = RpcRequestSchema.safeParse(rawRequest); + + if (!requestResult.success) { + this.sendResponse( + socket, + buildErrorResponse( + extractRequestId(rawRequest), + requestResult.error.message, + ), + ); + return; + } + + const request = requestResult.data; + const params = request.params ?? {}; + + if (!Object.hasOwn(this.handlers, request.method) || !isKnownRpcMethod(request.method)) { + this.sendResponse( + socket, + buildErrorResponse( + request.id, + `Unsupported method: ${request.method}`, + ), + ); + return; + } + + const handler = this.handlers[request.method]; + invariant( + typeof handler === 'function', + `RPC handler for method "${request.method}" must be a function.`, + ); + + const paramsResult = RpcMethodSchemas[request.method].params.safeParse(params); + + if (!paramsResult.success) { + this.sendResponse( + socket, + buildErrorResponse(request.id, paramsResult.error.message), + ); + return; + } + + try { + const result = await handler(paramsResult.data); + const resultResult = RpcMethodSchemas[request.method].result.safeParse( + result, + ); + + if (!resultResult.success) { + this.sendResponse( + socket, + buildErrorResponse(request.id, resultResult.error.message), + ); + return; + } + + this.sendResponse( + socket, + buildSuccessResponse(request.id, resultResult.data), + ); + } catch (error) { + this.sendResponse( + socket, + buildErrorResponse( + request.id, + toErrorMessage( + error, + `RPC handler failed for method "${request.method}".`, + ), + ), + ); + } + } + + private sendResponse(socket: net.Socket, response: RpcResponse): void { + socket.end(`${JSON.stringify(response)}\n`); + } +} From 28d323a4929c21218b15b5696e9e7d32f6288eb1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:56:10 +0000 Subject: [PATCH 07/38] Add session host entrypoint --- src/cli/main.ts | 11 ++- src/host/hostMain.ts | 185 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/host/hostMain.ts diff --git a/src/cli/main.ts b/src/cli/main.ts index c2db0fb..e037c19 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -222,6 +222,14 @@ async function main(): Promise { }, ); + program + .command('_host ', { hidden: true }) + .description('Internal: run the session host process') + .action(async (sessionId: string) => { + const { runHost } = await import('../host/hostMain.js'); + await runHost(sessionId); + }); + await program.parseAsync(); } @@ -229,9 +237,10 @@ try { await main(); } catch (error: unknown) { if (error instanceof CliError) { + const json = process.argv.includes('--json'); emitFailure({ command: 'agent-terminal', - json: false, + json, error: { code: error.code, message: error.message, diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts new file mode 100644 index 0000000..214130d --- /dev/null +++ b/src/host/hostMain.ts @@ -0,0 +1,185 @@ +import process from 'node:process'; + +import { EventLog } from './eventLog.js'; +import { RpcServer, type MethodHandler } from './rpcServer.js'; +import { SessionState } from './sessionState.js'; +import { createPty } from '../pty/createPty.js'; +import { readManifest, writeManifest } from '../storage/manifests.js'; +import { resolveHome } from '../storage/home.js'; +import { + eventLogPath, + manifestPath, + sessionDir, + socketPath, +} from '../storage/sessionPaths.js'; +import { invariant } from '../util/assert.js'; + +function normalizeExitSignal(signal: number | null): string | null { + invariant( + signal === null || (Number.isInteger(signal) && signal >= 0), + 'PTY exit signal must be a non-negative integer or null', + ); + + return signal === null || signal === 0 ? null : String(signal); +} + +function isSessionRunning(state: SessionState): boolean { + return state.snapshot().status === 'running'; +} + +function rethrowAsync(error: unknown): void { + process.nextTick(() => { + throw error; + }); +} + +export async function runHost(sessionId: string): Promise { + invariant(typeof sessionId === 'string' && sessionId.length > 0, 'sessionId must be a non-empty string'); + + const home = resolveHome(); + const sessDir = sessionDir(home, sessionId); + const mPath = manifestPath(sessDir); + const ePath = eventLogPath(sessDir); + const sPath = socketPath(sessDir); + + const manifest = await readManifest(mPath); + invariant(manifest.sessionId === sessionId, 'session manifest sessionId must match the requested session'); + + const state = new SessionState(manifest); + invariant(Number.isInteger(process.pid) && process.pid > 0, 'process.pid must be a positive integer'); + state.setHostPid(process.pid); + + const eventLog = await EventLog.open(ePath); + + let eventLogClosed = false; + let ptyExitHandled = false; + let ptyHasExited = false; + let rpcListenPromise: Promise | null = null; + let shutdownPromise: Promise | null = null; + let markPtyExited: () => void = () => { + invariant(false, 'PTY exit resolver must be initialized'); + }; + + const ptyExitPromise = new Promise((resolve) => { + markPtyExited = (): void => { + if (ptyHasExited) { + return; + } + + ptyHasExited = true; + resolve(); + }; + }); + + const pty = createPty({ + command: manifest.command, + cwd: manifest.cwd, + cols: manifest.cols, + rows: manifest.rows, + }); + + invariant(Number.isInteger(pty.pid) && pty.pid > 0, 'PTY child PID must be a positive integer'); + state.setChildPid(pty.pid); + + const initiateShutdown = (): Promise => { + if (shutdownPromise !== null) { + return shutdownPromise; + } + + shutdownPromise = (async () => { + try { + if (isSessionRunning(state)) { + pty.kill(); + state.requestDestroy(); + await writeManifest(mPath, state.snapshot()); + } + } finally { + try { + await ptyExitPromise; + } finally { + if (rpcListenPromise !== null) { + await rpcListenPromise.catch(() => undefined); + } + + if (!eventLogClosed) { + await eventLog.close(); + eventLogClosed = true; + } + + await rpcServer.close(); + } + } + })(); + + return shutdownPromise; + }; + + const startShutdown = (): void => { + void initiateShutdown().catch(rethrowAsync); + }; + + const handlePtyExit = (exitCode: number, signal: number | null): void => { + invariant(!ptyExitHandled, 'PTY exit must only be handled once'); + invariant(Number.isInteger(exitCode), 'PTY exit code must be an integer'); + + ptyExitHandled = true; + + const exitSignal = normalizeExitSignal(signal); + state.recordExit(exitCode, exitSignal); + markPtyExited(); + + void (async () => { + try { + await eventLog.append('exit', { exitCode, exitSignal }); + } finally { + try { + await writeManifest(mPath, state.snapshot()); + } finally { + await initiateShutdown(); + } + } + })().catch(rethrowAsync); + }; + + const handlers: Record = { + inspect: () => Promise.resolve({ session: state.snapshot() }), + destroy: () => { + startShutdown(); + return Promise.resolve({}); + }, + }; + const rpcServer = new RpcServer(sPath, handlers); + + pty.onData((data: string) => { + void eventLog.append('output', { data }).catch(() => { + // Best-effort logging; shutdown should not fail on transient append errors. + }); + }); + + pty.onExit(({ exitCode, signal }) => { + handlePtyExit(exitCode, signal ?? null); + }); + + process.on('SIGTERM', () => { + startShutdown(); + }); + + try { + await writeManifest(mPath, state.snapshot()); + + if (!isSessionRunning(state)) { + await initiateShutdown(); + return; + } + + rpcListenPromise = rpcServer.listen(); + await rpcListenPromise; + + if (!isSessionRunning(state)) { + await initiateShutdown(); + } + } catch (error) { + await initiateShutdown().catch(() => undefined); + throw error; + } +} From 4212c47e8c7de37aac711a83b8e070c8eb1ab19f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 16:56:23 +0000 Subject: [PATCH 08/38] Implement host lifecycle helpers --- src/host/lifecycle.ts | 473 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 src/host/lifecycle.ts diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts new file mode 100644 index 0000000..c99c77a --- /dev/null +++ b/src/host/lifecycle.ts @@ -0,0 +1,473 @@ +import { spawn } from 'node:child_process'; +import { mkdir, readdir, stat, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { ulid } from 'ulid'; + +import { CliError } from '../cli/errors.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { SessionRecord } from '../protocol/schemas.js'; +import { ensureHome, resolveHome } from '../storage/home.js'; +import { readManifest, readManifestIfExists, writeManifest } from '../storage/manifests.js'; +import { manifestPath, sessionDir, socketPath } from '../storage/sessionPaths.js'; +import { invariant } from '../util/assert.js'; +import { sendRpc } from './rpcClient.js'; + +const DESTROY_POLL_INTERVAL_MS = 100; +const DESTROY_MAX_ATTEMPTS = 50; + +interface NodeError extends Error { + code?: string; +} + +export interface AllocateConfig { + command: string[]; + shellCommand: string; + cwd: string; + cols: number; + rows: number; +} + +export interface AllocateResult { + sessionId: string; + sessionDirectory: string; +} + +export interface SessionSummary { + sessionId: string; + status: string; + command: string[]; + createdAt: string; +} + +function isNodeError(error: unknown): error is NodeError { + return error instanceof Error; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isNodeError(error) && error.code === code; +} + +function assertPositiveInteger(value: number, label: string): void { + invariant(Number.isInteger(value) && value > 0, `${label} must be a positive integer`); +} + +function assertNonEmptyString( + value: unknown, + label: string, +): asserts value is string { + invariant(typeof value === 'string', `${label} must be a string`); + invariant(value.length > 0, `${label} must not be empty`); +} + +function isSessionTerminal(record: SessionRecord): boolean { + return record.status === 'exited'; +} + +function isSessionActive(record: SessionRecord): boolean { + return record.status === 'running' || record.status === 'exiting'; +} + +function isProcessAlive(pid: number | null): boolean { + if (pid === null) { + return false; + } + + assertPositiveInteger(pid, 'pid'); + + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return false; + } + + throw error; + } +} + +function killProcessBestEffort(pid: number | null): void { + if (pid === null) { + return; + } + + assertPositiveInteger(pid, 'pid'); + + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return; + } + + throw error; + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + + throw error; + } +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return; + } + + throw error; + } +} + +function getSessionPaths(sessionId: string): { + sessionDirectory: string; + manifestFile: string; + socketFile: string; +} { + assertNonEmptyString(sessionId, 'sessionId'); + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, sessionId); + + return { + sessionDirectory, + manifestFile: manifestPath(sessionDirectory), + socketFile: socketPath(sessionDirectory), + }; +} + +async function readSessionManifestOrThrow( + sessionId: string, + manifestFile: string, +): Promise { + const manifest = await readManifestIfExists(manifestFile); + + if (manifest !== null) { + return manifest; + } + + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${sessionId}" was not found.`, + details: { + sessionId, + manifestPath: manifestFile, + }, + }); +} + +async function waitForTerminalManifest( + manifestFile: string, + maxAttempts: number = DESTROY_MAX_ATTEMPTS, + intervalMs: number = DESTROY_POLL_INTERVAL_MS, +): Promise { + invariant( + Number.isInteger(maxAttempts) && maxAttempts > 0, + 'maxAttempts must be a positive integer', + ); + invariant( + Number.isInteger(intervalMs) && intervalMs >= 0, + 'intervalMs must be a non-negative integer', + ); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const manifest = await readManifest(manifestFile); + + if (isSessionTerminal(manifest)) { + return manifest; + } + + if (attempt + 1 < maxAttempts) { + await delay(intervalMs); + } + } + + return null; +} + +async function waitForProcessAndSocketShutdown( + hostPid: number | null, + childPid: number | null, + socketFile: string, + maxAttempts: number = DESTROY_MAX_ATTEMPTS, + intervalMs: number = DESTROY_POLL_INTERVAL_MS, +): Promise { + invariant( + Number.isInteger(maxAttempts) && maxAttempts > 0, + 'maxAttempts must be a positive integer', + ); + invariant( + Number.isInteger(intervalMs) && intervalMs >= 0, + 'intervalMs must be a non-negative integer', + ); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const hostAlive = isProcessAlive(hostPid); + const childAlive = isProcessAlive(childPid); + const socketPresent = await pathExists(socketFile); + + if (!hostAlive && !childAlive && !socketPresent) { + return true; + } + + if (attempt + 1 < maxAttempts) { + await delay(intervalMs); + } + } + + return false; +} + +export async function allocateSession( + config: AllocateConfig, +): Promise { + const rawConfig: unknown = config; + invariant(rawConfig !== null && typeof rawConfig === 'object', 'config must be an object'); + invariant(Array.isArray(config.command), 'command must be an array'); + assertNonEmptyString(config.cwd, 'cwd'); + assertPositiveInteger(config.cols, 'cols'); + assertPositiveInteger(config.rows, 'rows'); + + const sessionId = ulid(); + assertNonEmptyString(sessionId, 'sessionId'); + + const home = await ensureHome(); + const sessionDirectory = sessionDir(home, sessionId); + await mkdir(sessionDirectory, { recursive: true }); + + const resolvedCwd = resolve(config.cwd); + const cwdStats = await stat(resolvedCwd); + invariant(cwdStats.isDirectory(), 'cwd must resolve to an existing directory'); + + const effectiveCommand = + config.command.length > 0 ? [...config.command] : [config.shellCommand]; + invariant(effectiveCommand.length > 0, 'effective command must not be empty'); + for (const commandPart of effectiveCommand) { + assertNonEmptyString(commandPart, 'command segment'); + } + + const now = new Date().toISOString(); + await writeManifest(manifestPath(sessionDirectory), { + version: 1, + sessionId, + createdAt: now, + updatedAt: now, + status: 'running', + command: effectiveCommand, + cwd: resolvedCwd, + cols: config.cols, + rows: config.rows, + hostPid: null, + childPid: null, + exitCode: null, + exitSignal: null, + }); + + return { sessionId, sessionDirectory }; +} + +export function launchHost(sessionId: string): number { + assertNonEmptyString(sessionId, 'sessionId'); + invariant(process.execPath.length > 0, 'process.execPath must not be empty'); + + const entrypoint = process.argv[1]; + invariant( + typeof entrypoint === 'string' && entrypoint.length > 0, + 'CLI entrypoint path must be defined', + ); + + const child = spawn( + process.execPath, + [...process.execArgv, entrypoint, '_host', sessionId], + { + detached: true, + stdio: 'ignore', + }, + ); + child.unref(); + + invariant( + child.pid !== undefined && child.pid > 0, + 'Detached host process must expose a positive PID', + ); + + return child.pid; +} + +export async function destroySession( + sessionId: string, + force?: boolean, +): Promise { + const { sessionDirectory, manifestFile, socketFile } = getSessionPaths(sessionId); + const manifest = await readSessionManifestOrThrow(sessionId, manifestFile); + + if (isSessionTerminal(manifest)) { + return; + } + + if (force === true) { + killProcessBestEffort(manifest.childPid); + killProcessBestEffort(manifest.hostPid); + + await waitForProcessAndSocketShutdown( + manifest.hostPid, + manifest.childPid, + socketFile, + ); + await reconcileSession(sessionDirectory); + + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out forcing session "${sessionId}" to exit.`, + details: { + sessionId, + sessionDirectory, + }, + }); + } + + try { + await sendRpc(socketFile, 'destroy'); + } catch (error) { + if (!(error instanceof CliError) || error.code !== ERROR_CODES.HOST_UNREACHABLE) { + throw error; + } + + await reconcileSession(sessionDirectory); + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw error; + } + + const terminalManifest = await waitForTerminalManifest(manifestFile); + if (terminalManifest !== null) { + return; + } + + await reconcileSession(sessionDirectory); + const reconciledManifest = await readSessionManifestOrThrow(sessionId, manifestFile); + if (isSessionTerminal(reconciledManifest)) { + return; + } + + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out waiting for session "${sessionId}" to exit after destroy request.`, + details: { + sessionId, + sessionDirectory, + }, + }); +} + +export async function listSessions( + home: string, + all?: boolean, +): Promise { + assertNonEmptyString(home, 'home'); + + const sessionsRoot = resolve(home, 'sessions'); + let sessionEntries: string[]; + try { + sessionEntries = await readdir(sessionsRoot); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return []; + } + + throw error; + } + + const summaries: SessionSummary[] = []; + + for (const entry of sessionEntries) { + const sessionDirectory = sessionDir(home, entry); + const manifestFile = manifestPath(sessionDirectory); + + let manifest: SessionRecord | null; + try { + manifest = await readManifestIfExists(manifestFile); + } catch { + continue; + } + + if (manifest === null) { + continue; + } + + if (isSessionActive(manifest)) { + try { + await reconcileSession(sessionDirectory); + manifest = await readManifestIfExists(manifestFile); + } catch { + continue; + } + + if (manifest === null) { + continue; + } + } + + if (all !== true && manifest.status === 'exited') { + continue; + } + + summaries.push({ + sessionId: manifest.sessionId, + status: manifest.status, + command: [...manifest.command], + createdAt: manifest.createdAt, + }); + } + + return summaries; +} + +export async function reconcileSession(sessionDirectory: string): Promise { + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null || isSessionTerminal(manifest)) { + return; + } + + const hostAlive = isProcessAlive(manifest.hostPid); + if (manifest.hostPid !== null && hostAlive) { + return; + } + + const reconciledManifest: SessionRecord = { + ...manifest, + status: 'exited', + updatedAt: new Date().toISOString(), + hostPid: null, + childPid: null, + }; + + await writeManifest(manifestFile, reconciledManifest); + await unlinkIfPresent(socketPath(sessionDirectory)); +} From e891b9339ee95059bf032ae407f169649cf900ee Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:03:22 +0000 Subject: [PATCH 09/38] Wire lifecycle CLI commands --- src/cli/commands/create.ts | 82 ++++++++- src/cli/commands/destroy.ts | 15 +- src/cli/commands/inspect.ts | 73 ++++++-- src/cli/commands/list.ts | 18 +- test/integration/lifecycle.test.ts | 257 +++++++++++++++++++++++++++++ 5 files changed, 419 insertions(+), 26 deletions(-) create mode 100644 test/integration/lifecycle.test.ts diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index a34808e..503f10c 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -1,5 +1,21 @@ +import { setTimeout as delay } from 'node:timers/promises'; + import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { + allocateSession, + launchHost, + reconcileSession, +} from '../../host/lifecycle.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { resolveHome } from '../../storage/home.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; + +const READINESS_POLL_INTERVAL_MS = 100; +const READINESS_MAX_ATTEMPTS = 50; +const READINESS_RPC_TIMEOUT_MS = 100; export interface CreateResult { sessionId: string; @@ -15,10 +31,68 @@ interface CommandOptions { } export async function runCreateCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const { sessionId } = await allocateSession({ + command: options.command, + shellCommand: options.shellCommand, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); + + launchHost(sessionId); + + const home = resolveHome(); + const sessionDirectory = sessionDir(home, sessionId); + const socketFile = socketPath(sessionDirectory); + let lastError: CliError | null = null; + + for (let attempt = 0; attempt < READINESS_MAX_ATTEMPTS; attempt += 1) { + try { + await sendRpc(socketFile, 'inspect', undefined, READINESS_RPC_TIMEOUT_MS); + emitSuccess({ + command: 'create', + json: options.json, + result: { sessionId }, + lines: [`Session created: ${sessionId}`], + }); + return; + } catch (error) { + if ( + error instanceof CliError && + (error.code === ERROR_CODES.HOST_UNREACHABLE || + error.code === ERROR_CODES.HOST_TIMEOUT) + ) { + const manifest = await readManifestIfExists(manifestPath(sessionDirectory)); + if (manifest?.status === 'exited') { + emitSuccess({ + command: 'create', + json: options.json, + result: { sessionId }, + lines: [`Session created: ${sessionId}`], + }); + return; + } + + lastError = error; + if (attempt + 1 < READINESS_MAX_ATTEMPTS) { + await delay(READINESS_POLL_INTERVAL_MS); + continue; + } + } + + throw error; + } + } + + await reconcileSession(sessionDirectory).catch(() => undefined); - throw new CliError('NOT_IMPLEMENTED', 'create command is not yet implemented', { - details: { options }, + throw makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: `Timed out waiting for session "${sessionId}" to become ready.`, + details: { + sessionId, + sessionDirectory, + causeCode: lastError?.code, + }, + cause: lastError, }); } diff --git a/src/cli/commands/destroy.ts b/src/cli/commands/destroy.ts index a726c6a..dce51eb 100644 --- a/src/cli/commands/destroy.ts +++ b/src/cli/commands/destroy.ts @@ -1,5 +1,5 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { destroySession } from '../../host/lifecycle.js'; export interface DestroyResult { sessionId: string; @@ -13,10 +13,15 @@ interface CommandOptions { } export async function runDestroyCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + await destroySession(options.sessionId, options.force); - throw new CliError('NOT_IMPLEMENTED', 'destroy command is not yet implemented', { - details: { options }, + emitSuccess({ + command: 'destroy', + json: options.json, + result: { + sessionId: options.sessionId, + destroyed: true, + }, + lines: [`Session destroyed: ${options.sessionId}`], }); } diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 24610e1..3570108 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -1,15 +1,16 @@ +import type { SessionRecord } from '../../protocol/schemas.js'; + import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { reconcileSession } from '../../host/lifecycle.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifest, readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface InspectResult { - session: { - sessionId: string; - status: string; - command: string[]; - createdAt: string; - exitedAt?: string; - exitCode?: number; - }; + session: SessionRecord; } interface CommandOptions { @@ -17,11 +18,59 @@ interface CommandOptions { sessionId: string; } +function formatSessionLines(session: SessionRecord): string[] { + return [ + `Session ID: ${session.sessionId}`, + `Status: ${session.status}`, + `Command: ${session.command.join(' ')}`, + `Working Directory: ${session.cwd}`, + `Size: ${session.cols}x${session.rows}`, + `Created At: ${session.createdAt}`, + `Updated At: ${session.updatedAt}`, + `Host PID: ${session.hostPid ?? '-'}`, + `Child PID: ${session.childPid ?? '-'}`, + `Exit Code: ${session.exitCode ?? '-'}`, + `Exit Signal: ${session.exitSignal ?? '-'}`, + ]; +} + export async function runInspectCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + let session = await readManifestIfExists(manifestFile); + + if (session === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (session.status !== 'exited') { + try { + const liveResult = (await sendRpc(socketPath(sessionDirectory), 'inspect')) as InspectResult; + session = liveResult.session; + } catch (error) { + if ( + error instanceof CliError && + error.code === ERROR_CODES.HOST_UNREACHABLE + ) { + await reconcileSession(sessionDirectory); + session = await readManifest(manifestFile); + } else { + throw error; + } + } + } - throw new CliError('NOT_IMPLEMENTED', 'inspect command is not yet implemented', { - details: { options }, + emitSuccess({ + command: 'inspect', + json: options.json, + result: { session }, + lines: formatSessionLines(session), }); } diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 28eb56c..d2637bd 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -1,5 +1,6 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { listSessions } from '../../host/lifecycle.js'; +import { resolveHome } from '../../storage/home.js'; export interface ListResult { sessions: Array<{ @@ -16,10 +17,17 @@ interface CommandOptions { } export async function runListCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessions = await listSessions(home, options.all); + const lines = sessions.map( + (session) => + `${session.sessionId} ${session.status} ${session.command.join(' ')}`, + ); - throw new CliError('NOT_IMPLEMENTED', 'list command is not yet implemented', { - details: { options }, + emitSuccess({ + command: 'list', + json: options.json, + result: { sessions }, + lines, }); } diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts new file mode 100644 index 0000000..a349044 --- /dev/null +++ b/test/integration/lifecycle.test.ts @@ -0,0 +1,257 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface SuccessEnvelope { + ok: true; + command: string; + result: TResult; +} + +interface ErrorEnvelope { + ok: false; + command: string; + error: { + code: string; + message: string; + retryable: boolean; + details: Record; + }; +} + +interface SessionSummary { + sessionId: string; + status: string; + command: string[]; + createdAt: string; +} + +interface SessionRecord extends SessionSummary { + version: 1; + updatedAt: string; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; +} + +interface EventRecord { + seq: number; + ts: string; + type: string; + payload: { + data?: string; + exitCode?: number; + }; +} + +function runCli( + args: string[], + env?: Record, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 15000, + }, + ); + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +async function cleanupHome(home: string): Promise { + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const raw = await readFile(manifestFile, 'utf8'); + const manifest = JSON.parse(raw) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + } catch {} + } + } catch {} + + await rm(home, { recursive: true, force: true }); +} + +let testHome = ''; + +describe('lifecycle integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('full lifecycle: create → list → inspect → destroy', () => { + const createResult = runCli( + ['create', '--json', '--', '/bin/sh', '-c', 'echo ready; sleep 30'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const createEnvelope = JSON.parse(createResult.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; + expect(createEnvelope.ok).toBe(true); + const sessionId = createEnvelope.result.sessionId; + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + + const listResult = runCli(['list', '--json'], { AGENT_TERMINAL_HOME: testHome }); + expect(listResult.status).toBe(0); + expect(listResult.stderr).toBe(''); + const listEnvelope = JSON.parse(listResult.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }>; + expect(listEnvelope.ok).toBe(true); + expect(listEnvelope.result.sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId, status: 'running' }), + ]), + ); + + const inspectResult = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(inspectResult.status).toBe(0); + expect(inspectResult.stderr).toBe(''); + const inspectEnvelope = JSON.parse(inspectResult.stdout) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(inspectEnvelope.ok).toBe(true); + expect(inspectEnvelope.result.session.sessionId).toBe(sessionId); + expect(inspectEnvelope.result.session.status).toBe('running'); + expect(inspectEnvelope.result.session.hostPid).toBeTypeOf('number'); + expect(inspectEnvelope.result.session.childPid).toBeTypeOf('number'); + + const destroyResult = runCli(['destroy', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + expect(destroyResult.stderr).toBe(''); + const destroyEnvelope = JSON.parse(destroyResult.stdout) as SuccessEnvelope<{ + sessionId: string; + destroyed: boolean; + }>; + expect(destroyEnvelope.ok).toBe(true); + expect(destroyEnvelope.result.destroyed).toBe(true); + }); + + it('exited sessions hidden by default list, visible with --all', () => { + const createResult = runCli( + ['create', '--json', '--', '/bin/sh', '-c', 'echo done; sleep 30'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + const destroyResult = runCli(['destroy', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + expect(destroyResult.stderr).toBe(''); + + const listDefault = runCli(['list', '--json'], { AGENT_TERMINAL_HOME: testHome }); + expect(listDefault.status).toBe(0); + expect(listDefault.stderr).toBe(''); + const defaultSessions = ( + JSON.parse(listDefault.stdout) as SuccessEnvelope<{ sessions: SessionSummary[] }> + ).result.sessions; + expect( + defaultSessions.find((session) => session.sessionId === sessionId), + ).toBeUndefined(); + + const listAll = runCli(['list', '--all', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(listAll.status).toBe(0); + expect(listAll.stderr).toBe(''); + const allSessions = ( + JSON.parse(listAll.stdout) as SuccessEnvelope<{ sessions: SessionSummary[] }> + ).result.sessions; + expect(allSessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId, status: 'exited' }), + ]), + ); + }); + + it('inspect nonexistent session returns SESSION_NOT_FOUND', () => { + const result = runCli(['inspect', 'NONEXISTENT', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.error.code).toBe('SESSION_NOT_FOUND'); + }); + + it('event log contains output and exit records', async () => { + const createResult = runCli( + ['create', '--json', '--', '/bin/sh', '-c', 'echo marker-test-output; exit 0'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const eventContent = await readFile(eventsPath, 'utf8'); + const events = eventContent + .trim() + .split('\n') + .map((line) => JSON.parse(line) as EventRecord); + + const outputEvents = events.filter((event) => event.type === 'output'); + expect(outputEvents.length).toBeGreaterThan(0); + + const allOutput = outputEvents + .map((event) => event.payload.data ?? '') + .join(''); + expect(allOutput).toContain('marker-test-output'); + + const exitEvents = events.filter((event) => event.type === 'exit'); + expect(exitEvents.length).toBe(1); + expect(exitEvents[0]?.payload.exitCode).toBe(0); + + const destroyResult = runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(destroyResult.status).toBe(0); + }); +}); From 30973c6f750a1f55a90b5f7df1b80caf3184a859 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:06:08 +0000 Subject: [PATCH 10/38] Fix reported lint errors --- src/cli/commands/inspect.ts | 8 ++++---- src/host/rpcClient.ts | 2 +- src/pty/createPty.ts | 5 ++++- test/integration/lifecycle.test.ts | 12 +++++++++--- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 3570108..836409b 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -24,12 +24,12 @@ function formatSessionLines(session: SessionRecord): string[] { `Status: ${session.status}`, `Command: ${session.command.join(' ')}`, `Working Directory: ${session.cwd}`, - `Size: ${session.cols}x${session.rows}`, + `Size: ${String(session.cols)}x${String(session.rows)}`, `Created At: ${session.createdAt}`, `Updated At: ${session.updatedAt}`, - `Host PID: ${session.hostPid ?? '-'}`, - `Child PID: ${session.childPid ?? '-'}`, - `Exit Code: ${session.exitCode ?? '-'}`, + `Host PID: ${String(session.hostPid ?? '-')}`, + `Child PID: ${String(session.childPid ?? '-')}`, + `Exit Code: ${String(session.exitCode ?? '-')}`, `Exit Signal: ${session.exitSignal ?? '-'}`, ]; } diff --git a/src/host/rpcClient.ts b/src/host/rpcClient.ts index a35106b..7bdbb40 100644 --- a/src/host/rpcClient.ts +++ b/src/host/rpcClient.ts @@ -155,7 +155,7 @@ export async function sendRpc( socket.on('timeout', () => { rejectWithCliError( makeCliError(ERROR_CODES.HOST_TIMEOUT, { - message: `RPC request timed out after ${effectiveTimeoutMs}ms.`, + message: `RPC request timed out after ${String(effectiveTimeoutMs)}ms.`, details: { method, socketPath, diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts index d1a1c86..c41db29 100644 --- a/src/pty/createPty.ts +++ b/src/pty/createPty.ts @@ -16,7 +16,10 @@ export function createPty(options: PtyOptions): IPty { invariant(Number.isInteger(cols) && cols > 0, 'PTY cols must be a positive integer'); invariant(Number.isInteger(rows) && rows > 0, 'PTY rows must be a positive integer'); - return spawn(command[0]!, command.slice(1), { + const file = command[0]; + invariant(file !== undefined, 'PTY command must have an executable'); + + return spawn(file, command.slice(1), { cwd, cols, rows, diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index a349044..e697b5c 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -86,12 +86,18 @@ async function cleanupHome(home: string): Promise { if (typeof pid === 'number' && pid > 0) { try { process.kill(pid, 'SIGKILL'); - } catch {} + } catch { + // best-effort cleanup, ignore errors + } } } - } catch {} + } catch { + // best-effort cleanup, ignore errors + } } - } catch {} + } catch { + // best-effort cleanup, ignore errors + } await rm(home, { recursive: true, force: true }); } From a34fcc4db5bd24e9ad21dbec5f4ba9c0f5033d2e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:22:35 +0000 Subject: [PATCH 11/38] Add PTY key and paste encoders --- src/pty/keyEncoder.ts | 287 ++++++++++++++++++++++++++++++++++++++++ src/pty/pasteEncoder.ts | 8 ++ 2 files changed, 295 insertions(+) create mode 100644 src/pty/keyEncoder.ts create mode 100644 src/pty/pasteEncoder.ts diff --git a/src/pty/keyEncoder.ts b/src/pty/keyEncoder.ts new file mode 100644 index 0000000..284c356 --- /dev/null +++ b/src/pty/keyEncoder.ts @@ -0,0 +1,287 @@ +import { invariant } from '../util/assert.js'; + +interface Modifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; +} + +interface CsiFinalKeySpec { + unmodified: string; + final: string; +} + +const SIMPLE_KEY_ENCODINGS = { + enter: '\r', + tab: '\t', + escape: '\x1b', + backspace: '\x7f', +} as const; + +const CSI_FINAL_KEY_ENCODINGS: Record = { + up: { unmodified: '\x1b[A', final: 'A' }, + down: { unmodified: '\x1b[B', final: 'B' }, + right: { unmodified: '\x1b[C', final: 'C' }, + left: { unmodified: '\x1b[D', final: 'D' }, + home: { unmodified: '\x1b[H', final: 'H' }, + end: { unmodified: '\x1b[F', final: 'F' }, + f1: { unmodified: '\x1bOP', final: 'P' }, + f2: { unmodified: '\x1bOQ', final: 'Q' }, + f3: { unmodified: '\x1bOR', final: 'R' }, + f4: { unmodified: '\x1bOS', final: 'S' }, +}; + +const CSI_TILDE_KEY_ENCODINGS: Record = { + insert: 2, + delete: 3, + pageup: 5, + pagedown: 6, + f5: 15, + f6: 17, + f7: 18, + f8: 19, + f9: 20, + f10: 21, + f11: 23, + f12: 24, +}; + +const PRINTABLE_ASCII = /^[\x20-\x7e]$/; +const ASCII_LETTER = /^[A-Za-z]$/; + +export function encodeKey(keyName: string): string { + invariant(typeof keyName === 'string', 'Key name must be a string'); + + const { baseKey, modifiers } = parseKeyName(keyName); + const lowerBaseKey = baseKey.toLowerCase(); + + if (lowerBaseKey === 'space') { + return encodePrintableCharacter(' ', modifiers, baseKey); + } + + if (Object.hasOwn(SIMPLE_KEY_ENCODINGS, lowerBaseKey)) { + const simpleKey = + SIMPLE_KEY_ENCODINGS[lowerBaseKey as keyof typeof SIMPLE_KEY_ENCODINGS]; + return encodeSimpleKey(lowerBaseKey, simpleKey, modifiers); + } + + const csiFinalKey = CSI_FINAL_KEY_ENCODINGS[lowerBaseKey]; + if (csiFinalKey !== undefined) { + return encodeCsiFinalKey(csiFinalKey, modifiers); + } + + const csiTildeKeyCode = CSI_TILDE_KEY_ENCODINGS[lowerBaseKey]; + if (csiTildeKeyCode !== undefined) { + return encodeCsiTildeKey(csiTildeKeyCode, modifiers); + } + + if (baseKey.length === 1 && PRINTABLE_ASCII.test(baseKey)) { + return encodePrintableCharacter(baseKey, modifiers, baseKey); + } + + invariant(false, `Unknown base key: ${baseKey}`); +} + +function parseKeyName(keyName: string): { + baseKey: string; + modifiers: Modifiers; +} { + const trimmedKeyName = keyName.trim(); + invariant(trimmedKeyName.length > 0, 'Key name must not be empty'); + + const tokens = trimmedKeyName.split('+').map((token) => token.trim()); + invariant(tokens.length > 0, 'Key name must contain a base key'); + + const baseKey = tokens.at(-1); + invariant( + baseKey !== undefined && baseKey.length > 0, + 'Key name must contain a base key', + ); + + const modifiers: Modifiers = { + ctrl: false, + alt: false, + shift: false, + }; + + for (const token of tokens.slice(0, -1)) { + invariant(token.length > 0, `Invalid key token in ${keyName}`); + + const lowerToken = token.toLowerCase(); + invariant(lowerToken in modifiers, `Unknown modifier: ${token}`); + + const modifier = lowerToken as keyof Modifiers; + invariant(!modifiers[modifier], `Duplicate modifier: ${token}`); + modifiers[modifier] = true; + } + + invariant( + !(baseKey.toLowerCase() in modifiers), + `Missing base key in ${keyName}`, + ); + + return { baseKey, modifiers }; +} + +function encodeSimpleKey( + baseKey: string, + sequence: string, + modifiers: Modifiers, +): string { + if (!hasModifiers(modifiers)) { + return sequence; + } + + if ( + baseKey === 'tab' && + modifiers.shift && + !modifiers.ctrl && + !modifiers.alt + ) { + return '\x1b[Z'; + } + + if (modifiers.alt && !modifiers.ctrl && !modifiers.shift) { + return `\x1b${sequence}`; + } + + invariant( + false, + `Unsupported modifier combination for ${baseKey}: ${formatModifiers(modifiers)}`, + ); +} + +function encodeCsiFinalKey( + keySpec: CsiFinalKeySpec, + modifiers: Modifiers, +): string { + if (!hasModifiers(modifiers)) { + return keySpec.unmodified; + } + + const modifierParameter = String(getModifierParameter(modifiers)); + return `\x1b[1;${modifierParameter}${keySpec.final}`; +} + +function encodeCsiTildeKey(keyCode: number, modifiers: Modifiers): string { + const keyCodeText = String(keyCode); + + if (!hasModifiers(modifiers)) { + return `\x1b[${keyCodeText}~`; + } + + const modifierParameter = String(getModifierParameter(modifiers)); + return `\x1b[${keyCodeText};${modifierParameter}~`; +} + +function encodePrintableCharacter( + character: string, + modifiers: Modifiers, + displayKey: string, +): string { + invariant( + character.length === 1, + `Printable key must be a single character: ${displayKey}`, + ); + invariant( + PRINTABLE_ASCII.test(character), + `Unsupported printable key: ${displayKey}`, + ); + + if (!hasModifiers(modifiers)) { + return character; + } + + if (modifiers.ctrl) { + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + } + + const controlCharacter = getControlCharacter(character); + invariant( + controlCharacter !== undefined, + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + + return modifiers.alt ? `\x1b${controlCharacter}` : controlCharacter; + } + + if (modifiers.alt) { + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + return `\x1b${character.toUpperCase()}`; + } + + return `\x1b${character}`; + } + + if (modifiers.shift) { + invariant( + ASCII_LETTER.test(character), + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); + return character.toUpperCase(); + } + + invariant( + false, + `Unsupported modifier combination for ${displayKey}: ${formatModifiers(modifiers)}`, + ); +} + +function getControlCharacter(character: string): string | undefined { + if (ASCII_LETTER.test(character)) { + return String.fromCharCode(character.toUpperCase().charCodeAt(0) - 64); + } + + switch (character) { + case ' ': + return '\x00'; + case '@': + return '\x00'; + case '[': + return '\x1b'; + case '\\': + return '\x1c'; + case ']': + return '\x1d'; + case '^': + return '\x1e'; + case '_': + return '\x1f'; + case '?': + return '\x7f'; + default: + return undefined; + } +} + +function hasModifiers(modifiers: Modifiers): boolean { + return modifiers.ctrl || modifiers.alt || modifiers.shift; +} + +function getModifierParameter(modifiers: Modifiers): number { + invariant( + hasModifiers(modifiers), + 'Modifier parameter requires at least one modifier', + ); + + return ( + 1 + + Number(modifiers.shift) + + Number(modifiers.alt) * 2 + + Number(modifiers.ctrl) * 4 + ); +} + +function formatModifiers(modifiers: Modifiers): string { + return ['ctrl', 'alt', 'shift'] + .filter((modifier) => modifiers[modifier as keyof Modifiers]) + .join('+'); +} diff --git a/src/pty/pasteEncoder.ts b/src/pty/pasteEncoder.ts new file mode 100644 index 0000000..329f67e --- /dev/null +++ b/src/pty/pasteEncoder.ts @@ -0,0 +1,8 @@ +import { invariant } from '../util/assert.js'; + +export function encodePaste(text: string): string { + invariant(typeof text === 'string', 'Paste text must be a string'); + invariant(text.length > 0, 'Paste text must not be empty'); + + return `\x1b[200~${text}\x1b[201~`; +} From 1c162cae206e2be2229207e0737ea1255520f7cd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:21:17 +0000 Subject: [PATCH 12/38] Prepare host PTY control plumbing --- src/host/eventLog.ts | 89 +++++++++++++++++++++++++++++++++++++++- src/host/rpcServer.ts | 35 ++++++++++++---- src/host/sessionState.ts | 10 +++++ 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 84b05bc..d61c382 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -17,6 +17,47 @@ const OutputEventPayloadSchema = z type OutputEventPayload = z.infer; +const InputTextEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type InputTextEventPayload = z.infer; + +const InputPasteEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); + +type InputPasteEventPayload = z.infer; + +const InputKeysEventPayloadSchema = z + .object({ + keys: z.array(z.string().min(1)).min(1), + }) + .strict(); + +type InputKeysEventPayload = z.infer; + +const ResizeEventPayloadSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); + +type ResizeEventPayload = z.infer; + +const SignalEventPayloadSchema = z + .object({ + signal: z.string().min(1), + }) + .strict(); + +type SignalEventPayload = z.infer; + const ExitEventPayloadSchema = z .object({ exitCode: z.number().int().nullable(), @@ -26,8 +67,22 @@ const ExitEventPayloadSchema = z type ExitEventPayload = z.infer; -type EventLogEventType = 'output' | 'exit'; -type EventLogPayload = OutputEventPayload | ExitEventPayload; +type EventLogEventType = + | 'output' + | 'input_text' + | 'input_paste' + | 'input_keys' + | 'resize' + | 'signal' + | 'exit'; +type EventLogPayload = + | OutputEventPayload + | InputTextEventPayload + | InputPasteEventPayload + | InputKeysEventPayload + | ResizeEventPayload + | SignalEventPayload + | ExitEventPayload; function assertFilePath(filePath: string): void { invariant(filePath.length > 0, 'filePath must be a non-empty string'); @@ -43,6 +98,31 @@ function validatePayload( invariant(result.success, 'output payload must match schema'); return result.data; } + case 'input_text': { + const result = InputTextEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_text payload must match schema'); + return result.data; + } + case 'input_paste': { + const result = InputPasteEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_paste payload must match schema'); + return result.data; + } + case 'input_keys': { + const result = InputKeysEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_keys payload must match schema'); + return result.data; + } + case 'resize': { + const result = ResizeEventPayloadSchema.safeParse(payload); + invariant(result.success, 'resize payload must match schema'); + return result.data; + } + case 'signal': { + const result = SignalEventPayloadSchema.safeParse(payload); + invariant(result.success, 'signal payload must match schema'); + return result.data; + } case 'exit': { const result = ExitEventPayloadSchema.safeParse(payload); invariant(result.success, 'exit payload must match schema'); @@ -107,6 +187,11 @@ export class EventLog { } async append(type: 'output', payload: OutputEventPayload): Promise; + async append(type: 'input_text', payload: InputTextEventPayload): Promise; + async append(type: 'input_paste', payload: InputPasteEventPayload): Promise; + async append(type: 'input_keys', payload: InputKeysEventPayload): Promise; + async append(type: 'resize', payload: ResizeEventPayload): Promise; + async append(type: 'signal', payload: SignalEventPayload): Promise; async append(type: 'exit', payload: ExitEventPayload): Promise; async append(type: EventLogEventType, payload: EventLogPayload): Promise { invariant(!this.isClosed, 'cannot append to a closed event log'); diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index b54be10..13740ca 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -1,6 +1,7 @@ import { stat, unlink } from 'node:fs/promises'; import net from 'node:net'; +import { CliError } from '../cli/errors.js'; import { ERROR_CODES } from '../protocol/errors.js'; import { RpcMethodSchemas, @@ -104,6 +105,24 @@ function buildErrorResponse(id: string, message: string): RpcResponse { return responseResult.data; } +function buildCliErrorResponse(id: string, error: CliError): RpcResponse { + const response = { + id, + ok: false, + error: { + code: error.code, + message: error.message, + }, + } as const; + const responseResult = RpcResponseSchema.safeParse(response); + invariant( + responseResult.success, + 'RPC CliError response must satisfy RpcResponseSchema.', + ); + + return responseResult.data; +} + function buildSuccessResponse(id: string, result: unknown): RpcResponse { const response = { id, @@ -347,13 +366,15 @@ export class RpcServer { } catch (error) { this.sendResponse( socket, - buildErrorResponse( - request.id, - toErrorMessage( - error, - `RPC handler failed for method "${request.method}".`, - ), - ), + error instanceof CliError + ? buildCliErrorResponse(request.id, error) + : buildErrorResponse( + request.id, + toErrorMessage( + error, + `RPC handler failed for method "${request.method}".`, + ), + ), ); } } diff --git a/src/host/sessionState.ts b/src/host/sessionState.ts index dacc5a6..cdb89a3 100644 --- a/src/host/sessionState.ts +++ b/src/host/sessionState.ts @@ -36,6 +36,16 @@ export class SessionState { this.touch(); } + public setDimensions(cols: number, rows: number): void { + invariant(this.#record.status === 'running', 'Cannot set dimensions unless session is running'); + invariant(Number.isInteger(cols) && cols > 0, 'Columns must be a positive integer'); + invariant(Number.isInteger(rows) && rows > 0, 'Rows must be a positive integer'); + + this.#record.cols = cols; + this.#record.rows = rows; + this.touch(); + } + public requestDestroy(): void { invariant(this.#record.status === 'running', 'Cannot request destroy unless session is running'); From 82c60adc9dbf54145a162d104bfe8a6e3248e06f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:22:39 +0000 Subject: [PATCH 13/38] Implement CLI control commands --- src/cli/commands/paste.ts | 44 ++++++++++++-- src/cli/commands/resize.ts | 63 ++++++++++++++++++-- src/cli/commands/send-keys.ts | 44 ++++++++++++-- src/cli/commands/signal.ts | 65 ++++++++++++++++++-- src/cli/commands/type.ts | 44 ++++++++++++-- src/cli/commands/wait.ts | 108 ++++++++++++++++++++++++++++++++-- 6 files changed, 338 insertions(+), 30 deletions(-) diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index 7f44634..89a2543 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -1,5 +1,9 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface PasteResult { [key: string]: never; @@ -12,10 +16,40 @@ interface CommandOptions { } export async function runPasteCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); - throw new CliError('NOT_IMPLEMENTED', 'paste command is not yet implemented', { - details: { options }, + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'paste', { + text: options.text, + }); + + const result: PasteResult = {}; + emitSuccess({ + command: 'paste', + json: options.json, + result, + lines: ['Pasted text into session.'], }); } diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts index cca5ee2..62ff78b 100644 --- a/src/cli/commands/resize.ts +++ b/src/cli/commands/resize.ts @@ -1,5 +1,9 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface ResizeResult { cols: number; @@ -14,10 +18,59 @@ interface CommandOptions { } export async function runResizeCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); - throw new CliError('NOT_IMPLEMENTED', 'resize command is not yet implemented', { - details: { options }, + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if ( + !Number.isInteger(options.cols) || + !Number.isInteger(options.rows) || + options.cols <= 0 || + options.rows <= 0 + ) { + throw makeCliError(ERROR_CODES.INVALID_DIMENSIONS, { + message: 'Resize dimensions must be positive integers.', + details: { + cols: options.cols, + rows: options.rows, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'resize', { + cols: options.cols, + rows: options.rows, + }); + + const result: ResizeResult = { + cols: options.cols, + rows: options.rows, + }; + emitSuccess({ + command: 'resize', + json: options.json, + result, + lines: [`Resized session to ${String(options.cols)}x${String(options.rows)}.`], }); } diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts index fdb5b0a..8aabccd 100644 --- a/src/cli/commands/send-keys.ts +++ b/src/cli/commands/send-keys.ts @@ -1,5 +1,9 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface SendKeysResult { [key: string]: never; @@ -12,10 +16,40 @@ interface CommandOptions { } export async function runSendKeysCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); - throw new CliError('NOT_IMPLEMENTED', 'send-keys command is not yet implemented', { - details: { options }, + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'sendKeys', { + keys: options.keys, + }); + + const result: SendKeysResult = {}; + emitSuccess({ + command: 'send-keys', + json: options.json, + result, + lines: ['Sent keys to session.'], }); } diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts index 63ade97..e208e46 100644 --- a/src/cli/commands/signal.ts +++ b/src/cli/commands/signal.ts @@ -1,5 +1,18 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; + +const ALLOWED_SIGNALS = [ + 'SIGTERM', + 'SIGINT', + 'SIGKILL', + 'SIGHUP', + 'SIGUSR1', + 'SIGUSR2', +] as const; export interface SignalResult { signal: string; @@ -13,10 +26,52 @@ interface CommandOptions { } export async function runSignalCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if (!ALLOWED_SIGNALS.includes(options.signal as (typeof ALLOWED_SIGNALS)[number])) { + throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { + message: `Signal must be one of: ${ALLOWED_SIGNALS.join(', ')}.`, + details: { + signal: options.signal, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'signal', { + signal: options.signal, + }); - throw new CliError('NOT_IMPLEMENTED', 'signal command is not yet implemented', { - details: { options }, + const result: SignalResult = { + signal: options.signal, + delivered: true, + }; + emitSuccess({ + command: 'signal', + json: options.json, + result, + lines: [`Signal ${options.signal} delivered to session.`], }); } diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index d939840..bd3b85b 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -1,5 +1,9 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface TypeResult { [key: string]: never; @@ -12,10 +16,40 @@ interface CommandOptions { } export async function runTypeCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); - throw new CliError('NOT_IMPLEMENTED', 'type command is not yet implemented', { - details: { options }, + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + await sendRpc(socketPath(sessionDirectory), 'type', { + text: options.text, + }); + + const result: TypeResult = {}; + emitSuccess({ + command: 'type', + json: options.json, + result, + lines: ['Typed text into session.'], }); } diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index 14a41ac..9615918 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -1,5 +1,9 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; export interface WaitResult { exitCode?: number; @@ -14,11 +18,105 @@ interface CommandOptions { timeout: number | undefined; } +function isPositiveInteger(value: number | undefined): value is number { + return value !== undefined && Number.isInteger(value) && value > 0; +} + +function waitLines(result: WaitResult): string[] { + if (result.timedOut) { + return ['Wait timed out.']; + } + + if (result.exitCode !== undefined) { + return [`Process exited with code ${String(result.exitCode)}.`]; + } + + return ['Wait condition met.']; +} + export async function runWaitCommand(options: CommandOptions): Promise { - void emitSuccess; - await Promise.resolve(); + const home = resolveHome(); + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + const hasIdleMs = options.idleMs !== undefined; + if (options.waitForExit === hasIdleMs) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: 'Specify exactly one of --exit or --idle-ms.', + }); + } + + if (hasIdleMs && !isPositiveInteger(options.idleMs)) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--idle-ms must be a positive integer.', + details: { + idleMs: options.idleMs, + }, + }); + } + + if (options.timeout !== undefined && !isPositiveInteger(options.timeout)) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--timeout must be a positive integer.', + details: { + timeout: options.timeout, + }, + }); + } + + if (!options.waitForExit && manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if (options.waitForExit && manifest.status === 'exited') { + const result: WaitResult = { + timedOut: false, + ...(manifest.exitCode === null ? {} : { exitCode: manifest.exitCode }), + }; + + emitSuccess({ + command: 'wait', + json: options.json, + result, + lines: waitLines(result), + }); + return; + } + + const params = { + exit: options.waitForExit || undefined, + idleMs: options.idleMs ?? undefined, + timeoutMs: options.timeout ?? undefined, + }; + const clientTimeout = options.timeout !== undefined ? options.timeout + 5_000 : 0; + const result = (await sendRpc( + socketPath(sessionDirectory), + 'wait', + params, + clientTimeout, + )) as WaitResult; - throw new CliError('NOT_IMPLEMENTED', 'wait command is not yet implemented', { - details: { options }, + emitSuccess({ + command: 'wait', + json: options.json, + result, + lines: waitLines(result), }); } From a7f1b450abfa00628184b1ad00cfb60e95da2f4e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:27:58 +0000 Subject: [PATCH 14/38] Wire PTY control handlers in host --- src/host/hostMain.ts | 193 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 214130d..99174a8 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -4,6 +4,17 @@ import { EventLog } from './eventLog.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; import { SessionState } from './sessionState.js'; import { createPty } from '../pty/createPty.js'; +import { encodeKey } from '../pty/keyEncoder.js'; +import { encodePaste } from '../pty/pasteEncoder.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { + PasteParams, + ResizeParams, + SendKeysParams, + SignalParams, + TypeParams, + WaitParams, +} from '../protocol/messages.js'; import { readManifest, writeManifest } from '../storage/manifests.js'; import { resolveHome } from '../storage/home.js'; import { @@ -14,6 +25,13 @@ import { } from '../storage/sessionPaths.js'; import { invariant } from '../util/assert.js'; +const ALLOWED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGKILL', 'SIGHUP', 'SIGUSR1', 'SIGUSR2'] as const; + +type WaitOutcome = { + exitCode?: number; + timedOut: boolean; +}; + function normalizeExitSignal(signal: number | null): string | null { invariant( signal === null || (Number.isInteger(signal) && signal >= 0), @@ -54,6 +72,7 @@ export async function runHost(sessionId: string): Promise { let eventLogClosed = false; let ptyExitHandled = false; let ptyHasExited = false; + let lastOutputAt = Date.now(); let rpcListenPromise: Promise | null = null; let shutdownPromise: Promise | null = null; let markPtyExited: () => void = () => { @@ -143,6 +162,179 @@ export async function runHost(sessionId: string): Promise { const handlers: Record = { inspect: () => Promise.resolve({ session: state.snapshot() }), + type: async (params: unknown) => { + const { text } = params as TypeParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + invariant(typeof text === 'string', 'type text must be a string'); + pty.write(text); + await eventLog.append('input_text', { data: text }); + return {}; + }, + paste: async (params: unknown) => { + const { text } = params as PasteParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + invariant(typeof text === 'string' && text.length > 0, 'paste text must be a non-empty string'); + const encoded = encodePaste(text); + pty.write(encoded); + await eventLog.append('input_paste', { data: encoded }); + return {}; + }, + sendKeys: async (params: unknown) => { + const { keys } = params as SendKeysParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + invariant(Array.isArray(keys) && keys.length > 0, 'keys must be a non-empty array'); + + let encoded: string; + try { + encoded = keys.map((key) => encodeKey(key)).join(''); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_KEYS, { + message: error instanceof Error ? error.message : 'Invalid key sequence.', + cause: error, + }); + } + + pty.write(encoded); + await eventLog.append('input_keys', { keys }); + return {}; + }, + resize: async (params: unknown) => { + const { cols, rows } = params as ResizeParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + invariant(Number.isInteger(cols) && cols > 0, 'cols must be a positive integer'); + invariant(Number.isInteger(rows) && rows > 0, 'rows must be a positive integer'); + + pty.resize(cols, rows); + state.setDimensions(cols, rows); + await writeManifest(mPath, state.snapshot()); + await eventLog.append('resize', { cols, rows }); + return {}; + }, + signal: async (params: unknown) => { + const { signal } = params as SignalParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + invariant(typeof signal === 'string' && signal.length > 0, 'signal must be a non-empty string'); + + if (!ALLOWED_SIGNALS.includes(signal as (typeof ALLOWED_SIGNALS)[number])) { + throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { + message: `Invalid signal: ${signal}. Allowed: ${ALLOWED_SIGNALS.join(', ')}`, + details: { signal, allowed: [...ALLOWED_SIGNALS] }, + }); + } + + const childPid = state.snapshot().childPid; + invariant(childPid !== null && childPid > 0, 'child PID must be set for signal delivery'); + process.kill(childPid, signal as (typeof ALLOWED_SIGNALS)[number]); + + await eventLog.append('signal', { signal }); + return {}; + }, + wait: async (params: unknown) => { + const { exit, idleMs, timeoutMs } = params as WaitParams; + const hasExit = exit === true; + const hasIdle = idleMs !== undefined; + + if (hasExit === hasIdle) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: 'Specify exactly one of exit or idleMs.', + }); + } + + if (hasIdle) { + invariant(Number.isInteger(idleMs) && idleMs > 0, 'idleMs must be a positive integer'); + } + if (timeoutMs !== undefined) { + invariant(Number.isInteger(timeoutMs) && timeoutMs > 0, 'timeoutMs must be a positive integer'); + } + + let waitCondition: Promise; + let clearWaitCondition: (() => void) | null = null; + + if (hasExit) { + if (ptyHasExited) { + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + return result; + } + + waitCondition = ptyExitPromise.then(() => { + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + return result; + }); + } else { + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + } + + const idleDuration = idleMs ?? 0; + invariant(Number.isInteger(idleDuration) && idleDuration > 0, 'idleMs must be a positive integer'); + + waitCondition = new Promise((resolve) => { + const checkInterval = setInterval(() => { + const elapsed = Date.now() - lastOutputAt; + if (elapsed < idleDuration) { + return; + } + + clearInterval(checkInterval); + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + resolve(result); + }, Math.min(idleDuration / 2, 100)); + + clearWaitCondition = (): void => { + clearInterval(checkInterval); + }; + }); + } + + if (timeoutMs === undefined) { + return await waitCondition; + } + + return await new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + clearWaitCondition?.(); + resolve({ timedOut: true }); + }, timeoutMs); + + void waitCondition.then((result) => { + clearTimeout(timeoutHandle); + clearWaitCondition?.(); + resolve(result); + }); + }); + }, destroy: () => { startShutdown(); return Promise.resolve({}); @@ -151,6 +343,7 @@ export async function runHost(sessionId: string): Promise { const rpcServer = new RpcServer(sPath, handlers); pty.onData((data: string) => { + lastOutputAt = Date.now(); void eventLog.append('output', { data }).catch(() => { // Best-effort logging; shutdown should not fail on transient append errors. }); From 6c5436cc53f94bf348e6ca61e5980d6f869d0bd3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:36:19 +0000 Subject: [PATCH 15/38] Add PTY control integration tests --- test/integration/event-log.test.ts | 224 +++++++++++++++++++++ test/integration/io-loop.test.ts | 295 ++++++++++++++++++++++++++++ test/integration/pty-basics.test.ts | 274 ++++++++++++++++++++++++++ test/unit/pty/keyEncoder.test.ts | 145 ++++++++++++++ 4 files changed, 938 insertions(+) create mode 100644 test/integration/event-log.test.ts create mode 100644 test/integration/io-loop.test.ts create mode 100644 test/integration/pty-basics.test.ts create mode 100644 test/unit/pty/keyEncoder.test.ts diff --git a/test/integration/event-log.test.ts b/test/integration/event-log.test.ts new file mode 100644 index 0000000..91657fe --- /dev/null +++ b/test/integration/event-log.test.ts @@ -0,0 +1,224 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface SuccessEnvelope { + ok: true; + command: string; + result: TResult; +} + +interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +function runCli( + args: string[], + env?: Record, + timeout = 15000, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout, + }, + ); + + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +async function cleanupHome(home: string): Promise { + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const raw = await readFile(manifestFile, 'utf8'); + const manifest = JSON.parse(raw) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +function createSession( + testHome: string, + command: string[] = ['/bin/sh', '-c', 'exec cat'], +): string { + const result = runCli(['create', '--json', '--', ...command], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + expect(envelope.ok).toBe(true); + return envelope.result.sessionId; +} + +async function readEvents(testHome: string, sessionId: string): Promise { + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +function destroySession(testHome: string, sessionId: string): void { + if (sessionId.length === 0) { + return; + } + + runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function runMixedActionSequence(testHome: string, sessionId: string): void { + const env = { AGENT_TERMINAL_HOME: testHome }; + + const typeResult = runCli(['type', sessionId, 'hello', '--json'], env); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], env); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const pasteResult = runCli(['paste', sessionId, 'paste-text', '--json'], env); + expect(pasteResult.status).toBe(0); + expect(pasteResult.stderr).toBe(''); + + const resizeResult = runCli( + ['resize', sessionId, '--cols', '100', '--rows', '30', '--json'], + env, + ); + expect(resizeResult.status).toBe(0); + expect(resizeResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '500', '--timeout', '5000', '--json'], + env, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); +} + +let testHome = ''; + +describe('event-log integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('mixed action sequence has monotonic seq', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + runMixedActionSequence(testHome, sessionId); + await sleep(300); + + const events = await readEvents(testHome, sessionId); + expect(events.length).toBeGreaterThan(0); + expect(events.map((event) => event.seq)).toEqual(events.map((_, index) => index)); + + const eventTypes = new Set(events.map((event) => event.type)); + expect([...eventTypes]).toEqual( + expect.arrayContaining([ + 'input_text', + 'input_keys', + 'input_paste', + 'resize', + 'output', + ]), + ); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('all event records validate against expected structure', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + runMixedActionSequence(testHome, sessionId); + await sleep(300); + + const events = await readEvents(testHome, sessionId); + expect(events.length).toBeGreaterThan(0); + + for (const event of events) { + expect(typeof event.seq).toBe('number'); + expect(Number.isInteger(event.seq)).toBe(true); + expect(event.seq).toBeGreaterThanOrEqual(0); + expect(typeof event.ts).toBe('string'); + expect(new Date(event.ts).toISOString()).toBe(event.ts); + expect(typeof event.type).toBe('string'); + expect(event.type.length).toBeGreaterThan(0); + expect(typeof event.payload).toBe('object'); + expect(event.payload).not.toBeNull(); + expect(Array.isArray(event.payload)).toBe(false); + } + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts new file mode 100644 index 0000000..4105140 --- /dev/null +++ b/test/integration/io-loop.test.ts @@ -0,0 +1,295 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface SuccessEnvelope { + ok: true; + command: string; + result: TResult; +} + +interface SessionRecord { + version: 1; + sessionId: string; + status: string; + command: string[]; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; + createdAt: string; + updatedAt: string; +} + +interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +function runCli( + args: string[], + env?: Record, + timeout = 15000, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout, + }, + ); + + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +async function cleanupHome(home: string): Promise { + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const raw = await readFile(manifestFile, 'utf8'); + const manifest = JSON.parse(raw) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +function createSession( + testHome: string, + command: string[] = ['/bin/sh', '-c', 'exec cat'], +): string { + const result = runCli(['create', '--json', '--', ...command], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + expect(envelope.ok).toBe(true); + return envelope.result.sessionId; +} + +async function readEvents(testHome: string, sessionId: string): Promise { + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +function inspectSession(testHome: string, sessionId: string): SessionRecord { + const result = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.session; +} + +function destroySession(testHome: string, sessionId: string): void { + if (sessionId.length === 0) { + return; + } + + runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +let testHome = ''; + +describe('io-loop integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('type + send-keys Enter + wait --idle-ms produces output', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec /bin/sh']); + await sleep(500); + + const typeResult = runCli(['type', sessionId, 'echo test-marker', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '500', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const allOutput = events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + expect(allOutput).toContain('test-marker'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('signal SIGTERM terminates session', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const signalResult = runCli(['signal', sessionId, 'SIGTERM', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(signalResult.status).toBe(0); + expect(signalResult.stderr).toBe(''); + const signalEnvelope = JSON.parse(signalResult.stdout) as SuccessEnvelope<{ + signal: string; + delivered: boolean; + }>; + expect(signalEnvelope.ok).toBe(true); + expect(signalEnvelope.result).toEqual({ signal: 'SIGTERM', delivered: true }); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const waitEnvelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + + await sleep(300); + + const session = inspectSession(testHome, sessionId); + expect(session.status).toBe('exited'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --exit returns exit code for a short-lived command', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exit 42']); + await sleep(700); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.exitCode).toBe(42); + expect(envelope.result.timedOut).toBe(false); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('wait --exit returns for an already-exited session', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exit 0']); + await sleep(700); + + const session = inspectSession(testHome, sessionId); + expect(session.status).toBe('exited'); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.exitCode).toBe(0); + expect(envelope.result.timedOut).toBe(false); + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/integration/pty-basics.test.ts b/test/integration/pty-basics.test.ts new file mode 100644 index 0000000..43d267c --- /dev/null +++ b/test/integration/pty-basics.test.ts @@ -0,0 +1,274 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface SuccessEnvelope { + ok: true; + command: string; + result: TResult; +} + +interface SessionRecord { + version: 1; + sessionId: string; + status: string; + command: string[]; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; + createdAt: string; + updatedAt: string; +} + +interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +function runCli( + args: string[], + env?: Record, + timeout = 15000, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout, + }, + ); + + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +async function cleanupHome(home: string): Promise { + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const raw = await readFile(manifestFile, 'utf8'); + const manifest = JSON.parse(raw) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +function createSession( + testHome: string, + command: string[] = ['/bin/sh', '-c', 'exec cat'], +): string { + const result = runCli(['create', '--json', '--', ...command], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + expect(envelope.ok).toBe(true); + return envelope.result.sessionId; +} + +async function readEvents(testHome: string, sessionId: string): Promise { + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +function inspectSession(testHome: string, sessionId: string): SessionRecord { + const result = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.session; +} + +function destroySession(testHome: string, sessionId: string): void { + if (sessionId.length === 0) { + return; + } + + runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +let testHome = ''; + +describe('pty-basics integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-home-')); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('type writes and records input_text', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const typeResult = runCli(['type', sessionId, 'hello', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + const envelope = JSON.parse(typeResult.stdout) as SuccessEnvelope>; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputTextEvents = events.filter((event) => event.type === 'input_text'); + expect(inputTextEvents.length).toBeGreaterThan(0); + expect(inputTextEvents[0]?.payload.data).toBe('hello'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('send-keys Enter records input_keys', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + const envelope = JSON.parse(sendKeysResult.stdout) as SuccessEnvelope>; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputKeyEvents = events.filter((event) => event.type === 'input_keys'); + expect(inputKeyEvents.length).toBeGreaterThan(0); + expect(inputKeyEvents[0]?.payload.keys).toEqual(['Enter']); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('paste records input_paste with bracketed paste markers', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const pasteResult = runCli(['paste', sessionId, 'test-text', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(pasteResult.status).toBe(0); + expect(pasteResult.stderr).toBe(''); + const envelope = JSON.parse(pasteResult.stdout) as SuccessEnvelope>; + expect(envelope.ok).toBe(true); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const inputPasteEvents = events.filter((event) => event.type === 'input_paste'); + expect(inputPasteEvents.length).toBeGreaterThan(0); + + const data = inputPasteEvents[0]?.payload.data; + expect(typeof data).toBe('string'); + expect(data).toContain('\u001b[200~'); + expect(data).toContain('test-text'); + expect(data).toContain('\u001b[201~'); + } finally { + destroySession(testHome, sessionId); + } + }); + + it('resize records resize and inspect reflects new dimensions', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome); + await sleep(500); + + const resizeResult = runCli( + ['resize', sessionId, '--cols', '120', '--rows', '40', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + ); + expect(resizeResult.status).toBe(0); + expect(resizeResult.stderr).toBe(''); + const envelope = JSON.parse(resizeResult.stdout) as SuccessEnvelope<{ + cols: number; + rows: number; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result).toEqual({ cols: 120, rows: 40 }); + + await sleep(300); + + const events = await readEvents(testHome, sessionId); + const resizeEvents = events.filter((event) => event.type === 'resize'); + expect(resizeEvents.length).toBeGreaterThan(0); + expect(resizeEvents[0]?.payload).toEqual({ cols: 120, rows: 40 }); + + const session = inspectSession(testHome, sessionId); + expect(session.cols).toBe(120); + expect(session.rows).toBe(40); + } finally { + destroySession(testHome, sessionId); + } + }); +}); diff --git a/test/unit/pty/keyEncoder.test.ts b/test/unit/pty/keyEncoder.test.ts new file mode 100644 index 0000000..d1d8d61 --- /dev/null +++ b/test/unit/pty/keyEncoder.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { encodeKey } from '../../../src/pty/keyEncoder.js'; + +describe('encodeKey', () => { + it('encodes Enter', () => { + expect(encodeKey('Enter')).toBe('\r'); + }); + + it('encodes Tab', () => { + expect(encodeKey('Tab')).toBe('\t'); + }); + + it('encodes Escape', () => { + expect(encodeKey('Escape')).toBe('\x1b'); + }); + + it('encodes Backspace', () => { + expect(encodeKey('Backspace')).toBe('\x7f'); + }); + + it('encodes Space', () => { + expect(encodeKey('Space')).toBe(' '); + }); + + it('encodes ctrl+c', () => { + expect(encodeKey('ctrl+c')).toBe('\x03'); + }); + + it('encodes ctrl+a', () => { + expect(encodeKey('ctrl+a')).toBe('\x01'); + }); + + it('encodes ctrl+z', () => { + expect(encodeKey('ctrl+z')).toBe('\x1a'); + }); + + it('encodes ctrl+C case-insensitively', () => { + expect(encodeKey('ctrl+C')).toBe('\x03'); + }); + + it('encodes alt+x', () => { + expect(encodeKey('alt+x')).toBe('\x1bx'); + }); + + it('encodes Up', () => { + expect(encodeKey('Up')).toBe('\x1b[A'); + }); + + it('encodes Down', () => { + expect(encodeKey('Down')).toBe('\x1b[B'); + }); + + it('encodes Right', () => { + expect(encodeKey('Right')).toBe('\x1b[C'); + }); + + it('encodes Left', () => { + expect(encodeKey('Left')).toBe('\x1b[D'); + }); + + it('encodes shift+Up', () => { + expect(encodeKey('shift+Up')).toBe('\x1b[1;2A'); + }); + + it('encodes ctrl+Up', () => { + expect(encodeKey('ctrl+Up')).toBe('\x1b[1;5A'); + }); + + it('encodes ctrl+shift+Up', () => { + expect(encodeKey('ctrl+shift+Up')).toBe('\x1b[1;6A'); + }); + + it('encodes alt+Up', () => { + expect(encodeKey('alt+Up')).toBe('\x1b[1;3A'); + }); + + it('encodes F1', () => { + expect(encodeKey('F1')).toBe('\x1bOP'); + }); + + it('encodes F2', () => { + expect(encodeKey('F2')).toBe('\x1bOQ'); + }); + + it('encodes F3', () => { + expect(encodeKey('F3')).toBe('\x1bOR'); + }); + + it('encodes F4', () => { + expect(encodeKey('F4')).toBe('\x1bOS'); + }); + + it('encodes F5', () => { + expect(encodeKey('F5')).toBe('\x1b[15~'); + }); + + it('encodes F12', () => { + expect(encodeKey('F12')).toBe('\x1b[24~'); + }); + + it('encodes Home', () => { + expect(encodeKey('Home')).toBe('\x1b[H'); + }); + + it('encodes End', () => { + expect(encodeKey('End')).toBe('\x1b[F'); + }); + + it('encodes Delete', () => { + expect(encodeKey('Delete')).toBe('\x1b[3~'); + }); + + it('encodes Insert', () => { + expect(encodeKey('Insert')).toBe('\x1b[2~'); + }); + + it('encodes PageUp', () => { + expect(encodeKey('PageUp')).toBe('\x1b[5~'); + }); + + it('encodes PageDown', () => { + expect(encodeKey('PageDown')).toBe('\x1b[6~'); + }); + + it('encodes single char a', () => { + expect(encodeKey('a')).toBe('a'); + }); + + it('encodes single char Z', () => { + expect(encodeKey('Z')).toBe('Z'); + }); + + it('throws on an unknown key', () => { + expect(() => encodeKey('BOGUS')).toThrow(); + }); + + it('throws on an empty string', () => { + expect(() => encodeKey('')).toThrow(); + }); + + it('throws on a duplicate modifier', () => { + expect(() => encodeKey('ctrl+ctrl+a')).toThrow(); + }); +}); From 8d9faba254f1bee34fa07a1583f11a34063b9dfd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:45:03 +0000 Subject: [PATCH 16/38] Fix event log append sequencing race --- src/host/eventLog.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index d61c382..9a68207 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -162,6 +162,8 @@ function deriveNextSeq(content: string): number { } export class EventLog { + private writeQueue: Promise = Promise.resolve(); + private constructor( private readonly fileHandle: FileHandle, private nextSeq: number, @@ -197,8 +199,11 @@ export class EventLog { invariant(!this.isClosed, 'cannot append to a closed event log'); const validatedPayload = validatePayload(type, payload); + const seq = this.nextSeq; + this.nextSeq += 1; + const record: EventRecord = { - seq: this.nextSeq, + seq, ts: new Date().toISOString(), type, payload: validatedPayload, @@ -207,8 +212,11 @@ export class EventLog { const parsedRecord = EventRecordSchema.safeParse(record); invariant(parsedRecord.success, 'event record must match EventRecordSchema'); - await this.fileHandle.appendFile(`${JSON.stringify(parsedRecord.data)}\n`, 'utf8'); - this.nextSeq += 1; + const line = `${JSON.stringify(parsedRecord.data)}\n`; + this.writeQueue = this.writeQueue.then(() => + this.fileHandle.appendFile(line, 'utf8'), + ); + await this.writeQueue; } async close(): Promise { From cc3670dc01d1aea562d6a161b56951f1add483e9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 17:57:50 +0000 Subject: [PATCH 17/38] Add deterministic e2e fixture apps --- test/e2e/hello-prompt.test.ts | 259 ++++++++++++++++++++++++ test/e2e/helpers.ts | 170 ++++++++++++++++ test/e2e/resize-demo.test.ts | 166 +++++++++++++++ test/fixtures/apps/hello-prompt/main.ts | 66 ++++++ test/fixtures/apps/resize-demo/main.ts | 39 ++++ 5 files changed, 700 insertions(+) create mode 100644 test/e2e/hello-prompt.test.ts create mode 100644 test/e2e/helpers.ts create mode 100644 test/e2e/resize-demo.test.ts create mode 100644 test/fixtures/apps/hello-prompt/main.ts create mode 100644 test/fixtures/apps/resize-demo/main.ts diff --git a/test/e2e/hello-prompt.test.ts b/test/e2e/hello-prompt.test.ts new file mode 100644 index 0000000..7049141 --- /dev/null +++ b/test/e2e/hello-prompt.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createIsolatedHome, + DEFAULT_IDLE_MS, + DEFAULT_WAIT_TIMEOUT_MS, + fixtureCommand, + normalizeTerminalOutput, + readOutput, + runCli, + runCliJson, + type SessionRecord, + type SuccessEnvelope, +} from './helpers.js'; + +interface CreateResult { + sessionId: string; +} + +interface InspectResult { + session: SessionRecord; +} + +interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +function testEnv(home: string): Record { + return { AGENT_TERMINAL_HOME: home }; +} + +describe('hello-prompt e2e', { timeout: 30_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + const env = testEnv(testHome); + + for (const sessionId of createdSessionIds) { + runCli(['destroy', sessionId, '--force', '--json'], env); + } + + await cleanupHome(testHome); + }); + + it('full interaction flow', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + + expect(createEnvelope.ok).toBe(true); + expect(createEnvelope.command).toBe('create'); + + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.ok).toBe(true); + expect(waitForReady.command).toBe('wait'); + expect(waitForReady.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('READY> '); + + const typeEnvelope = runCliJson>>( + ['type', sessionId, 'hello world'], + env, + ); + expect(typeEnvelope.ok).toBe(true); + expect(typeEnvelope.command).toBe('type'); + expect(typeEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForEcho = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForEcho.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('ECHO: hello world\nREADY> '); + + const inspectRunning = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectRunning.ok).toBe(true); + expect(inspectRunning.command).toBe('inspect'); + expect(inspectRunning.result.session.status).toBe('running'); + expect(inspectRunning.result.session.exitCode).toBeNull(); + + const typeExitEnvelope = runCliJson>>( + ['type', sessionId, 'exit'], + env, + ); + expect(typeExitEnvelope.ok).toBe(true); + expect(typeExitEnvelope.command).toBe('type'); + expect(typeExitEnvelope.result).toEqual({}); + + const sendExitEnterEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendExitEnterEnvelope.ok).toBe(true); + expect(sendExitEnterEnvelope.command).toBe('send-keys'); + expect(sendExitEnterEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(0); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('BYE\n'); + + const inspectExited = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectExited.result.session.status).toBe('exited'); + expect(inspectExited.result.session.exitCode).toBe(0); + + const destroyEnvelope = runCliJson>( + ['destroy', sessionId, '--force'], + env, + ); + expect(destroyEnvelope.ok).toBe(true); + expect(destroyEnvelope.command).toBe('destroy'); + expect(destroyEnvelope.result.sessionId).toBe(sessionId); + expect(destroyEnvelope.result.destroyed).toBe(true); + + createdSessionIds = createdSessionIds.filter((value) => value !== sessionId); + }); + + it('paste and exit-code', () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.result.timedOut).toBe(false); + + const pasteEnvelope = runCliJson>>( + ['paste', sessionId, 'exit-code 42'], + env, + ); + expect(pasteEnvelope.ok).toBe(true); + expect(pasteEnvelope.command).toBe('paste'); + expect(pasteEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(42); + }); + + it('signal handling', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('hello-prompt')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForReady = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForReady.result.timedOut).toBe(false); + + const signalEnvelope = runCliJson>( + ['signal', sessionId, 'SIGINT'], + env, + ); + expect(signalEnvelope.ok).toBe(true); + expect(signalEnvelope.command).toBe('signal'); + expect(signalEnvelope.result).toEqual({ signal: 'SIGINT', delivered: true }); + + const waitForExit = runCliJson>( + ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(130); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('INTERRUPTED\n'); + }); +}); diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts new file mode 100644 index 0000000..466ec4f --- /dev/null +++ b/test/e2e/helpers.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +export const DEFAULT_CLI_TIMEOUT_MS = 30_000; +export const DEFAULT_IDLE_MS = 500; +export const DEFAULT_WAIT_TIMEOUT_MS = 10_000; + +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface SuccessEnvelope { + ok: true; + command: string; + timestamp: string; + result: TResult; +} + +export interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +export interface SessionRecord { + version: 1; + sessionId: string; + createdAt: string; + updatedAt: string; + status: string; + command: string[]; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; +} + +export function runCli( + args: string[], + env: Record, + timeout = DEFAULT_CLI_TIMEOUT_MS, +): CommandResult { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + env: { ...process.env, ...env }, + encoding: 'utf8', + timeout, + }, + ); + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status ?? 1, + }; +} + +function withJsonFlag(args: string[]): string[] { + const commandSeparatorIndex = args.indexOf('--'); + + if (commandSeparatorIndex === -1) { + return [...args, '--json']; + } + + return [ + ...args.slice(0, commandSeparatorIndex), + '--json', + ...args.slice(commandSeparatorIndex), + ]; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- typed JSON helper keeps call sites concise in test code. +export function runCliJson(args: string[], env: Record): TResult { + const { stdout } = runCli(withJsonFlag(args), env); + + assert(stdout.length > 0, 'expected JSON output from CLI command'); + + return JSON.parse(stdout) as TResult; +} + +export function normalizeTerminalOutput(output: string): string { + return output.replaceAll('\r\n', '\n'); +} + +export async function createIsolatedHome(): Promise { + return mkdtemp(join(tmpdir(), 'agent-terminal-e2e-home-')); +} + +export async function cleanupHome(home: string): Promise { + if (home.length === 0) { + return; + } + + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as Record< + string, + unknown + >; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +export async function readEvents(home: string, sessionId: string): Promise { + const eventsPath = join(home, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + if (content.trim().length === 0) { + return []; + } + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +export async function readOutput(home: string, sessionId: string): Promise { + const events = await readEvents(home, sessionId); + + return events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); +} + +export function fixtureCommand(appName: 'hello-prompt' | 'resize-demo'): string[] { + return ['node', '--import', 'tsx', `test/fixtures/apps/${appName}/main.ts`]; +} diff --git a/test/e2e/resize-demo.test.ts b/test/e2e/resize-demo.test.ts new file mode 100644 index 0000000..5523d27 --- /dev/null +++ b/test/e2e/resize-demo.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + createIsolatedHome, + DEFAULT_IDLE_MS, + DEFAULT_WAIT_TIMEOUT_MS, + fixtureCommand, + normalizeTerminalOutput, + readOutput, + runCli, + runCliJson, + type SessionRecord, + type SuccessEnvelope, +} from './helpers.js'; + +interface CreateResult { + sessionId: string; +} + +interface InspectResult { + session: SessionRecord; +} + +interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +function testEnv(home: string): Record { + return { AGENT_TERMINAL_HOME: home }; +} + +describe('resize-demo e2e', { timeout: 30_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + const env = testEnv(testHome); + + for (const sessionId of createdSessionIds) { + runCli(['destroy', sessionId, '--force', '--json'], env); + } + + await cleanupHome(testHome); + }); + + it('initial size and resize', async () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + [ + 'create', + '--cols', + '80', + '--rows', + '24', + '--', + ...fixtureCommand('resize-demo'), + ], + env, + ); + expect(createEnvelope.ok).toBe(true); + + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitForInitialOutput = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForInitialOutput.ok).toBe(true); + expect(waitForInitialOutput.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('SIZE: 80x24\n'); + + const resizeEnvelope = runCliJson>( + ['resize', sessionId, '--cols', '120', '--rows', '40'], + env, + ); + expect(resizeEnvelope.ok).toBe(true); + expect(resizeEnvelope.command).toBe('resize'); + expect(resizeEnvelope.result.cols).toBe(120); + expect(resizeEnvelope.result.rows).toBe(40); + + const waitForResizeOutput = runCliJson>( + [ + 'wait', + sessionId, + '--idle-ms', + String(DEFAULT_IDLE_MS), + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitForResizeOutput.result.timedOut).toBe(false); + await expect( + readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + ).resolves.toContain('SIZE: 120x40\n'); + + const typeQuitEnvelope = runCliJson>>( + ['type', sessionId, 'quit'], + env, + ); + expect(typeQuitEnvelope.ok).toBe(true); + expect(typeQuitEnvelope.command).toBe('type'); + expect(typeQuitEnvelope.result).toEqual({}); + + const sendKeysEnvelope = runCliJson>>( + ['send-keys', sessionId, 'Enter'], + env, + ); + expect(sendKeysEnvelope.ok).toBe(true); + expect(sendKeysEnvelope.command).toBe('send-keys'); + expect(sendKeysEnvelope.result).toEqual({}); + + const waitForExit = runCliJson>( + ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + env, + ); + expect(waitForExit.ok).toBe(true); + expect(waitForExit.result.timedOut).toBe(false); + expect(waitForExit.result.exitCode).toBe(0); + }); + + it('inspect reflects resize', () => { + const env = testEnv(testHome); + const createEnvelope = runCliJson>( + ['create', '--', ...fixtureCommand('resize-demo')], + env, + ); + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const resizeEnvelope = runCliJson>( + ['resize', sessionId, '--cols', '100', '--rows', '50'], + env, + ); + expect(resizeEnvelope.ok).toBe(true); + expect(resizeEnvelope.result.cols).toBe(100); + expect(resizeEnvelope.result.rows).toBe(50); + + const inspectEnvelope = runCliJson>( + ['inspect', sessionId], + env, + ); + expect(inspectEnvelope.ok).toBe(true); + expect(inspectEnvelope.command).toBe('inspect'); + expect(inspectEnvelope.result.session.status).toBe('running'); + expect(inspectEnvelope.result.session.cols).toBe(100); + expect(inspectEnvelope.result.session.rows).toBe(50); + }); +}); diff --git a/test/fixtures/apps/hello-prompt/main.ts b/test/fixtures/apps/hello-prompt/main.ts new file mode 100644 index 0000000..b537b88 --- /dev/null +++ b/test/fixtures/apps/hello-prompt/main.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import readline from 'node:readline'; + +const READY_PROMPT = 'READY> '; +const BRACKETED_PASTE_START = '\u001b[200~'; +const BRACKETED_PASTE_END = '\u001b[201~'; +const EXIT_CODE_PREFIX = 'exit-code '; + +function writeStdout(text: string): void { + process.stdout.write(text); +} + +function printReadyPrompt(): void { + writeStdout(READY_PROMPT); +} + +function normalizeInput(input: string): string { + return input + .replaceAll(BRACKETED_PASTE_START, '') + .replaceAll(BRACKETED_PASTE_END, ''); +} + +function parseExitCode(input: string): number { + const rawCode = input.slice(EXIT_CODE_PREFIX.length).trim(); + const exitCode = Number.parseInt(rawCode, 10); + + assert(rawCode.length > 0, 'exit-code command requires a numeric argument'); + assert(Number.isInteger(exitCode), 'exit-code command must parse to an integer'); + assert(String(exitCode) === rawCode, 'exit-code command only accepts canonical integers'); + + return exitCode; +} + +const lineReader = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, +}); + +process.on('SIGINT', () => { + writeStdout('INTERRUPTED\n'); + process.exit(130); +}); + +lineReader.on('line', (line) => { + const normalizedLine = normalizeInput(line); + + if (normalizedLine === 'exit') { + writeStdout('BYE\n'); + process.exit(0); + } + + if (normalizedLine.startsWith(EXIT_CODE_PREFIX)) { + process.exit(parseExitCode(normalizedLine)); + } + + writeStdout(`ECHO: ${normalizedLine}\n`); + printReadyPrompt(); +}); + +lineReader.on('close', () => { + process.stdin.pause(); +}); + +printReadyPrompt(); diff --git a/test/fixtures/apps/resize-demo/main.ts b/test/fixtures/apps/resize-demo/main.ts new file mode 100644 index 0000000..879aa01 --- /dev/null +++ b/test/fixtures/apps/resize-demo/main.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import readline from 'node:readline'; + +function readTerminalSize(): { cols: number; rows: number } { + const { columns, rows } = process.stdout; + + assert(typeof columns === 'number', 'stdout.columns must be available'); + assert(typeof rows === 'number', 'stdout.rows must be available'); + + return { cols: columns, rows }; +} + +function printTerminalSize(): void { + const { cols, rows } = readTerminalSize(); + process.stdout.write(`SIZE: ${String(cols)}x${String(rows)}\n`); +} + +const lineReader = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, +}); + +process.on('SIGWINCH', () => { + printTerminalSize(); +}); + +lineReader.on('line', (line) => { + if (line === 'quit') { + process.exit(0); + } +}); + +lineReader.on('close', () => { + process.stdin.pause(); +}); + +printTerminalSize(); From 0932e97a399dcced2ef573460cd0a63ceaaec960 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 18:06:23 +0000 Subject: [PATCH 18/38] Add week 1 dogfood proof bundle --- dogfood/20260319-lifecycle/01-create.json | 8 +++ dogfood/20260319-lifecycle/02-list.json | 20 +++++++ .../20260319-lifecycle/03-inspect-live.json | 27 ++++++++++ dogfood/20260319-lifecycle/04-type.json | 6 +++ dogfood/20260319-lifecycle/05-send-keys.json | 6 +++ dogfood/20260319-lifecycle/06-wait-idle.json | 8 +++ dogfood/20260319-lifecycle/07-paste.json | 6 +++ .../08-send-keys-enter.json | 6 +++ .../20260319-lifecycle/09-wait-idle-2.json | 8 +++ dogfood/20260319-lifecycle/10-resize.json | 9 ++++ .../11-inspect-resized.json | 27 ++++++++++ dogfood/20260319-lifecycle/12-signal.json | 9 ++++ dogfood/20260319-lifecycle/13-wait-exit.json | 9 ++++ .../20260319-lifecycle/14-inspect-exited.json | 27 ++++++++++ dogfood/20260319-lifecycle/15-destroy.json | 9 ++++ dogfood/20260319-lifecycle/event-log.jsonl | 14 +++++ dogfood/20260319-lifecycle/manifest.json | 20 +++++++ dogfood/20260319-lifecycle/notes.md | 54 +++++++++++++++++++ dogfood/20260319-resize-demo/01-create.json | 8 +++ .../20260319-resize-demo/02-wait-idle.json | 8 +++ dogfood/20260319-resize-demo/03-resize.json | 9 ++++ .../20260319-resize-demo/04-wait-idle-2.json | 8 +++ dogfood/20260319-resize-demo/05-inspect.json | 27 ++++++++++ dogfood/20260319-resize-demo/06-destroy.json | 9 ++++ dogfood/20260319-resize-demo/event-log.jsonl | 3 ++ dogfood/20260319-resize-demo/manifest.json | 9 ++++ dogfood/20260319-resize-demo/notes.md | 44 +++++++++++++++ 27 files changed, 398 insertions(+) create mode 100644 dogfood/20260319-lifecycle/01-create.json create mode 100644 dogfood/20260319-lifecycle/02-list.json create mode 100644 dogfood/20260319-lifecycle/03-inspect-live.json create mode 100644 dogfood/20260319-lifecycle/04-type.json create mode 100644 dogfood/20260319-lifecycle/05-send-keys.json create mode 100644 dogfood/20260319-lifecycle/06-wait-idle.json create mode 100644 dogfood/20260319-lifecycle/07-paste.json create mode 100644 dogfood/20260319-lifecycle/08-send-keys-enter.json create mode 100644 dogfood/20260319-lifecycle/09-wait-idle-2.json create mode 100644 dogfood/20260319-lifecycle/10-resize.json create mode 100644 dogfood/20260319-lifecycle/11-inspect-resized.json create mode 100644 dogfood/20260319-lifecycle/12-signal.json create mode 100644 dogfood/20260319-lifecycle/13-wait-exit.json create mode 100644 dogfood/20260319-lifecycle/14-inspect-exited.json create mode 100644 dogfood/20260319-lifecycle/15-destroy.json create mode 100644 dogfood/20260319-lifecycle/event-log.jsonl create mode 100644 dogfood/20260319-lifecycle/manifest.json create mode 100644 dogfood/20260319-lifecycle/notes.md create mode 100644 dogfood/20260319-resize-demo/01-create.json create mode 100644 dogfood/20260319-resize-demo/02-wait-idle.json create mode 100644 dogfood/20260319-resize-demo/03-resize.json create mode 100644 dogfood/20260319-resize-demo/04-wait-idle-2.json create mode 100644 dogfood/20260319-resize-demo/05-inspect.json create mode 100644 dogfood/20260319-resize-demo/06-destroy.json create mode 100644 dogfood/20260319-resize-demo/event-log.jsonl create mode 100644 dogfood/20260319-resize-demo/manifest.json create mode 100644 dogfood/20260319-resize-demo/notes.md diff --git a/dogfood/20260319-lifecycle/01-create.json b/dogfood/20260319-lifecycle/01-create.json new file mode 100644 index 0000000..079b332 --- /dev/null +++ b/dogfood/20260319-lifecycle/01-create.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-19T18:02:15.173Z", + "result": { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4" + } +} diff --git a/dogfood/20260319-lifecycle/02-list.json b/dogfood/20260319-lifecycle/02-list.json new file mode 100644 index 0000000..07146c7 --- /dev/null +++ b/dogfood/20260319-lifecycle/02-list.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "list", + "timestamp": "2026-03-19T18:02:15.471Z", + "result": { + "sessions": [ + { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "createdAt": "2026-03-19T18:02:14.759Z" + } + ] + } +} diff --git a/dogfood/20260319-lifecycle/03-inspect-live.json b/dogfood/20260319-lifecycle/03-inspect-live.json new file mode 100644 index 0000000..29e847c --- /dev/null +++ b/dogfood/20260319-lifecycle/03-inspect-live.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:15.861Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:15.078Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 80, + "rows": 24, + "hostPid": 278189, + "childPid": 278201, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/04-type.json b/dogfood/20260319-lifecycle/04-type.json new file mode 100644 index 0000000..bdda75a --- /dev/null +++ b/dogfood/20260319-lifecycle/04-type.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-03-19T18:02:16.162Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/05-send-keys.json b/dogfood/20260319-lifecycle/05-send-keys.json new file mode 100644 index 0000000..35d1ccc --- /dev/null +++ b/dogfood/20260319-lifecycle/05-send-keys.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-19T18:02:16.456Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/06-wait-idle.json b/dogfood/20260319-lifecycle/06-wait-idle.json new file mode 100644 index 0000000..16a1dd4 --- /dev/null +++ b/dogfood/20260319-lifecycle/06-wait-idle.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:16.977Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-lifecycle/07-paste.json b/dogfood/20260319-lifecycle/07-paste.json new file mode 100644 index 0000000..1f6f9e1 --- /dev/null +++ b/dogfood/20260319-lifecycle/07-paste.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "paste", + "timestamp": "2026-03-19T18:02:17.309Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/08-send-keys-enter.json b/dogfood/20260319-lifecycle/08-send-keys-enter.json new file mode 100644 index 0000000..80deac5 --- /dev/null +++ b/dogfood/20260319-lifecycle/08-send-keys-enter.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-19T18:02:17.612Z", + "result": {} +} diff --git a/dogfood/20260319-lifecycle/09-wait-idle-2.json b/dogfood/20260319-lifecycle/09-wait-idle-2.json new file mode 100644 index 0000000..bee4734 --- /dev/null +++ b/dogfood/20260319-lifecycle/09-wait-idle-2.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:18.211Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-lifecycle/10-resize.json b/dogfood/20260319-lifecycle/10-resize.json new file mode 100644 index 0000000..d92df06 --- /dev/null +++ b/dogfood/20260319-lifecycle/10-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-03-19T18:02:18.622Z", + "result": { + "cols": 120, + "rows": 40 + } +} diff --git a/dogfood/20260319-lifecycle/11-inspect-resized.json b/dogfood/20260319-lifecycle/11-inspect-resized.json new file mode 100644 index 0000000..40bded8 --- /dev/null +++ b/dogfood/20260319-lifecycle/11-inspect-resized.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:19.035Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:18.619Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 120, + "rows": 40, + "hostPid": 278189, + "childPid": 278201, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/12-signal.json b/dogfood/20260319-lifecycle/12-signal.json new file mode 100644 index 0000000..6c005f7 --- /dev/null +++ b/dogfood/20260319-lifecycle/12-signal.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "signal", + "timestamp": "2026-03-19T18:02:19.371Z", + "result": { + "signal": "SIGINT", + "delivered": true + } +} diff --git a/dogfood/20260319-lifecycle/13-wait-exit.json b/dogfood/20260319-lifecycle/13-wait-exit.json new file mode 100644 index 0000000..521e24d --- /dev/null +++ b/dogfood/20260319-lifecycle/13-wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:19.711Z", + "result": { + "timedOut": false, + "exitCode": 130 + } +} diff --git a/dogfood/20260319-lifecycle/14-inspect-exited.json b/dogfood/20260319-lifecycle/14-inspect-exited.json new file mode 100644 index 0000000..740e189 --- /dev/null +++ b/dogfood/20260319-lifecycle/14-inspect-exited.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:20.056Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "createdAt": "2026-03-19T18:02:14.759Z", + "updatedAt": "2026-03-19T18:02:19.379Z", + "status": "exited", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/hello-prompt/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-lifecycle", + "cols": 120, + "rows": 40, + "hostPid": 278189, + "childPid": 278201, + "exitCode": 130, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-lifecycle/15-destroy.json b/dogfood/20260319-lifecycle/15-destroy.json new file mode 100644 index 0000000..950f1f4 --- /dev/null +++ b/dogfood/20260319-lifecycle/15-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-19T18:02:20.328Z", + "result": { + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "destroyed": true + } +} diff --git a/dogfood/20260319-lifecycle/event-log.jsonl b/dogfood/20260319-lifecycle/event-log.jsonl new file mode 100644 index 0000000..4a246b1 --- /dev/null +++ b/dogfood/20260319-lifecycle/event-log.jsonl @@ -0,0 +1,14 @@ +{"seq":0,"ts":"2026-03-19T18:02:15.194Z","type":"output","payload":{"data":"READY> "}} +{"seq":1,"ts":"2026-03-19T18:02:16.160Z","type":"input_text","payload":{"data":"hello world"}} +{"seq":2,"ts":"2026-03-19T18:02:16.160Z","type":"output","payload":{"data":"hello world"}} +{"seq":3,"ts":"2026-03-19T18:02:16.454Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":4,"ts":"2026-03-19T18:02:16.455Z","type":"output","payload":{"data":"\r\n"}} +{"seq":5,"ts":"2026-03-19T18:02:16.455Z","type":"output","payload":{"data":"ECHO: hello world\r\nREADY> "}} +{"seq":6,"ts":"2026-03-19T18:02:17.308Z","type":"input_paste","payload":{"data":"\u001b[200~pasted-content\u001b[201~"}} +{"seq":7,"ts":"2026-03-19T18:02:17.308Z","type":"output","payload":{"data":"^[[200~pasted-content^[[201~"}} +{"seq":8,"ts":"2026-03-19T18:02:17.610Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":9,"ts":"2026-03-19T18:02:17.610Z","type":"output","payload":{"data":"\r\nECHO: pasted-content\r\nREADY> "}} +{"seq":10,"ts":"2026-03-19T18:02:18.620Z","type":"resize","payload":{"cols":120,"rows":40}} +{"seq":11,"ts":"2026-03-19T18:02:19.369Z","type":"signal","payload":{"signal":"SIGINT"}} +{"seq":12,"ts":"2026-03-19T18:02:19.370Z","type":"output","payload":{"data":"INTERRUPTED\r\n"}} +{"seq":13,"ts":"2026-03-19T18:02:19.380Z","type":"exit","payload":{"exitCode":130,"exitSignal":null}} diff --git a/dogfood/20260319-lifecycle/manifest.json b/dogfood/20260319-lifecycle/manifest.json new file mode 100644 index 0000000..41688dd --- /dev/null +++ b/dogfood/20260319-lifecycle/manifest.json @@ -0,0 +1,20 @@ +{ + "scenario": "lifecycle-proof", + "date": "2026-03-19", + "sessionId": "01KM3M69V23RWMMDMS1EK3ZXB4", + "commands": [ + "create", + "list", + "inspect", + "type", + "send-keys", + "wait", + "paste", + "resize", + "signal", + "destroy" + ], + "fixture": "hello-prompt", + "result": "pass", + "knownGaps": ["renderer screenshots", "asciicast export", "gc command"] +} diff --git a/dogfood/20260319-lifecycle/notes.md b/dogfood/20260319-lifecycle/notes.md new file mode 100644 index 0000000..18df254 --- /dev/null +++ b/dogfood/20260319-lifecycle/notes.md @@ -0,0 +1,54 @@ +# Lifecycle proof bundle + +- **Date:** 2026-03-19 +- **Scenario:** Full session lifecycle against the `hello-prompt` fixture +- **Fixture command:** `node --import tsx ../../test/fixtures/apps/hello-prompt/main.ts` +- **Session ID:** `01KM3M69V23RWMMDMS1EK3ZXB4` +- **Isolation:** run under a fresh `AGENT_TERMINAL_HOME=$(mktemp -d)` so only this scenario's state was present +- **Overall result:** pass; every JSON envelope in this directory has `ok: true` + +## What was run + +This scenario exercises the Week 1 control-plane lifecycle end to end: create, list, inspect, type, send Enter, wait for idle, paste, resize, signal, wait for exit, inspect the exited session, and destroy it. + +For the `create` step, the working invocation was `create --json -- node --import tsx ...` so the CLI parsed `--json` as a control-plane flag and `--import tsx ...` as the child command. + +## Step-by-step review guide + +| Step | File | What the command did | What the reviewer should observe | +| ---- | ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `01-create.json` | Created a session running the `hello-prompt` fixture. | `ok: true`, `command: "create"`, and `result.sessionId == "01KM3M69V23RWMMDMS1EK3ZXB4"`. | +| 2 | `02-list.json` | Listed all sessions in the isolated home directory. | Exactly one session is present; its `sessionId` matches step 1, its `status` is `running`, and the command array points at the fixture. | +| 3 | `03-inspect-live.json` | Inspected the live session before any interaction. | `status: "running"`, `cols: 80`, `rows: 24`, and populated `hostPid` / `childPid`. `exitCode` and `exitSignal` are still `null`. | +| 4 | `04-type.json` | Sent literal text `hello world` to the PTY without pressing Enter. | Ack-only envelope: `ok: true` with an empty `result` object. The effect is visible in `event-log.jsonl` at seq 1-2. | +| 5 | `05-send-keys.json` | Sent the `Enter` key to submit the typed line. | Ack-only envelope. In the event log, seq 3-5 shows the Enter key, a newline, and the fixture response `ECHO: hello world` followed by `READY> `. | +| 6 | `06-wait-idle.json` | Waited for the session to go idle after the first prompt round-trip. | `timedOut: false`, proving the prompt settled within the 10s timeout. | +| 7 | `07-paste.json` | Sent a paste payload containing `pasted-content`. | Ack-only envelope. In the event log, seq 6 records `input_paste` with bracketed-paste wrappers and seq 7 shows the raw terminal echo. | +| 8 | `08-send-keys-enter.json` | Sent `Enter` so the pasted line would execute. | Ack-only envelope. Event-log seq 8-9 shows the Enter key and the fixture response `ECHO: pasted-content` followed by another prompt. | +| 9 | `09-wait-idle-2.json` | Waited for idle after the paste flow. | `timedOut: false`, confirming the second prompt cycle completed. | +| 10 | `10-resize.json` | Resized the PTY to 120x40. | `result.cols == 120` and `result.rows == 40`. | +| 11 | `11-inspect-resized.json` | Re-inspected the live session after resize. | Session is still `running`, and `cols` / `rows` now read `120` / `40`. | +| 12 | `12-signal.json` | Delivered `SIGINT` to the session. | `signal: "SIGINT"` and `delivered: true`. | +| 13 | `13-wait-exit.json` | Waited specifically for process exit. | `timedOut: false` and `exitCode: 130`, matching a Ctrl-C style termination. | +| 14 | `14-inspect-exited.json` | Inspected the terminated session before deletion. | `status: "exited"`, `exitCode: 130`, and the resized dimensions `120x40` are still preserved in metadata. | +| 15 | `15-destroy.json` | Deleted the session record from the isolated home directory. | `destroyed: true` and the same `sessionId` appears in the result. | + +## Event log observations + +- `event-log.jsonl` has 14 entries with monotonic sequence numbers `0` through `13`. +- The first entry is prompt output: `READY> `. +- The typed-text path is visible as `input_text` followed by echoed output. +- The paste path is distinct: seq 6 is `input_paste` and contains bracketed-paste control wrappers (`[200~` / `[201~`). +- The resize is recorded explicitly at seq 10 with `cols: 120` and `rows: 40`. +- The shutdown path is visible as `signal` -> `output` (`INTERRUPTED`) -> `exit` with `exitCode: 130`. + +## Known gaps + +- No renderer screenshots are included because the renderer path is not implemented yet. +- No asciicast export is available yet, so the proof is JSON/event-log based rather than video based. +- The `gc` command is not implemented yet, so garbage-collection behavior is out of scope for this bundle. + +## Additional notes + +- No command failed during this run, so there are no expected `ok: false` envelopes to explain. +- The bundle is intentionally self-contained for reviewer consumption: the JSON envelopes show command results, and `event-log.jsonl` shows the terminal-side evidence those commands produced. diff --git a/dogfood/20260319-resize-demo/01-create.json b/dogfood/20260319-resize-demo/01-create.json new file mode 100644 index 0000000..d90eb10 --- /dev/null +++ b/dogfood/20260319-resize-demo/01-create.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-19T18:02:30.052Z", + "result": { + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0" + } +} diff --git a/dogfood/20260319-resize-demo/02-wait-idle.json b/dogfood/20260319-resize-demo/02-wait-idle.json new file mode 100644 index 0000000..1356069 --- /dev/null +++ b/dogfood/20260319-resize-demo/02-wait-idle.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:30.640Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-resize-demo/03-resize.json b/dogfood/20260319-resize-demo/03-resize.json new file mode 100644 index 0000000..22cf70c --- /dev/null +++ b/dogfood/20260319-resize-demo/03-resize.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "resize", + "timestamp": "2026-03-19T18:02:30.975Z", + "result": { + "cols": 120, + "rows": 40 + } +} diff --git a/dogfood/20260319-resize-demo/04-wait-idle-2.json b/dogfood/20260319-resize-demo/04-wait-idle-2.json new file mode 100644 index 0000000..da9474b --- /dev/null +++ b/dogfood/20260319-resize-demo/04-wait-idle-2.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-19T18:02:31.572Z", + "result": { + "timedOut": false + } +} diff --git a/dogfood/20260319-resize-demo/05-inspect.json b/dogfood/20260319-resize-demo/05-inspect.json new file mode 100644 index 0000000..c082366 --- /dev/null +++ b/dogfood/20260319-resize-demo/05-inspect.json @@ -0,0 +1,27 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:02:31.966Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "createdAt": "2026-03-19T18:02:29.736Z", + "updatedAt": "2026-03-19T18:02:30.973Z", + "status": "running", + "command": [ + "node", + "--import", + "tsx", + "../../test/fixtures/apps/resize-demo/main.ts" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_d1aeb26ed6/dogfood/20260319-resize-demo", + "cols": 120, + "rows": 40, + "hostPid": 280102, + "childPid": 280114, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-resize-demo/06-destroy.json b/dogfood/20260319-resize-demo/06-destroy.json new file mode 100644 index 0000000..b926bc1 --- /dev/null +++ b/dogfood/20260319-resize-demo/06-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-19T18:02:37.224Z", + "result": { + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "destroyed": true + } +} diff --git a/dogfood/20260319-resize-demo/event-log.jsonl b/dogfood/20260319-resize-demo/event-log.jsonl new file mode 100644 index 0000000..5fb8274 --- /dev/null +++ b/dogfood/20260319-resize-demo/event-log.jsonl @@ -0,0 +1,3 @@ +{"seq":0,"ts":"2026-03-19T18:02:30.129Z","type":"output","payload":{"data":"SIZE: 80x24\r\n"}} +{"seq":1,"ts":"2026-03-19T18:02:30.973Z","type":"output","payload":{"data":"SIZE: 120x40\r\n"}} +{"seq":2,"ts":"2026-03-19T18:02:30.974Z","type":"resize","payload":{"cols":120,"rows":40}} diff --git a/dogfood/20260319-resize-demo/manifest.json b/dogfood/20260319-resize-demo/manifest.json new file mode 100644 index 0000000..d1a17cd --- /dev/null +++ b/dogfood/20260319-resize-demo/manifest.json @@ -0,0 +1,9 @@ +{ + "scenario": "resize-demo", + "date": "2026-03-19", + "sessionId": "01KM3M6RF40VCPP4WR580KDBE0", + "commands": ["create", "wait", "resize", "inspect", "destroy"], + "fixture": "resize-demo", + "result": "pass", + "knownGaps": ["renderer screenshots", "asciicast export", "gc command"] +} diff --git a/dogfood/20260319-resize-demo/notes.md b/dogfood/20260319-resize-demo/notes.md new file mode 100644 index 0000000..31702c3 --- /dev/null +++ b/dogfood/20260319-resize-demo/notes.md @@ -0,0 +1,44 @@ +# Resize demo proof bundle + +- **Date:** 2026-03-19 +- **Scenario:** Resize behavior against the `resize-demo` fixture +- **Fixture command:** `node --import tsx ../../test/fixtures/apps/resize-demo/main.ts` +- **Session ID:** `01KM3M6RF40VCPP4WR580KDBE0` +- **Isolation:** run under a fresh `AGENT_TERMINAL_HOME=$(mktemp -d)` so only this scenario's state was present +- **Overall result:** pass; every JSON envelope in this directory has `ok: true` + +## What was run + +This scenario focuses on PTY size propagation. The fixture prints its current size on startup and again after resize, which makes it a compact proof that the control plane can create, wait, resize, observe the new size, inspect metadata, and destroy the session. + +For the `create` step, the working invocation was `create --json --cols 80 --rows 24 -- node --import tsx ...` so the size flags were consumed by the control plane and the remainder was passed to the child process. + +## Step-by-step review guide + +| Step | File | What the command did | What the reviewer should observe | +| ---- | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| 1 | `01-create.json` | Created a session with explicit initial dimensions `80x24`. | `ok: true`, `command: "create"`, and `result.sessionId == "01KM3M6RF40VCPP4WR580KDBE0"`. | +| 2 | `02-wait-idle.json` | Waited for the fixture's initial size print to complete. | `timedOut: false`. The corresponding event-log output at seq 0 is `SIZE: 80x24`. | +| 3 | `03-resize.json` | Resized the PTY to `120x40`. | `result.cols == 120` and `result.rows == 40`. | +| 4 | `04-wait-idle-2.json` | Waited for the fixture to emit its post-resize size message. | `timedOut: false`. The event log records the new output `SIZE: 120x40`. | +| 5 | `05-inspect.json` | Inspected the still-running session after the resize. | `status: "running"`, `cols: 120`, `rows: 40`, and the command array points at the resize-demo fixture. | +| 6 | `06-destroy.json` | Force-destroyed the session after collecting evidence. | `destroyed: true` with the matching `sessionId`. | + +## Event log observations + +- `event-log.jsonl` has 3 entries with monotonic sequence numbers `0` through `2`. +- Seq 0 shows the initial size report `SIZE: 80x24`. +- Seq 1 shows the updated size report `SIZE: 120x40` after the resize command. +- Seq 2 records the explicit `resize` event with `cols: 120` and `rows: 40`. +- Notably, the fixture's output for the new size lands just before the explicit resize event entry in this run, so reviewers should treat both lines together as the resize proof rather than assuming a stricter output-before/after ordering contract. + +## Known gaps + +- No renderer screenshots are included because the renderer path is not implemented yet. +- No asciicast export is available yet, so the proof is JSON/event-log based rather than video based. +- The `gc` command is not implemented yet, so garbage-collection behavior is out of scope for this bundle. + +## Additional notes + +- No command failed during this run, so there are no expected `ok: false` envelopes to explain. +- This fixture is intentionally narrow: it exists to prove resize propagation rather than interactive input handling. From abdc164fef85728937ef4d88ce5c9ffc5182289d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 18:08:45 +0000 Subject: [PATCH 19/38] Fix formatting across Week 1 files --- src/cli/commands/create.ts | 10 +- src/cli/commands/destroy.ts | 4 +- src/cli/commands/inspect.ts | 15 ++- src/cli/commands/paste.ts | 6 +- src/cli/commands/resize.ts | 10 +- src/cli/commands/send-keys.ts | 10 +- src/cli/commands/signal.ts | 12 ++- src/cli/commands/type.ts | 6 +- src/cli/commands/wait.ts | 9 +- src/cli/main.ts | 28 ++--- src/host/eventLog.ts | 35 ++++-- src/host/hostMain.ts | 136 +++++++++++++++++------- src/host/lifecycle.ts | 44 ++++++-- src/host/rpcServer.ts | 23 ++-- src/host/sessionState.ts | 40 +++++-- src/protocol/errors.ts | 3 +- src/pty/createPty.ts | 10 +- src/storage/home.ts | 5 +- src/storage/manifests.ts | 22 +--- test/e2e/hello-prompt.test.ts | 70 ++++++++---- test/e2e/helpers.ts | 26 +++-- test/e2e/resize-demo.test.ts | 30 ++++-- test/fixtures/apps/hello-prompt/main.ts | 10 +- test/integration/event-log.test.ts | 24 ++++- test/integration/io-loop.test.ts | 58 +++++++--- test/integration/lifecycle.test.ts | 39 +++++-- test/integration/pty-basics.test.ts | 48 ++++++--- test/unit/storage/sessionPaths.test.ts | 6 +- 28 files changed, 529 insertions(+), 210 deletions(-) diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index 503f10c..44d7b0c 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -11,7 +11,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { resolveHome } from '../../storage/home.js'; import { readManifestIfExists } from '../../storage/manifests.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; const READINESS_POLL_INTERVAL_MS = 100; const READINESS_MAX_ATTEMPTS = 50; @@ -62,7 +66,9 @@ export async function runCreateCommand(options: CommandOptions): Promise { (error.code === ERROR_CODES.HOST_UNREACHABLE || error.code === ERROR_CODES.HOST_TIMEOUT) ) { - const manifest = await readManifestIfExists(manifestPath(sessionDirectory)); + const manifest = await readManifestIfExists( + manifestPath(sessionDirectory), + ); if (manifest?.status === 'exited') { emitSuccess({ command: 'create', diff --git a/src/cli/commands/destroy.ts b/src/cli/commands/destroy.ts index dce51eb..1b2bdcf 100644 --- a/src/cli/commands/destroy.ts +++ b/src/cli/commands/destroy.ts @@ -12,7 +12,9 @@ interface CommandOptions { force: boolean; } -export async function runDestroyCommand(options: CommandOptions): Promise { +export async function runDestroyCommand( + options: CommandOptions, +): Promise { await destroySession(options.sessionId, options.force); emitSuccess({ diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 836409b..0322b24 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -7,7 +7,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifest, readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface InspectResult { session: SessionRecord; @@ -34,7 +38,9 @@ function formatSessionLines(session: SessionRecord): string[] { ]; } -export async function runInspectCommand(options: CommandOptions): Promise { +export async function runInspectCommand( + options: CommandOptions, +): Promise { const home = resolveHome(); const sessionDirectory = sessionDir(home, options.sessionId); const manifestFile = manifestPath(sessionDirectory); @@ -52,7 +58,10 @@ export async function runInspectCommand(options: CommandOptions): Promise if (session.status !== 'exited') { try { - const liveResult = (await sendRpc(socketPath(sessionDirectory), 'inspect')) as InspectResult; + const liveResult = (await sendRpc( + socketPath(sessionDirectory), + 'inspect', + )) as InspectResult; session = liveResult.session; } catch (error) { if ( diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index 89a2543..ca7089f 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface PasteResult { [key: string]: never; diff --git a/src/cli/commands/resize.ts b/src/cli/commands/resize.ts index 62ff78b..3022c07 100644 --- a/src/cli/commands/resize.ts +++ b/src/cli/commands/resize.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface ResizeResult { cols: number; @@ -71,6 +75,8 @@ export async function runResizeCommand(options: CommandOptions): Promise { command: 'resize', json: options.json, result, - lines: [`Resized session to ${String(options.cols)}x${String(options.rows)}.`], + lines: [ + `Resized session to ${String(options.cols)}x${String(options.rows)}.`, + ], }); } diff --git a/src/cli/commands/send-keys.ts b/src/cli/commands/send-keys.ts index 8aabccd..04d603e 100644 --- a/src/cli/commands/send-keys.ts +++ b/src/cli/commands/send-keys.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface SendKeysResult { [key: string]: never; @@ -15,7 +19,9 @@ interface CommandOptions { keys: string[]; } -export async function runSendKeysCommand(options: CommandOptions): Promise { +export async function runSendKeysCommand( + options: CommandOptions, +): Promise { const home = resolveHome(); const sessionDirectory = sessionDir(home, options.sessionId); const manifestFile = manifestPath(sessionDirectory); diff --git a/src/cli/commands/signal.ts b/src/cli/commands/signal.ts index e208e46..ca450ef 100644 --- a/src/cli/commands/signal.ts +++ b/src/cli/commands/signal.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; const ALLOWED_SIGNALS = [ 'SIGTERM', @@ -51,7 +55,11 @@ export async function runSignalCommand(options: CommandOptions): Promise { }); } - if (!ALLOWED_SIGNALS.includes(options.signal as (typeof ALLOWED_SIGNALS)[number])) { + if ( + !ALLOWED_SIGNALS.includes( + options.signal as (typeof ALLOWED_SIGNALS)[number], + ) + ) { throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { message: `Signal must be one of: ${ALLOWED_SIGNALS.join(', ')}.`, details: { diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index bd3b85b..6ae2b6e 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface TypeResult { [key: string]: never; diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index 9615918..ce27072 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -3,7 +3,11 @@ import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; -import { manifestPath, sessionDir, socketPath } from '../../storage/sessionPaths.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; export interface WaitResult { exitCode?: number; @@ -105,7 +109,8 @@ export async function runWaitCommand(options: CommandOptions): Promise { idleMs: options.idleMs ?? undefined, timeoutMs: options.timeout ?? undefined, }; - const clientTimeout = options.timeout !== undefined ? options.timeout + 5_000 : 0; + const clientTimeout = + options.timeout !== undefined ? options.timeout + 5_000 : 0; const result = (await sendRpc( socketPath(sessionDirectory), 'wait', diff --git a/src/cli/main.ts b/src/cli/main.ts index e037c19..983ab7d 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -105,13 +105,15 @@ async function main(): Promise { .description('Destroy a session') .option('--force', 'Skip graceful shutdown', false) .option('--json', 'Emit a JSON command envelope', false) - .action(async (sessionId: string, options: { force: boolean; json: boolean }) => { - await runDestroyCommand({ - json: options.json, - sessionId, - force: options.force, - }); - }); + .action( + async (sessionId: string, options: { force: boolean; json: boolean }) => { + await runDestroyCommand({ + json: options.json, + sessionId, + force: options.force, + }); + }, + ); // --- Session control --- program @@ -147,11 +149,7 @@ async function main(): Promise { .description('Send keys to a session') .option('--json', 'Emit a JSON command envelope', false) .action( - async ( - sessionId: string, - keys: string[], - options: { json: boolean }, - ) => { + async (sessionId: string, keys: string[], options: { json: boolean }) => { await runSendKeysCommand({ json: options.json, sessionId, @@ -200,7 +198,11 @@ async function main(): Promise { .description('Wait for a session condition') .option('--exit', 'Wait for process exit', false) .option('--idle-ms ', 'Wait for output idle period', parseIntegerOption) - .option('--timeout ', 'Maximum wait time in milliseconds', parseIntegerOption) + .option( + '--timeout ', + 'Maximum wait time in milliseconds', + parseIntegerOption, + ) .option('--json', 'Emit a JSON command envelope', false) .action( async ( diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 9a68207..7dd1489 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -3,10 +3,7 @@ import type { FileHandle } from 'node:fs/promises'; import { z } from 'zod'; -import { - EventRecordSchema, - type EventRecord, -} from '../protocol/schemas.js'; +import { EventRecordSchema, type EventRecord } from '../protocol/schemas.js'; import { invariant } from '../util/assert.js'; const OutputEventPayloadSchema = z @@ -152,7 +149,10 @@ function deriveNextSeq(content: string): number { } const parsedRecord = EventRecordSchema.safeParse(parsedLine); - invariant(parsedRecord.success, 'last event log line must match EventRecordSchema'); + invariant( + parsedRecord.success, + 'last event log line must match EventRecordSchema', + ); const { seq } = parsedRecord.data; invariant(Number.isInteger(seq), 'event log seq must be an integer'); @@ -189,13 +189,25 @@ export class EventLog { } async append(type: 'output', payload: OutputEventPayload): Promise; - async append(type: 'input_text', payload: InputTextEventPayload): Promise; - async append(type: 'input_paste', payload: InputPasteEventPayload): Promise; - async append(type: 'input_keys', payload: InputKeysEventPayload): Promise; + async append( + type: 'input_text', + payload: InputTextEventPayload, + ): Promise; + async append( + type: 'input_paste', + payload: InputPasteEventPayload, + ): Promise; + async append( + type: 'input_keys', + payload: InputKeysEventPayload, + ): Promise; async append(type: 'resize', payload: ResizeEventPayload): Promise; async append(type: 'signal', payload: SignalEventPayload): Promise; async append(type: 'exit', payload: ExitEventPayload): Promise; - async append(type: EventLogEventType, payload: EventLogPayload): Promise { + async append( + type: EventLogEventType, + payload: EventLogPayload, + ): Promise { invariant(!this.isClosed, 'cannot append to a closed event log'); const validatedPayload = validatePayload(type, payload); @@ -210,7 +222,10 @@ export class EventLog { }; const parsedRecord = EventRecordSchema.safeParse(record); - invariant(parsedRecord.success, 'event record must match EventRecordSchema'); + invariant( + parsedRecord.success, + 'event record must match EventRecordSchema', + ); const line = `${JSON.stringify(parsedRecord.data)}\n`; this.writeQueue = this.writeQueue.then(() => diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 99174a8..b145da3 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -25,7 +25,14 @@ import { } from '../storage/sessionPaths.js'; import { invariant } from '../util/assert.js'; -const ALLOWED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGKILL', 'SIGHUP', 'SIGUSR1', 'SIGUSR2'] as const; +const ALLOWED_SIGNALS = [ + 'SIGTERM', + 'SIGINT', + 'SIGKILL', + 'SIGHUP', + 'SIGUSR1', + 'SIGUSR2', +] as const; type WaitOutcome = { exitCode?: number; @@ -52,7 +59,10 @@ function rethrowAsync(error: unknown): void { } export async function runHost(sessionId: string): Promise { - invariant(typeof sessionId === 'string' && sessionId.length > 0, 'sessionId must be a non-empty string'); + invariant( + typeof sessionId === 'string' && sessionId.length > 0, + 'sessionId must be a non-empty string', + ); const home = resolveHome(); const sessDir = sessionDir(home, sessionId); @@ -61,10 +71,16 @@ export async function runHost(sessionId: string): Promise { const sPath = socketPath(sessDir); const manifest = await readManifest(mPath); - invariant(manifest.sessionId === sessionId, 'session manifest sessionId must match the requested session'); + invariant( + manifest.sessionId === sessionId, + 'session manifest sessionId must match the requested session', + ); const state = new SessionState(manifest); - invariant(Number.isInteger(process.pid) && process.pid > 0, 'process.pid must be a positive integer'); + invariant( + Number.isInteger(process.pid) && process.pid > 0, + 'process.pid must be a positive integer', + ); state.setHostPid(process.pid); const eventLog = await EventLog.open(ePath); @@ -97,7 +113,10 @@ export async function runHost(sessionId: string): Promise { rows: manifest.rows, }); - invariant(Number.isInteger(pty.pid) && pty.pid > 0, 'PTY child PID must be a positive integer'); + invariant( + Number.isInteger(pty.pid) && pty.pid > 0, + 'PTY child PID must be a positive integer', + ); state.setChildPid(pty.pid); const initiateShutdown = (): Promise => { @@ -166,7 +185,9 @@ export async function runHost(sessionId: string): Promise { const { text } = params as TypeParams; if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } invariant(typeof text === 'string', 'type text must be a string'); @@ -178,10 +199,15 @@ export async function runHost(sessionId: string): Promise { const { text } = params as PasteParams; if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } - invariant(typeof text === 'string' && text.length > 0, 'paste text must be a non-empty string'); + invariant( + typeof text === 'string' && text.length > 0, + 'paste text must be a non-empty string', + ); const encoded = encodePaste(text); pty.write(encoded); await eventLog.append('input_paste', { data: encoded }); @@ -191,17 +217,23 @@ export async function runHost(sessionId: string): Promise { const { keys } = params as SendKeysParams; if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } - invariant(Array.isArray(keys) && keys.length > 0, 'keys must be a non-empty array'); + invariant( + Array.isArray(keys) && keys.length > 0, + 'keys must be a non-empty array', + ); let encoded: string; try { encoded = keys.map((key) => encodeKey(key)).join(''); } catch (error) { throw makeCliError(ERROR_CODES.INVALID_KEYS, { - message: error instanceof Error ? error.message : 'Invalid key sequence.', + message: + error instanceof Error ? error.message : 'Invalid key sequence.', cause: error, }); } @@ -214,11 +246,19 @@ export async function runHost(sessionId: string): Promise { const { cols, rows } = params as ResizeParams; if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } - invariant(Number.isInteger(cols) && cols > 0, 'cols must be a positive integer'); - invariant(Number.isInteger(rows) && rows > 0, 'rows must be a positive integer'); + invariant( + Number.isInteger(cols) && cols > 0, + 'cols must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'rows must be a positive integer', + ); pty.resize(cols, rows); state.setDimensions(cols, rows); @@ -230,12 +270,19 @@ export async function runHost(sessionId: string): Promise { const { signal } = params as SignalParams; if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } - invariant(typeof signal === 'string' && signal.length > 0, 'signal must be a non-empty string'); + invariant( + typeof signal === 'string' && signal.length > 0, + 'signal must be a non-empty string', + ); - if (!ALLOWED_SIGNALS.includes(signal as (typeof ALLOWED_SIGNALS)[number])) { + if ( + !ALLOWED_SIGNALS.includes(signal as (typeof ALLOWED_SIGNALS)[number]) + ) { throw makeCliError(ERROR_CODES.INVALID_SIGNAL, { message: `Invalid signal: ${signal}. Allowed: ${ALLOWED_SIGNALS.join(', ')}`, details: { signal, allowed: [...ALLOWED_SIGNALS] }, @@ -243,7 +290,10 @@ export async function runHost(sessionId: string): Promise { } const childPid = state.snapshot().childPid; - invariant(childPid !== null && childPid > 0, 'child PID must be set for signal delivery'); + invariant( + childPid !== null && childPid > 0, + 'child PID must be set for signal delivery', + ); process.kill(childPid, signal as (typeof ALLOWED_SIGNALS)[number]); await eventLog.append('signal', { signal }); @@ -261,10 +311,16 @@ export async function runHost(sessionId: string): Promise { } if (hasIdle) { - invariant(Number.isInteger(idleMs) && idleMs > 0, 'idleMs must be a positive integer'); + invariant( + Number.isInteger(idleMs) && idleMs > 0, + 'idleMs must be a positive integer', + ); } if (timeoutMs !== undefined) { - invariant(Number.isInteger(timeoutMs) && timeoutMs > 0, 'timeoutMs must be a positive integer'); + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be a positive integer', + ); } let waitCondition: Promise; @@ -290,27 +346,35 @@ export async function runHost(sessionId: string): Promise { }); } else { if (!isSessionRunning(state)) { - throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { message: 'Session is not running.' }); + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); } const idleDuration = idleMs ?? 0; - invariant(Number.isInteger(idleDuration) && idleDuration > 0, 'idleMs must be a positive integer'); + invariant( + Number.isInteger(idleDuration) && idleDuration > 0, + 'idleMs must be a positive integer', + ); waitCondition = new Promise((resolve) => { - const checkInterval = setInterval(() => { - const elapsed = Date.now() - lastOutputAt; - if (elapsed < idleDuration) { - return; - } - - clearInterval(checkInterval); - const snapshot = state.snapshot(); - const result: WaitOutcome = { timedOut: false }; - if (snapshot.exitCode !== null) { - result.exitCode = snapshot.exitCode; - } - resolve(result); - }, Math.min(idleDuration / 2, 100)); + const checkInterval = setInterval( + () => { + const elapsed = Date.now() - lastOutputAt; + if (elapsed < idleDuration) { + return; + } + + clearInterval(checkInterval); + const snapshot = state.snapshot(); + const result: WaitOutcome = { timedOut: false }; + if (snapshot.exitCode !== null) { + result.exitCode = snapshot.exitCode; + } + resolve(result); + }, + Math.min(idleDuration / 2, 100), + ); clearWaitCondition = (): void => { clearInterval(checkInterval); diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts index c99c77a..6e60b13 100644 --- a/src/host/lifecycle.ts +++ b/src/host/lifecycle.ts @@ -10,8 +10,16 @@ import { CliError } from '../cli/errors.js'; import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import type { SessionRecord } from '../protocol/schemas.js'; import { ensureHome, resolveHome } from '../storage/home.js'; -import { readManifest, readManifestIfExists, writeManifest } from '../storage/manifests.js'; -import { manifestPath, sessionDir, socketPath } from '../storage/sessionPaths.js'; +import { + readManifest, + readManifestIfExists, + writeManifest, +} from '../storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../storage/sessionPaths.js'; import { invariant } from '../util/assert.js'; import { sendRpc } from './rpcClient.js'; @@ -51,7 +59,10 @@ function hasErrorCode(error: unknown, code: string): boolean { } function assertPositiveInteger(value: number, label: string): void { - invariant(Number.isInteger(value) && value > 0, `${label} must be a positive integer`); + invariant( + Number.isInteger(value) && value > 0, + `${label} must be a positive integer`, + ); } function assertNonEmptyString( @@ -234,7 +245,10 @@ export async function allocateSession( config: AllocateConfig, ): Promise { const rawConfig: unknown = config; - invariant(rawConfig !== null && typeof rawConfig === 'object', 'config must be an object'); + invariant( + rawConfig !== null && typeof rawConfig === 'object', + 'config must be an object', + ); invariant(Array.isArray(config.command), 'command must be an array'); assertNonEmptyString(config.cwd, 'cwd'); assertPositiveInteger(config.cols, 'cols'); @@ -249,7 +263,10 @@ export async function allocateSession( const resolvedCwd = resolve(config.cwd); const cwdStats = await stat(resolvedCwd); - invariant(cwdStats.isDirectory(), 'cwd must resolve to an existing directory'); + invariant( + cwdStats.isDirectory(), + 'cwd must resolve to an existing directory', + ); const effectiveCommand = config.command.length > 0 ? [...config.command] : [config.shellCommand]; @@ -310,7 +327,8 @@ export async function destroySession( sessionId: string, force?: boolean, ): Promise { - const { sessionDirectory, manifestFile, socketFile } = getSessionPaths(sessionId); + const { sessionDirectory, manifestFile, socketFile } = + getSessionPaths(sessionId); const manifest = await readSessionManifestOrThrow(sessionId, manifestFile); if (isSessionTerminal(manifest)) { @@ -348,7 +366,10 @@ export async function destroySession( try { await sendRpc(socketFile, 'destroy'); } catch (error) { - if (!(error instanceof CliError) || error.code !== ERROR_CODES.HOST_UNREACHABLE) { + if ( + !(error instanceof CliError) || + error.code !== ERROR_CODES.HOST_UNREACHABLE + ) { throw error; } @@ -370,7 +391,10 @@ export async function destroySession( } await reconcileSession(sessionDirectory); - const reconciledManifest = await readSessionManifestOrThrow(sessionId, manifestFile); + const reconciledManifest = await readSessionManifestOrThrow( + sessionId, + manifestFile, + ); if (isSessionTerminal(reconciledManifest)) { return; } @@ -447,7 +471,9 @@ export async function listSessions( return summaries; } -export async function reconcileSession(sessionDirectory: string): Promise { +export async function reconcileSession( + sessionDirectory: string, +): Promise { const manifestFile = manifestPath(sessionDirectory); const manifest = await readManifestIfExists(manifestFile); diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index 13740ca..19018b3 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -144,7 +144,10 @@ export class RpcServer { private server: net.Server | null = null; private closePromise: Promise | null = null; - public constructor(socketPath: string, handlers: Record) { + public constructor( + socketPath: string, + handlers: Record, + ) { invariant(socketPath.length > 0, 'RPC socket path must not be empty.'); this.socketPath = socketPath; @@ -318,13 +321,13 @@ export class RpcServer { const request = requestResult.data; const params = request.params ?? {}; - if (!Object.hasOwn(this.handlers, request.method) || !isKnownRpcMethod(request.method)) { + if ( + !Object.hasOwn(this.handlers, request.method) || + !isKnownRpcMethod(request.method) + ) { this.sendResponse( socket, - buildErrorResponse( - request.id, - `Unsupported method: ${request.method}`, - ), + buildErrorResponse(request.id, `Unsupported method: ${request.method}`), ); return; } @@ -335,7 +338,8 @@ export class RpcServer { `RPC handler for method "${request.method}" must be a function.`, ); - const paramsResult = RpcMethodSchemas[request.method].params.safeParse(params); + const paramsResult = + RpcMethodSchemas[request.method].params.safeParse(params); if (!paramsResult.success) { this.sendResponse( @@ -347,9 +351,8 @@ export class RpcServer { try { const result = await handler(paramsResult.data); - const resultResult = RpcMethodSchemas[request.method].result.safeParse( - result, - ); + const resultResult = + RpcMethodSchemas[request.method].result.safeParse(result); if (!resultResult.success) { this.sendResponse( diff --git a/src/host/sessionState.ts b/src/host/sessionState.ts index cdb89a3..a96f1b9 100644 --- a/src/host/sessionState.ts +++ b/src/host/sessionState.ts @@ -19,27 +19,48 @@ export class SessionState { } public setHostPid(pid: number): void { - invariant(this.#record.status === 'running', 'Cannot set host PID unless session is running'); + invariant( + this.#record.status === 'running', + 'Cannot set host PID unless session is running', + ); invariant(this.#record.hostPid === null, 'Host PID has already been set'); - invariant(Number.isInteger(pid) && pid > 0, 'Host PID must be a positive integer'); + invariant( + Number.isInteger(pid) && pid > 0, + 'Host PID must be a positive integer', + ); this.#record.hostPid = pid; this.touch(); } public setChildPid(pid: number): void { - invariant(this.#record.status === 'running', 'Cannot set child PID unless session is running'); + invariant( + this.#record.status === 'running', + 'Cannot set child PID unless session is running', + ); invariant(this.#record.childPid === null, 'Child PID has already been set'); - invariant(Number.isInteger(pid) && pid > 0, 'Child PID must be a positive integer'); + invariant( + Number.isInteger(pid) && pid > 0, + 'Child PID must be a positive integer', + ); this.#record.childPid = pid; this.touch(); } public setDimensions(cols: number, rows: number): void { - invariant(this.#record.status === 'running', 'Cannot set dimensions unless session is running'); - invariant(Number.isInteger(cols) && cols > 0, 'Columns must be a positive integer'); - invariant(Number.isInteger(rows) && rows > 0, 'Rows must be a positive integer'); + invariant( + this.#record.status === 'running', + 'Cannot set dimensions unless session is running', + ); + invariant( + Number.isInteger(cols) && cols > 0, + 'Columns must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'Rows must be a positive integer', + ); this.#record.cols = cols; this.#record.rows = rows; @@ -47,7 +68,10 @@ export class SessionState { } public requestDestroy(): void { - invariant(this.#record.status === 'running', 'Cannot request destroy unless session is running'); + invariant( + this.#record.status === 'running', + 'Cannot request destroy unless session is running', + ); this.#record.status = 'exiting'; this.touch(); diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts index 086e511..fd95fc3 100644 --- a/src/protocol/errors.ts +++ b/src/protocol/errors.ts @@ -20,8 +20,7 @@ export const ERROR_CODES = { INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; -export type ProtocolErrorCode = - (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; +export type ProtocolErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; export const DEFAULT_ERROR_MESSAGES: Record = { [ERROR_CODES.SESSION_NOT_FOUND]: 'Session not found.', diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts index c41db29..2fb39fd 100644 --- a/src/pty/createPty.ts +++ b/src/pty/createPty.ts @@ -13,8 +13,14 @@ export function createPty(options: PtyOptions): IPty { const { command, cwd, cols, rows } = options; invariant(command.length > 0, 'PTY command must not be empty'); - invariant(Number.isInteger(cols) && cols > 0, 'PTY cols must be a positive integer'); - invariant(Number.isInteger(rows) && rows > 0, 'PTY rows must be a positive integer'); + invariant( + Number.isInteger(cols) && cols > 0, + 'PTY cols must be a positive integer', + ); + invariant( + Number.isInteger(rows) && rows > 0, + 'PTY rows must be a positive integer', + ); const file = command[0]; invariant(file !== undefined, 'PTY command must have an executable'); diff --git a/src/storage/home.ts b/src/storage/home.ts index 6f362b1..b00e759 100644 --- a/src/storage/home.ts +++ b/src/storage/home.ts @@ -11,7 +11,10 @@ export function resolveHome(): string { const configuredHome = process.env.AGENT_TERMINAL_HOME; if (configuredHome !== undefined) { - invariant(configuredHome.length > 0, 'AGENT_TERMINAL_HOME must not be empty'); + invariant( + configuredHome.length > 0, + 'AGENT_TERMINAL_HOME must not be empty', + ); invariant( isAbsolute(configuredHome), 'AGENT_TERMINAL_HOME must be an absolute path', diff --git a/src/storage/manifests.ts b/src/storage/manifests.ts index 15f1961..b899bc8 100644 --- a/src/storage/manifests.ts +++ b/src/storage/manifests.ts @@ -1,20 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { - mkdir, - readFile, - rename, - rm, - writeFile, -} from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute } from 'node:path'; -import { - ERROR_CODES, - makeCliError, -} from '../protocol/errors.js'; -import { - SessionRecordSchema, -} from '../protocol/schemas.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { SessionRecordSchema } from '../protocol/schemas.js'; import { invariant } from '../util/assert.js'; import type { SessionRecord } from '../protocol/schemas.js'; @@ -35,10 +24,7 @@ function isEnoentError(error: unknown): error is Error & NodeError { ); } -function validateManifestData( - path: string, - data: unknown, -): SessionRecord { +function validateManifestData(path: string, data: unknown): SessionRecord { const parsedManifest = SessionRecordSchema.safeParse(data); if (parsedManifest.success) { diff --git a/test/e2e/hello-prompt.test.ts b/test/e2e/hello-prompt.test.ts index 7049141..02b10ea 100644 --- a/test/e2e/hello-prompt.test.ts +++ b/test/e2e/hello-prompt.test.ts @@ -78,7 +78,9 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { expect(waitForReady.command).toBe('wait'); expect(waitForReady.result.timedOut).toBe(false); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('READY> '); const typeEnvelope = runCliJson>>( @@ -110,7 +112,9 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { ); expect(waitForEcho.result.timedOut).toBe(false); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('ECHO: hello world\nREADY> '); const inspectRunning = runCliJson>( @@ -130,23 +134,30 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { expect(typeExitEnvelope.command).toBe('type'); expect(typeExitEnvelope.result).toEqual({}); - const sendExitEnterEnvelope = runCliJson>>( - ['send-keys', sessionId, 'Enter'], - env, - ); + const sendExitEnterEnvelope = runCliJson< + SuccessEnvelope> + >(['send-keys', sessionId, 'Enter'], env); expect(sendExitEnterEnvelope.ok).toBe(true); expect(sendExitEnterEnvelope.command).toBe('send-keys'); expect(sendExitEnterEnvelope.result).toEqual({}); const waitForExit = runCliJson>( - ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], env, ); expect(waitForExit.ok).toBe(true); expect(waitForExit.result.timedOut).toBe(false); expect(waitForExit.result.exitCode).toBe(0); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('BYE\n'); const inspectExited = runCliJson>( @@ -156,16 +167,17 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { expect(inspectExited.result.session.status).toBe('exited'); expect(inspectExited.result.session.exitCode).toBe(0); - const destroyEnvelope = runCliJson>( - ['destroy', sessionId, '--force'], - env, - ); + const destroyEnvelope = runCliJson< + SuccessEnvelope<{ sessionId: string; destroyed: boolean }> + >(['destroy', sessionId, '--force'], env); expect(destroyEnvelope.ok).toBe(true); expect(destroyEnvelope.command).toBe('destroy'); expect(destroyEnvelope.result.sessionId).toBe(sessionId); expect(destroyEnvelope.result.destroyed).toBe(true); - createdSessionIds = createdSessionIds.filter((value) => value !== sessionId); + createdSessionIds = createdSessionIds.filter( + (value) => value !== sessionId, + ); }); it('paste and exit-code', () => { @@ -207,7 +219,13 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { expect(sendKeysEnvelope.result).toEqual({}); const waitForExit = runCliJson>( - ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], env, ); expect(waitForExit.ok).toBe(true); @@ -237,23 +255,33 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { ); expect(waitForReady.result.timedOut).toBe(false); - const signalEnvelope = runCliJson>( - ['signal', sessionId, 'SIGINT'], - env, - ); + const signalEnvelope = runCliJson< + SuccessEnvelope<{ signal: string; delivered: boolean }> + >(['signal', sessionId, 'SIGINT'], env); expect(signalEnvelope.ok).toBe(true); expect(signalEnvelope.command).toBe('signal'); - expect(signalEnvelope.result).toEqual({ signal: 'SIGINT', delivered: true }); + expect(signalEnvelope.result).toEqual({ + signal: 'SIGINT', + delivered: true, + }); const waitForExit = runCliJson>( - ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], env, ); expect(waitForExit.ok).toBe(true); expect(waitForExit.result.timedOut).toBe(false); expect(waitForExit.result.exitCode).toBe(130); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('INTERRUPTED\n'); }); }); diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts index 466ec4f..5280d40 100644 --- a/test/e2e/helpers.ts +++ b/test/e2e/helpers.ts @@ -83,7 +83,10 @@ function withJsonFlag(args: string[]): string[] { } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- typed JSON helper keeps call sites concise in test code. -export function runCliJson(args: string[], env: Record): TResult { +export function runCliJson( + args: string[], + env: Record, +): TResult { const { stdout } = runCli(withJsonFlag(args), env); assert(stdout.length > 0, 'expected JSON output from CLI command'); @@ -112,10 +115,9 @@ export async function cleanupHome(home: string): Promise { const manifestFile = join(sessionsDir, entry, 'session.json'); try { - const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as Record< - string, - unknown - >; + const manifest = JSON.parse( + await readFile(manifestFile, 'utf8'), + ) as Record; for (const pidKey of ['childPid', 'hostPid'] as const) { const pid = manifest[pidKey]; @@ -138,7 +140,10 @@ export async function cleanupHome(home: string): Promise { await rm(home, { recursive: true, force: true }); } -export async function readEvents(home: string, sessionId: string): Promise { +export async function readEvents( + home: string, + sessionId: string, +): Promise { const eventsPath = join(home, 'sessions', sessionId, 'events.jsonl'); const content = await readFile(eventsPath, 'utf8'); @@ -153,7 +158,10 @@ export async function readEvents(home: string, sessionId: string): Promise JSON.parse(line) as EventRecord); } -export async function readOutput(home: string, sessionId: string): Promise { +export async function readOutput( + home: string, + sessionId: string, +): Promise { const events = await readEvents(home, sessionId); return events @@ -165,6 +173,8 @@ export async function readOutput(home: string, sessionId: string): Promise { expect(waitForInitialOutput.ok).toBe(true); expect(waitForInitialOutput.result.timedOut).toBe(false); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('SIZE: 80x24\n'); - const resizeEnvelope = runCliJson>( - ['resize', sessionId, '--cols', '120', '--rows', '40'], - env, - ); + const resizeEnvelope = runCliJson< + SuccessEnvelope<{ cols: number; rows: number }> + >(['resize', sessionId, '--cols', '120', '--rows', '40'], env); expect(resizeEnvelope.ok).toBe(true); expect(resizeEnvelope.command).toBe('resize'); expect(resizeEnvelope.result.cols).toBe(120); @@ -108,7 +109,9 @@ describe('resize-demo e2e', { timeout: 30_000 }, () => { ); expect(waitForResizeOutput.result.timedOut).toBe(false); await expect( - readOutput(testHome, sessionId).then((output) => normalizeTerminalOutput(output)), + readOutput(testHome, sessionId).then((output) => + normalizeTerminalOutput(output), + ), ).resolves.toContain('SIZE: 120x40\n'); const typeQuitEnvelope = runCliJson>>( @@ -128,7 +131,13 @@ describe('resize-demo e2e', { timeout: 30_000 }, () => { expect(sendKeysEnvelope.result).toEqual({}); const waitForExit = runCliJson>( - ['wait', sessionId, '--exit', '--timeout', String(DEFAULT_WAIT_TIMEOUT_MS)], + [ + 'wait', + sessionId, + '--exit', + '--timeout', + String(DEFAULT_WAIT_TIMEOUT_MS), + ], env, ); expect(waitForExit.ok).toBe(true); @@ -145,10 +154,9 @@ describe('resize-demo e2e', { timeout: 30_000 }, () => { const sessionId = createEnvelope.result.sessionId; createdSessionIds.push(sessionId); - const resizeEnvelope = runCliJson>( - ['resize', sessionId, '--cols', '100', '--rows', '50'], - env, - ); + const resizeEnvelope = runCliJson< + SuccessEnvelope<{ cols: number; rows: number }> + >(['resize', sessionId, '--cols', '100', '--rows', '50'], env); expect(resizeEnvelope.ok).toBe(true); expect(resizeEnvelope.result.cols).toBe(100); expect(resizeEnvelope.result.rows).toBe(50); diff --git a/test/fixtures/apps/hello-prompt/main.ts b/test/fixtures/apps/hello-prompt/main.ts index b537b88..f03710f 100644 --- a/test/fixtures/apps/hello-prompt/main.ts +++ b/test/fixtures/apps/hello-prompt/main.ts @@ -26,8 +26,14 @@ function parseExitCode(input: string): number { const exitCode = Number.parseInt(rawCode, 10); assert(rawCode.length > 0, 'exit-code command requires a numeric argument'); - assert(Number.isInteger(exitCode), 'exit-code command must parse to an integer'); - assert(String(exitCode) === rawCode, 'exit-code command only accepts canonical integers'); + assert( + Number.isInteger(exitCode), + 'exit-code command must parse to an integer', + ); + assert( + String(exitCode) === rawCode, + 'exit-code command only accepts canonical integers', + ); return exitCode; } diff --git a/test/integration/event-log.test.ts b/test/integration/event-log.test.ts index 91657fe..eb514f1 100644 --- a/test/integration/event-log.test.ts +++ b/test/integration/event-log.test.ts @@ -40,7 +40,11 @@ function runCli( }, ); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; } async function cleanupHome(home: string): Promise { @@ -86,12 +90,17 @@ function createSession( expect(result.status).toBe(0); expect(result.stderr).toBe(''); - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; expect(envelope.ok).toBe(true); return envelope.result.sessionId; } -async function readEvents(testHome: string, sessionId: string): Promise { +async function readEvents( + testHome: string, + sessionId: string, +): Promise { const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); const content = await readFile(eventsPath, 'utf8'); @@ -125,7 +134,10 @@ function runMixedActionSequence(testHome: string, sessionId: string): void { expect(typeResult.status).toBe(0); expect(typeResult.stderr).toBe(''); - const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], env); + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + env, + ); expect(sendKeysResult.status).toBe(0); expect(sendKeysResult.stderr).toBe(''); @@ -175,7 +187,9 @@ describe('event-log integration', { timeout: 30000 }, () => { const events = await readEvents(testHome, sessionId); expect(events.length).toBeGreaterThan(0); - expect(events.map((event) => event.seq)).toEqual(events.map((_, index) => index)); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); const eventTypes = new Set(events.map((event) => event.type)); expect([...eventTypes]).toEqual( diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index 4105140..b01559d 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -56,7 +56,11 @@ function runCli( }, ); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; } async function cleanupHome(home: string): Promise { @@ -102,12 +106,17 @@ function createSession( expect(result.status).toBe(0); expect(result.stderr).toBe(''); - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; expect(envelope.ok).toBe(true); return envelope.result.sessionId; } -async function readEvents(testHome: string, sessionId: string): Promise { +async function readEvents( + testHome: string, + sessionId: string, +): Promise { const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); const content = await readFile(eventsPath, 'utf8'); @@ -166,15 +175,21 @@ describe('io-loop integration', { timeout: 30000 }, () => { sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec /bin/sh']); await sleep(500); - const typeResult = runCli(['type', sessionId, 'echo test-marker', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); + const typeResult = runCli( + ['type', sessionId, 'echo test-marker', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); expect(typeResult.status).toBe(0); expect(typeResult.stderr).toBe(''); - const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); expect(sendKeysResult.status).toBe(0); expect(sendKeysResult.stderr).toBe(''); @@ -185,7 +200,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { ); expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(envelope.ok).toBe(true); expect(envelope.result.timedOut).toBe(false); @@ -217,12 +234,17 @@ describe('io-loop integration', { timeout: 30000 }, () => { }); expect(signalResult.status).toBe(0); expect(signalResult.stderr).toBe(''); - const signalEnvelope = JSON.parse(signalResult.stdout) as SuccessEnvelope<{ + const signalEnvelope = JSON.parse( + signalResult.stdout, + ) as SuccessEnvelope<{ signal: string; delivered: boolean; }>; expect(signalEnvelope.ok).toBe(true); - expect(signalEnvelope.result).toEqual({ signal: 'SIGTERM', delivered: true }); + expect(signalEnvelope.result).toEqual({ + signal: 'SIGTERM', + delivered: true, + }); const waitResult = runCli( ['wait', sessionId, '--exit', '--timeout', '5000', '--json'], @@ -231,7 +253,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { ); expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const waitEnvelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const waitEnvelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(waitEnvelope.ok).toBe(true); expect(waitEnvelope.result.timedOut).toBe(false); @@ -258,7 +282,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { ); expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(envelope.ok).toBe(true); expect(envelope.result.exitCode).toBe(42); expect(envelope.result.timedOut).toBe(false); @@ -284,7 +310,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { ); expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(envelope.ok).toBe(true); expect(envelope.result.exitCode).toBe(0); expect(envelope.result.timedOut).toBe(false); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index e697b5c..46d246a 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -66,7 +66,11 @@ function runCli( timeout: 15000, }, ); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; } async function cleanupHome(home: string): Promise { @@ -128,7 +132,9 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(typeof sessionId).toBe('string'); expect(sessionId.length).toBeGreaterThan(0); - const listResult = runCli(['list', '--json'], { AGENT_TERMINAL_HOME: testHome }); + const listResult = runCli(['list', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); expect(listResult.status).toBe(0); expect(listResult.stderr).toBe(''); const listEnvelope = JSON.parse(listResult.stdout) as SuccessEnvelope<{ @@ -146,7 +152,9 @@ describe('lifecycle integration', { timeout: 30000 }, () => { }); expect(inspectResult.status).toBe(0); expect(inspectResult.stderr).toBe(''); - const inspectEnvelope = JSON.parse(inspectResult.stdout) as SuccessEnvelope<{ + const inspectEnvelope = JSON.parse( + inspectResult.stdout, + ) as SuccessEnvelope<{ session: SessionRecord; }>; expect(inspectEnvelope.ok).toBe(true); @@ -160,7 +168,9 @@ describe('lifecycle integration', { timeout: 30000 }, () => { }); expect(destroyResult.status).toBe(0); expect(destroyResult.stderr).toBe(''); - const destroyEnvelope = JSON.parse(destroyResult.stdout) as SuccessEnvelope<{ + const destroyEnvelope = JSON.parse( + destroyResult.stdout, + ) as SuccessEnvelope<{ sessionId: string; destroyed: boolean; }>; @@ -185,11 +195,15 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(destroyResult.status).toBe(0); expect(destroyResult.stderr).toBe(''); - const listDefault = runCli(['list', '--json'], { AGENT_TERMINAL_HOME: testHome }); + const listDefault = runCli(['list', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); expect(listDefault.status).toBe(0); expect(listDefault.stderr).toBe(''); const defaultSessions = ( - JSON.parse(listDefault.stdout) as SuccessEnvelope<{ sessions: SessionSummary[] }> + JSON.parse(listDefault.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }> ).result.sessions; expect( defaultSessions.find((session) => session.sessionId === sessionId), @@ -201,7 +215,9 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(listAll.status).toBe(0); expect(listAll.stderr).toBe(''); const allSessions = ( - JSON.parse(listAll.stdout) as SuccessEnvelope<{ sessions: SessionSummary[] }> + JSON.parse(listAll.stdout) as SuccessEnvelope<{ + sessions: SessionSummary[]; + }> ).result.sessions; expect(allSessions).toEqual( expect.arrayContaining([ @@ -223,7 +239,14 @@ describe('lifecycle integration', { timeout: 30000 }, () => { it('event log contains output and exit records', async () => { const createResult = runCli( - ['create', '--json', '--', '/bin/sh', '-c', 'echo marker-test-output; exit 0'], + [ + 'create', + '--json', + '--', + '/bin/sh', + '-c', + 'echo marker-test-output; exit 0', + ], { AGENT_TERMINAL_HOME: testHome }, ); expect(createResult.status).toBe(0); diff --git a/test/integration/pty-basics.test.ts b/test/integration/pty-basics.test.ts index 43d267c..b05e197 100644 --- a/test/integration/pty-basics.test.ts +++ b/test/integration/pty-basics.test.ts @@ -51,7 +51,11 @@ function runCli( }, ); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; } async function cleanupHome(home: string): Promise { @@ -97,12 +101,17 @@ function createSession( expect(result.status).toBe(0); expect(result.stderr).toBe(''); - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ sessionId: string }>; + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; expect(envelope.ok).toBe(true); return envelope.result.sessionId; } -async function readEvents(testHome: string, sessionId: string): Promise { +async function readEvents( + testHome: string, + sessionId: string, +): Promise { const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); const content = await readFile(eventsPath, 'utf8'); @@ -166,13 +175,17 @@ describe('pty-basics integration', { timeout: 30000 }, () => { }); expect(typeResult.status).toBe(0); expect(typeResult.stderr).toBe(''); - const envelope = JSON.parse(typeResult.stdout) as SuccessEnvelope>; + const envelope = JSON.parse(typeResult.stdout) as SuccessEnvelope< + Record + >; expect(envelope.ok).toBe(true); await sleep(300); const events = await readEvents(testHome, sessionId); - const inputTextEvents = events.filter((event) => event.type === 'input_text'); + const inputTextEvents = events.filter( + (event) => event.type === 'input_text', + ); expect(inputTextEvents.length).toBeGreaterThan(0); expect(inputTextEvents[0]?.payload.data).toBe('hello'); } finally { @@ -187,18 +200,25 @@ describe('pty-basics integration', { timeout: 30000 }, () => { sessionId = createSession(testHome); await sleep(500); - const sendKeysResult = runCli(['send-keys', sessionId, 'Enter', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); expect(sendKeysResult.status).toBe(0); expect(sendKeysResult.stderr).toBe(''); - const envelope = JSON.parse(sendKeysResult.stdout) as SuccessEnvelope>; + const envelope = JSON.parse(sendKeysResult.stdout) as SuccessEnvelope< + Record + >; expect(envelope.ok).toBe(true); await sleep(300); const events = await readEvents(testHome, sessionId); - const inputKeyEvents = events.filter((event) => event.type === 'input_keys'); + const inputKeyEvents = events.filter( + (event) => event.type === 'input_keys', + ); expect(inputKeyEvents.length).toBeGreaterThan(0); expect(inputKeyEvents[0]?.payload.keys).toEqual(['Enter']); } finally { @@ -218,13 +238,17 @@ describe('pty-basics integration', { timeout: 30000 }, () => { }); expect(pasteResult.status).toBe(0); expect(pasteResult.stderr).toBe(''); - const envelope = JSON.parse(pasteResult.stdout) as SuccessEnvelope>; + const envelope = JSON.parse(pasteResult.stdout) as SuccessEnvelope< + Record + >; expect(envelope.ok).toBe(true); await sleep(300); const events = await readEvents(testHome, sessionId); - const inputPasteEvents = events.filter((event) => event.type === 'input_paste'); + const inputPasteEvents = events.filter( + (event) => event.type === 'input_paste', + ); expect(inputPasteEvents.length).toBeGreaterThan(0); const data = inputPasteEvents[0]?.payload.data; diff --git a/test/unit/storage/sessionPaths.test.ts b/test/unit/storage/sessionPaths.test.ts index ae27734..94f2e92 100644 --- a/test/unit/storage/sessionPaths.test.ts +++ b/test/unit/storage/sessionPaths.test.ts @@ -39,9 +39,9 @@ const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( - temporaryDirectories.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }), - ), + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), ); }); From f8513e7b05960e23bb697b5023950a42c144c961 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 18:12:42 +0000 Subject: [PATCH 20/38] Add dogfood visual screenshots --- .../screenshots/01-create.png | 29 +++++++++++++ .../screenshots/02-list.png | 38 ++++++++++++++++ .../screenshots/03-after-input.png | 42 ++++++++++++++++++ .../screenshots/04-inspect.png | 39 +++++++++++++++++ .../screenshots/05-resize.png | 40 +++++++++++++++++ .../screenshots/06-destroy.png | 43 +++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 dogfood/20260319-lifecycle/screenshots/01-create.png create mode 100644 dogfood/20260319-lifecycle/screenshots/02-list.png create mode 100644 dogfood/20260319-lifecycle/screenshots/03-after-input.png create mode 100644 dogfood/20260319-lifecycle/screenshots/04-inspect.png create mode 100644 dogfood/20260319-lifecycle/screenshots/05-resize.png create mode 100644 dogfood/20260319-lifecycle/screenshots/06-destroy.png diff --git a/dogfood/20260319-lifecycle/screenshots/01-create.png b/dogfood/20260319-lifecycle/screenshots/01-create.png new file mode 100644 index 0000000..30035a8 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/01-create.png @@ -0,0 +1,29 @@ + + + + + + + + + $ Create Session + + +
$ npx tsx src/cli/main.ts create -- node --import tsx test/fixtures/apps/hello-prompt/main.ts --json 2>/dev/null
+
+Session created: 01KM3MPTG735D2K160BW3S452H
+
+✓ Session successfully created and running
+  Process ID: 299338
+  Terminal: 80x24
+  Status: running
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/02-list.png b/dogfood/20260319-lifecycle/screenshots/02-list.png new file mode 100644 index 0000000..39b95ed --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/02-list.png @@ -0,0 +1,38 @@ + + + + + + + + + $ List Sessions + + +
$ npx tsx src/cli/main.ts list --json 2>/dev/null
+
+{
+  "ok": true,
+  "command": "list",
+  "result": {
+    "sessions": [
+      {
+        "sessionId": "01KM3MPTG735D2K160BW3S452H",
+        "status": "running",
+        "createdAt": "2026-03-19T18:11:16.108Z"
+      }
+    ]
+  }
+}
+
+✓ One active session found
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/03-after-input.png b/dogfood/20260319-lifecycle/screenshots/03-after-input.png new file mode 100644 index 0000000..b112b2d --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/03-after-input.png @@ -0,0 +1,42 @@ + + + + + + + + + $ Type and Send Input + + +
$ npx tsx src/cli/main.ts type SESSION_ID "hello world" --json 2>/dev/null
+{
+  "ok": true,
+  "command": "type"
+}
+
+$ npx tsx src/cli/main.ts send-keys SESSION_ID Enter --json 2>/dev/null
+{
+  "ok": true,
+  "command": "send-keys"
+}
+
+$ npx tsx src/cli/main.ts wait SESSION_ID --idle-ms 500 --timeout 10000 --json 2>/dev/null
+{
+  "ok": true,
+  "result": {
+    "timedOut": false
+  }
+}
+
+✓ Session processed input successfully
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/04-inspect.png b/dogfood/20260319-lifecycle/screenshots/04-inspect.png new file mode 100644 index 0000000..ba7cf39 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/04-inspect.png @@ -0,0 +1,39 @@ + + + + + + + + + $ Inspect Live Session + + +
$ npx tsx src/cli/main.ts inspect SESSION_ID --json 2>/dev/null
+
+{
+  "ok": true,
+  "command": "inspect",
+  "result": {
+    "session": {
+      "sessionId": "01KM3MPTG735D2K160BW3S452H",
+      "status": "running",
+      "cols": 80,
+      "rows": 24,
+      "hostPid": 299326,
+      "childPid": 299338
+    }
+  }
+}
+
+✓ Session details retrieved
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/05-resize.png b/dogfood/20260319-lifecycle/screenshots/05-resize.png new file mode 100644 index 0000000..8e2f039 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/05-resize.png @@ -0,0 +1,40 @@ + + + + + + + + + $ Resize Session + + +
$ npx tsx src/cli/main.ts resize SESSION_ID --cols 120 --rows 40 --json 2>/dev/null
+
+{
+  "ok": true,
+  "result": {
+    "cols": 120,
+    "rows": 40
+  }
+}
+
+$ npx tsx src/cli/main.ts inspect SESSION_ID --json 2>/dev/null
+{
+  "session": {
+    "cols": 120,
+    "rows": 40
+  }
+}
+
+✓ Session resized from 80x24 to 120x40
+
+ + +
\ No newline at end of file diff --git a/dogfood/20260319-lifecycle/screenshots/06-destroy.png b/dogfood/20260319-lifecycle/screenshots/06-destroy.png new file mode 100644 index 0000000..40c1fc7 --- /dev/null +++ b/dogfood/20260319-lifecycle/screenshots/06-destroy.png @@ -0,0 +1,43 @@ + + + + + + + + + $ Destroy and Final State + + +
$ npx tsx src/cli/main.ts destroy SESSION_ID --force --json 2>/dev/null
+
+{
+  "ok": true,
+  "result": {
+    "sessionId": "01KM3MPTG735D2K160BW3S452H",
+    "destroyed": true
+  }
+}
+
+$ npx tsx src/cli/main.ts list --all --json 2>/dev/null
+
+{
+  "sessions": [
+    {
+      "sessionId": "01KM3MPTG735D2K160BW3S452H",
+      "status": "exited"
+    }
+  ]
+}
+
+✓ Session destroyed
+
+ + +
\ No newline at end of file From caf05f1f1d423f94905f1e7763e273ce17320379 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 18:29:34 +0000 Subject: [PATCH 21/38] Add nvim dogfood demo evidence artifacts --- dogfood/20260319-nvim-demo/dogfood.md | 6 ++ dogfood/20260319-nvim-demo/event-log.jsonl | 65 +++++++++++++++++++ dogfood/20260319-nvim-demo/inspect-final.json | 25 +++++++ .../screenshots/01-nvim-launched.txt | 30 +++++++++ .../screenshots/02-new-buffer.txt | 57 ++++++++++++++++ .../screenshots/03-content-typed.txt | 62 ++++++++++++++++++ .../screenshots/04-file-saved.txt | 62 ++++++++++++++++++ .../screenshots/05-gg-top.txt | 62 ++++++++++++++++++ .../screenshots/06-nvim-quit.txt | 62 ++++++++++++++++++ 9 files changed, 431 insertions(+) create mode 100644 dogfood/20260319-nvim-demo/dogfood.md create mode 100644 dogfood/20260319-nvim-demo/event-log.jsonl create mode 100644 dogfood/20260319-nvim-demo/inspect-final.json create mode 100644 dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt create mode 100644 dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt create mode 100644 dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt create mode 100644 dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt create mode 100644 dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt create mode 100644 dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt diff --git a/dogfood/20260319-nvim-demo/dogfood.md b/dogfood/20260319-nvim-demo/dogfood.md new file mode 100644 index 0000000..1416b05 --- /dev/null +++ b/dogfood/20260319-nvim-demo/dogfood.md @@ -0,0 +1,6 @@ +# Dogfood Demo + +This file was created by agent-terminal driving neovim. +All keystrokes were sent via the agent-terminal CLI. + +Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ diff --git a/dogfood/20260319-nvim-demo/event-log.jsonl b/dogfood/20260319-nvim-demo/event-log.jsonl new file mode 100644 index 0000000..dfac3ce --- /dev/null +++ b/dogfood/20260319-nvim-demo/event-log.jsonl @@ -0,0 +1,65 @@ +{"seq":0,"ts":"2026-03-19T18:23:55.324Z","type":"output","payload":{"data":"\u001b[?1049h\u001b[22;0;0t\u001b[22;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b]11;?\u0007\u001b[?2004h\u001b[?u\u001b[c\u001b[?25h"}} +{"seq":1,"ts":"2026-03-19T18:23:55.368Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\u001b[H\u001b[2J\u001b[96m\" ============================================================================\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Netrw Directory Listing \u001b(B\u001b[0;1m\u001b[96m(netrw v171)\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" /home/coder/.mux/src/agent-terminal/planning-ws01\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Sorted by\u001b(B\u001b[m\u001b[93m name\u001b(B\u001b[m\u001b[K\r\n\u001b[96m\" Sort sequence:\u001b(B\u001b[m\u001b[93m [\\/]$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.h$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.c$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.cpp$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\~\\=\\*$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m*\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.o$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.obj$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.info$\u001b(B\u001b[m\u001b[96m,\u001b(B\u001b[m\u001b[93m\\.swp$\r\n\u001b(B\u001b[m\u001b[96m\" Quick Help: \u001b(B\u001b[0;1m\u001b[96m\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mhelp \u001b(B\u001b[0;1m\u001b[96m-\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mgo up dir \u001b(B\u001b[0;1m\u001b[96mD\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mdelete \u001b(B\u001b[0;1m\u001b[96mR\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mrename \u001b(B\u001b[0;1m\u001b[96ms\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[msort-by \u001b(B\u001b[0;1m\u001b[96mx\u001b(B\u001b[m\u001b[38;5;224m:\u001b(B\u001b[mspecial\u001b[K\r\n\u001b[96m\" ==============================================================================\u001b(B\u001b[m\u001b[K\r\n\u001b(B\u001b[0;4m\u001b[38;5;159m..\u001b(B\u001b[0;1;4m\u001b[96m/\u001b(B\u001b[0;4m \r\n\u001b(B\u001b[m\u001b[38;5;159m.\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159m.github\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159m.mux\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdesign\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdist\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mdogfood\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mnode_modules\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159msrc\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n\u001b[38;5;159mtest\u001b(B\u001b[0;1m\u001b[96m/\u001b(B\u001b[m\u001b[K\r\n.editorconfig\u001b[K\r\n.git\u001b[K\r\n.gitignore\u001b[K\r\n.prettierignore\u001b[K\r\n.prettierrc.json\u001b[K\r\n.tsconfig.build.tsbuildinfo\u001b[K\r\n.tsconfig.tsbuildinfo\u001b[K\r\nREADME.md\u001b[K\r\neslint.config.mjs\u001b[K\r\nmise.toml\u001b[K\r\npackage-lock.json\u001b[K\r\n\u001b(B\u001b[0;1;7m[No Name] [RO] 8,1 Top\u001b]112\u0007\u001b[2 q\u001b]112\u0007\u001b[2 q\r\u001b[21A\u001b[?25h"}} +{"seq":2,"ts":"2026-03-19T18:23:55.408Z","type":"output","payload":{"data":"\u001b[?25l\u001b[?1004h\u001b[?25h"}} +{"seq":3,"ts":"2026-03-19T18:24:34.585Z","type":"input_keys","payload":{"keys":["Escape"]}} +{"seq":4,"ts":"2026-03-19T18:24:34.635Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[m^[ \r\u001b[22A\u001b[?25h\u001b[?25l\u001b[30;90H \r\u001b[22A\u001b[?25h"}} +{"seq":5,"ts":"2026-03-19T18:24:35.206Z","type":"input_text","payload":{"data":":enew"}} +{"seq":6,"ts":"2026-03-19T18:24:35.207Z","type":"output","payload":{"data":"\u001b[?25l\u001b[22B\u001b[J:enew\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":7,"ts":"2026-03-19T18:24:35.840Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":8,"ts":"2026-03-19T18:24:35.840Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":9,"ts":"2026-03-19T18:24:35.841Z","type":"output","payload":{"data":"\u001b[?25l\u001b[H\u001b[78X\n\u001b[94m~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\r\n~\u001b[K\u001b(B\u001b[0;1;7m\u001b[29;11H \u001b[68C0,0-1 All\u001b]112\u0007\u001b[2 q\u001b[H\u001b[?25h"}} +{"seq":10,"ts":"2026-03-19T18:24:37.175Z","type":"input_text","payload":{"data":":file dogfood"}} +{"seq":11,"ts":"2026-03-19T18:24:37.176Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\u001b[29B\u001b[5X:file\u001b[Cdogfood\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":12,"ts":"2026-03-19T18:24:37.751Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":13,"ts":"2026-03-19T18:24:37.751Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":14,"ts":"2026-03-19T18:24:37.752Z","type":"output","payload":{"data":"\u001b[?25l\u001b[A\u001b(B\u001b[0;1;7mdogfood \u001b]112\u0007\u001b[2 q\u001b[H\u001b[?25h"}} +{"seq":15,"ts":"2026-03-19T18:24:55.669Z","type":"input_keys","payload":{"keys":["i"]}} +{"seq":16,"ts":"2026-03-19T18:24:55.670Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[mi\u001b[H\u001b[?25h\u001b[?25l\u001b[30;90H \u001b]112\u0007\u001b[6 q\u001b[H\u001b[?25h\u001b[?25l\u001b[29B\u001b(B\u001b[0;1m-- INSERT --\u001b(B\u001b[m \b\u001b[?25h\u001b[?25l\u001b[29;85H\u001b(B\u001b[0;1;7m1 \u001b[H\u001b[?25h"}} +{"seq":17,"ts":"2026-03-19T18:24:56.319Z","type":"input_text","payload":{"data":"# Dogfood Demo"}} +{"seq":18,"ts":"2026-03-19T18:24:56.319Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m# Dogfood Demo\u001b[29;9H\u001b(B\u001b[0;1;7m[+]\u001b[71C1,15\u001b[1;15H\u001b[?25h"}} +{"seq":19,"ts":"2026-03-19T18:24:56.985Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":20,"ts":"2026-03-19T18:24:56.985Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m2,1 \r\u001b[27A\u001b[?25h"}} +{"seq":21,"ts":"2026-03-19T18:24:57.575Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":22,"ts":"2026-03-19T18:24:57.575Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m3\r\u001b[26A\u001b[?25h"}} +{"seq":23,"ts":"2026-03-19T18:24:58.228Z","type":"input_text","payload":{"data":"This file was created by agent-terminal driving neovim."}} +{"seq":24,"ts":"2026-03-19T18:24:58.229Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mThis file was created by agent-terminal driving neovim.\u001b[29;85H\u001b(B\u001b[0;1;7m56\u001b[3;56H\u001b[?25h"}} +{"seq":25,"ts":"2026-03-19T18:24:58.852Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":26,"ts":"2026-03-19T18:24:58.853Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m4,1 \r\u001b[25A\u001b[?25h"}} +{"seq":27,"ts":"2026-03-19T18:24:59.542Z","type":"input_text","payload":{"data":"All keystrokes were sent via the agent-terminal CLI."}} +{"seq":28,"ts":"2026-03-19T18:24:59.542Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mAll keystrokes were sent via the agent-terminal CLI.\u001b[29;85H\u001b(B\u001b[0;1;7m53\u001b[4;53H\u001b[?25h"}} +{"seq":29,"ts":"2026-03-19T18:25:00.177Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":30,"ts":"2026-03-19T18:25:00.178Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m5,1 \r\u001b[24A\u001b[?25h"}} +{"seq":31,"ts":"2026-03-19T18:25:00.797Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":32,"ts":"2026-03-19T18:25:00.798Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\n\u001b[K\u001b[29;83H\u001b(B\u001b[0;1;7m6\r\u001b[23A\u001b[?25h"}} +{"seq":33,"ts":"2026-03-19T18:25:01.417Z","type":"input_text","payload":{"data":"Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ"}} +{"seq":34,"ts":"2026-03-19T18:25:01.418Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[mSession ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ\u001b[29;85H\u001b(B\u001b[0;1;7m39\u001b[6;39H\u001b[?25h"}} +{"seq":35,"ts":"2026-03-19T18:25:15.048Z","type":"input_keys","payload":{"keys":["Escape"]}} +{"seq":36,"ts":"2026-03-19T18:25:15.099Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[12X\u001b[29;86H\u001b(B\u001b[0;1;7m8\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":37,"ts":"2026-03-19T18:25:16.377Z","type":"input_text","payload":{"data":":w"}} +{"seq":38,"ts":"2026-03-19T18:25:16.377Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[24B\u001b(B\u001b[m:w\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":39,"ts":"2026-03-19T18:25:16.987Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":40,"ts":"2026-03-19T18:25:16.987Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":41,"ts":"2026-03-19T18:25:16.987Z","type":"output","payload":{"data":"\u001b[?25l\u001b[97m\u001b[41mE17: \"/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood\" is a directory\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":42,"ts":"2026-03-19T18:25:31.906Z","type":"input_text","payload":{"data":":file dogfood.md"}} +{"seq":43,"ts":"2026-03-19T18:25:31.906Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[79X:file\u001b[Cdogfood.md\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":44,"ts":"2026-03-19T18:25:32.480Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":45,"ts":"2026-03-19T18:25:32.481Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":46,"ts":"2026-03-19T18:25:32.481Z","type":"output","payload":{"data":"\u001b[?25l\u001b[29;8H\u001b(B\u001b[0;1;7m.md [+]\u001b]112\u0007\u001b[2 q\u001b[6;38H\u001b[?25h"}} +{"seq":47,"ts":"2026-03-19T18:25:33.779Z","type":"input_text","payload":{"data":":w"}} +{"seq":48,"ts":"2026-03-19T18:25:33.779Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[24B\u001b[16X:w\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":49,"ts":"2026-03-19T18:25:34.443Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":50,"ts":"2026-03-19T18:25:34.443Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":51,"ts":"2026-03-19T18:25:34.443Z","type":"output","payload":{"data":"\u001b[?25l\"dogfood.md\"\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":52,"ts":"2026-03-19T18:25:34.444Z","type":"output","payload":{"data":"\u001b[?25l\u001b[C[New] 6L, 165B written\u001b(B\u001b[0;1;7m\u001b[29;12H \u001b[6;38H\u001b[?25h"}} +{"seq":53,"ts":"2026-03-19T18:25:48.912Z","type":"input_keys","payload":{"keys":["g"]}} +{"seq":54,"ts":"2026-03-19T18:25:48.912Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H\u001b(B\u001b[mg\u001b[6;38H\u001b[?25h"}} +{"seq":55,"ts":"2026-03-19T18:25:49.539Z","type":"input_keys","payload":{"keys":["g"]}} +{"seq":56,"ts":"2026-03-19T18:25:49.540Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H \u001b[6;38H\u001b[?25h\u001b[?25l\u001b[30;90Hgg\u001b[6;38H\u001b[?25h"}} +{"seq":57,"ts":"2026-03-19T18:25:49.540Z","type":"output","payload":{"data":"\u001b[?25l\u001b[30;90H \u001b[29;83H\u001b(B\u001b[0;1;7m1,14\u001b[1;14H\u001b[?25h"}} +{"seq":58,"ts":"2026-03-19T18:25:57.400Z","type":"input_text","payload":{"data":":q"}} +{"seq":59,"ts":"2026-03-19T18:25:57.400Z","type":"output","payload":{"data":"\u001b[?25l\u001b(B\u001b[m\r\u001b[29B\u001b[35X:q\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":60,"ts":"2026-03-19T18:25:58.055Z","type":"input_keys","payload":{"keys":["Enter"]}} +{"seq":61,"ts":"2026-03-19T18:25:58.055Z","type":"output","payload":{"data":"\u001b[?25l\r\u001b[30;1H\u001b[?25h"}} +{"seq":62,"ts":"2026-03-19T18:25:58.067Z","type":"output","payload":{"data":"\u001b[?25l\u001b]112\u0007\u001b[2 q\u001b[?25h"}} +{"seq":63,"ts":"2026-03-19T18:25:58.067Z","type":"output","payload":{"data":"\u001b[?25l\u001b]112\u0007\u001b[2 q\u001b(B\u001b[m\u001b[?25h\u001b[?1l\u001b>\u001b[?1049l\u001b[23;0;0t\u001b[23;0t\u001b[?2004l\u001b[?1004l\u001b[?25h"}} +{"seq":64,"ts":"2026-03-19T18:25:58.079Z","type":"exit","payload":{"exitCode":0,"exitSignal":null}} diff --git a/dogfood/20260319-nvim-demo/inspect-final.json b/dogfood/20260319-nvim-demo/inspect-final.json new file mode 100644 index 0000000..48eca06 --- /dev/null +++ b/dogfood/20260319-nvim-demo/inspect-final.json @@ -0,0 +1,25 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-19T18:26:09.136Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM3NDZK4TXG5C3SQJ811ZGVJ", + "createdAt": "2026-03-19T18:23:54.983Z", + "updatedAt": "2026-03-19T18:25:58.079Z", + "status": "exited", + "command": [ + "nvim", + "." + ], + "cwd": "/home/coder/.mux/src/agent-terminal/planning-ws01", + "cols": 100, + "rows": 30, + "hostPid": 347474, + "childPid": 347584, + "exitCode": 0, + "exitSignal": null + } + } +} diff --git a/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt b/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt new file mode 100644 index 0000000..f990a56 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/01-nvim-launched.txt @@ -0,0 +1,30 @@ +=== Step 1: nvim launched with netrw directory listing === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top diff --git a/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt b/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt new file mode 100644 index 0000000..f9aaa4b --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/02-new-buffer.txt @@ -0,0 +1,57 @@ +=== Step 2: New empty buffer named 'dogfood' === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood diff --git a/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt b/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt new file mode 100644 index 0000000..e464ff8 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/03-content-typed.txt @@ -0,0 +1,62 @@ +=== Step 3: Content typed in insert mode === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 diff --git a/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt b/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt new file mode 100644 index 0000000..f52ad04 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/04-file-saved.txt @@ -0,0 +1,62 @@ +=== Step 4: File saved as dogfood.md with :w === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written diff --git a/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt b/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt new file mode 100644 index 0000000..b18e4aa --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/05-gg-top.txt @@ -0,0 +1,62 @@ +=== Step 5: Cursor at top of file (gg motion) === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written g gg 1,14 diff --git a/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt b/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt new file mode 100644 index 0000000..a434879 --- /dev/null +++ b/dogfood/20260319-nvim-demo/screenshots/06-nvim-quit.txt @@ -0,0 +1,62 @@ +=== Step 6: nvim exited cleanly (exit code 0) === +" ============================================================================ +" Netrw Directory Listing (netrw v171) +" /home/coder/.mux/src/agent-terminal/planning-ws01 +" Sorted by name +" Sort sequence: [\/]$,\,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$ +" Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special +" ============================================================================== +../ +./ +.github/ +.mux/ +design/ +dist/ +dogfood/ +node_modules/ +src/ +test/ +.editorconfig +.git +.gitignore +.prettierignore +.prettierrc.json +.tsconfig.build.tsbuildinfo +.tsconfig.tsbuildinfo +README.md +eslint.config.mjs +mise.toml +package-lock.json +[No Name] [RO] 8,1 Top ^[ :enew +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ 0,0-1 All:filedogfood dogfood i -- INSERT -- 1 # Dogfood Demo[+]1,15 +2,1 +3 This file was created by agent-terminal driving neovim.56 +4,1 All keystrokes were sent via the agent-terminal CLI.53 +5,1 +6 Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ39 8 :w E17: "/home/coder/.mux/src/agent-terminal/planning-ws01/dogfood" is a directory :filedogfood.md .md [+] :w "dogfood.md"[New] 6L, 165B written g gg 1,14 :q From ffe7f796ab4928a3af679a3443488bfa55721cf6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 18:28:58 +0000 Subject: [PATCH 22/38] Add nvim dogfood demo proof bundle --- dogfood/20260319-nvim-demo/manifest.json | 26 +++++++ dogfood/20260319-nvim-demo/notes.md | 87 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 dogfood/20260319-nvim-demo/manifest.json create mode 100644 dogfood/20260319-nvim-demo/notes.md diff --git a/dogfood/20260319-nvim-demo/manifest.json b/dogfood/20260319-nvim-demo/manifest.json new file mode 100644 index 0000000..27cc7df --- /dev/null +++ b/dogfood/20260319-nvim-demo/manifest.json @@ -0,0 +1,26 @@ +{ + "scenario": "nvim-dogfood", + "date": "2026-03-19", + "sessionId": "01KM3NDZK4TXG5C3SQJ811ZGVJ", + "command": ["nvim", "."], + "dimensions": { "cols": 100, "rows": 30 }, + "cliCommandsUsed": ["create", "wait", "send-keys", "type", "inspect"], + "vimMotionsUsed": [ + ":enew", + ":file", + "i (insert mode)", + ":w (save)", + "gg (go to top)", + ":q (quit)", + "Escape" + ], + "result": "pass", + "exitCode": 0, + "eventCount": 65, + "fileCreated": "dogfood.md", + "knownGaps": [ + "renderer screenshots (text-only snapshots from event log)", + "asciicast export", + "gc command" + ] +} diff --git a/dogfood/20260319-nvim-demo/notes.md b/dogfood/20260319-nvim-demo/notes.md new file mode 100644 index 0000000..a18e467 --- /dev/null +++ b/dogfood/20260319-nvim-demo/notes.md @@ -0,0 +1,87 @@ +# Nvim Dogfood Demo — agent-terminal Week 1 + +- **Date:** 2026-03-19 +- **Scenario:** Driving `neovim` entirely through the `agent-terminal` CLI +- **Session ID:** `01KM3NDZK4TXG5C3SQJ811ZGVJ` +- **Command:** `nvim .` +- **Dimensions:** `100x30` +- **Created:** `2026-03-19T18:23:54.983Z` +- **Exited:** `2026-03-19T18:25:58.079Z` +- **Exit code:** `0` +- **Overall result:** pass + +## Scenario summary + +This proof bundle demonstrates that the Week 1 control plane can drive a complex, modal, full-screen terminal application rather than only narrow fixture programs. In this run, `agent-terminal` launched `neovim`, opened a new buffer, named it, entered insert mode, typed multi-line Markdown content, saved the file, navigated with a real Vim motion (`gg`), and exited cleanly. + +That combination matters because `nvim` exercises several properties at once: full-screen terminal rendering, modal input handling, command-line mode, text insertion, file saving, cursor movement, and orderly process exit. The run therefore acts as a stronger dogfood demo than a simple line-oriented prompt loop. + +## Session metadata + +| Field | Value | +| ----------- | ---------------------------- | +| Session ID | `01KM3NDZK4TXG5C3SQJ811ZGVJ` | +| Command | `nvim .` | +| Dimensions | `100 cols x 30 rows` | +| Created | `2026-03-19T18:23:54.983Z` | +| Exited | `2026-03-19T18:25:58.079Z` | +| Exit status | `exited` | +| Exit code | `0` | + +`inspect-final.json` is the final machine-readable confirmation that the session exited normally with code `0`. + +## Step-by-step walkthrough + +| Step | CLI action | What it did | Expected evidence | +| ---- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| 1 | `create --cols 100 --rows 30 -- nvim .` | Started a new session and launched `nvim` against the current directory. | `01-nvim-launched.txt` should show the initial netrw directory listing inside Neovim. | +| 2 | `send-keys Escape` | Forced normal mode before issuing editor commands. | The session remains in Neovim and is ready for command-mode input. | +| 3 | `type ":enew"` + `send-keys Enter` | Created a fresh empty buffer. | `02-new-buffer.txt` should show an empty buffer named `dogfood`. | +| 4 | `type ":file dogfood"` + `send-keys Enter` | Assigned the new buffer an initial name. | The buffer name changes to `dogfood`, but this later proves ambiguous because a `dogfood/` directory already exists. | +| 5 | `send-keys i` | Entered INSERT mode. | Neovim is ready to accept literal text input. | +| 6 | `type "# Dogfood Demo"` + `send-keys Enter` | Wrote the Markdown heading. | The first line of the document is populated. | +| 7 | `type "This file was created by agent-terminal driving neovim."` + `send-keys Enter` | Added the first explanatory sentence. | The second line appears beneath the heading. | +| 8 | `type "All keystrokes were sent via the agent-terminal CLI."` + `send-keys Enter` | Added the second explanatory sentence. | The third line appears in the buffer. | +| 9 | `type "Session ID: 01KM3NDZK4TXG5C3SQJ811ZGVJ"` | Added the run-specific session identifier to the file contents. | The fourth content line includes the exact session ID used for the demo. | +| 10 | `send-keys Escape` | Returned to normal mode. | Insert mode ends so Ex commands can be issued again. | +| 11 | `type ":file dogfood.md"` + `send-keys Enter` | Renamed the buffer to `dogfood.md`. | This is the real-world correction after discovering that `dogfood` conflicts with an existing directory name. | +| 12 | `type ":w"` + `send-keys Enter` | Saved the file to disk. | `04-file-saved.txt` should show the successful write message: `"dogfood.md" [New] 6L, 165B written`. | +| 13 | `send-keys g` + `send-keys g` | Executed the Vim motion `gg` to jump to the top of the file. | `05-gg-top.txt` should show the cursor positioned back at line 1. | +| 14 | `type ":q"` + `send-keys Enter` | Quit Neovim. | `06-nvim-quit.txt` should show the post-exit terminal state. | +| 15 | `wait --exit` | Waited for process termination. | The session exits cleanly without timing out. | +| 16 | `inspect` | Collected final session state. | `inspect-final.json` should report `status: "exited"` and `exitCode: 0`. | + +## Screenshot review guide + +The screenshot artifacts for this bundle are text snapshots captured from the event log rather than renderer-produced terminal frames. + +| File | What the reviewer should observe | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `screenshots/01-nvim-launched.txt` | Neovim has launched successfully and is showing the netrw directory browser for the current working directory. | +| `screenshots/02-new-buffer.txt` | An empty buffer is open after `:enew`, with the provisional name `dogfood`. | +| `screenshots/03-content-typed.txt` | The Markdown content has been typed into the buffer while in INSERT mode. | +| `screenshots/04-file-saved.txt` | The status area confirms the save succeeded as `dogfood.md` with `6L, 165B written`. | +| `screenshots/05-gg-top.txt` | The `gg` motion has moved the cursor to the top of the file. | +| `screenshots/06-nvim-quit.txt` | Neovim has exited, demonstrating clean control handoff back to the terminal session. | + +## Event log observations + +- `event-log.jsonl` contains **65 events** for this run. +- The log spans the important interaction categories for an editor demo: terminal `output`, typed text via `input_text`, individual keypresses via `input_keys`, and the final `exit` record. +- That coverage is important because it shows the control plane is not faking a one-shot file write; it is actually driving the interactive program through the same primitives exposed by the CLI. +- The final session result is corroborated by `inspect-final.json`, which records an exited session with exit code `0`. + +## Real-world debugging note: `dogfood` -> `dogfood.md` + +One useful detail from this run is that the first filename choice (`dogfood`) had to be corrected to `dogfood.md` because the repository already contains a `dogfood/` directory. That small rename is worth preserving in the notes because it shows the demo was interactive and realistic: the operator hit an ordinary naming conflict, adjusted the buffer name, and continued successfully. + +## Known gaps + +- The screenshot artifacts are **text snapshots derived from the event log**, not rendered terminal frames. +- A renderer-backed screenshot path is not implemented yet, so this bundle does not include pixel-faithful terminal captures. +- No asciicast export is included yet. +- The `gc` command is still out of scope for this bundle. + +## Conclusion + +This demo proves that the Week 1 `agent-terminal` control plane can drive a sophisticated interactive terminal program like Neovim end to end: launch it, switch modes, enter text, issue editor commands, save a file, navigate with Vim motions, and exit cleanly. Even without a renderer-backed screenshot pipeline, the combination of the session metadata, event log, final inspection output, and text-based screenshots is strong evidence that the control plane works on real terminal software rather than only on purpose-built fixtures. From 32a1f6376c5c2cb2bb938c0a12a707828e2b4532 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:17:39 +0000 Subject: [PATCH 23/38] Drain event log writes before close --- src/host/eventLog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 7dd1489..f8d71a9 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -236,7 +236,8 @@ export class EventLog { async close(): Promise { invariant(!this.isClosed, 'event log is already closed'); - + // Drain any in-flight append writes before closing the file. + await this.writeQueue; await this.fileHandle.sync(); await this.fileHandle.close(); this.isClosed = true; From 20f6c825300fba0ff72e40ca59bb09b0fa8b8c9f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:18:41 +0000 Subject: [PATCH 24/38] Tighten protocol message schemas --- src/host/hostMain.ts | 2 +- src/protocol/messages.ts | 11 ++++++-- test/unit/protocol/messages.test.ts | 43 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index b145da3..740e4c4 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -264,7 +264,7 @@ export async function runHost(sessionId: string): Promise { state.setDimensions(cols, rows); await writeManifest(mPath, state.snapshot()); await eventLog.append('resize', { cols, rows }); - return {}; + return { cols, rows }; }, signal: async (params: unknown) => { const { signal } = params as SignalParams; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index b171e9d..5663db6 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -4,7 +4,7 @@ import { SessionRecordSchema } from './schemas.js'; const EmptyObjectSchema = z.object({}).strict(); const NonEmptyStringSchema = z.string().min(1); -const DurationSchema = z.number().int().nonnegative(); +const DurationSchema = z.number().int().positive(); export const RpcRequestSchema = z .object({ @@ -69,7 +69,7 @@ export type TypeResult = z.infer; export const PasteParamsSchema = z .object({ - text: z.string(), + text: z.string().min(1), }) .strict(); export type PasteParams = z.infer; @@ -95,7 +95,12 @@ export const ResizeParamsSchema = z .strict(); export type ResizeParams = z.infer; -export const ResizeResultSchema = EmptyObjectSchema; +export const ResizeResultSchema = z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive(), + }) + .strict(); export type ResizeResult = z.infer; export const SignalParamsSchema = z diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index 3162b4a..4c8c010 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from 'vitest'; import { DestroyParamsSchema, InspectResultSchema, + PasteParamsSchema, + ResizeResultSchema, RpcMethodSchemas, RpcRequestSchema, RpcResponseSchema, SendKeysParamsSchema, + WaitParamsSchema, WaitResultSchema, } from '../../../src/protocol/messages.js'; import { @@ -140,6 +143,46 @@ describe('RPC message schemas', () => { expect(result.success).toBe(false); }); + it('rejects empty paste text', () => { + const result = PasteParamsSchema.safeParse({ + text: '', + }); + + expect(result.success).toBe(false); + }); + + it('rejects zero-valued wait durations', () => { + expect( + WaitParamsSchema.safeParse({ + idleMs: 0, + }).success, + ).toBe(false); + expect( + WaitParamsSchema.safeParse({ + timeoutMs: 0, + }).success, + ).toBe(false); + }); + + it('accepts resize results with positive dimensions', () => { + const result = ResizeResultSchema.safeParse({ + cols: 120, + rows: 40, + }); + + expect(result.success).toBe(true); + }); + + it('rejects resize results without positive dimensions', () => { + expect(ResizeResultSchema.safeParse({}).success).toBe(false); + expect( + ResizeResultSchema.safeParse({ + cols: 0, + rows: 40, + }).success, + ).toBe(false); + }); + it('rejects invalid wait result exit codes', () => { const result = WaitResultSchema.safeParse({ exitCode: 2.5, From 1a433c4f4c5a27555c1f171b9e51b8519d749e8c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:17:04 +0000 Subject: [PATCH 25/38] fix: rename lifecycle proof screenshots to svg --- .../screenshots/{01-create.png => 01-create.svg} | 0 .../20260319-lifecycle/screenshots/{02-list.png => 02-list.svg} | 0 .../screenshots/{03-after-input.png => 03-after-input.svg} | 0 .../screenshots/{04-inspect.png => 04-inspect.svg} | 0 .../screenshots/{05-resize.png => 05-resize.svg} | 0 .../screenshots/{06-destroy.png => 06-destroy.svg} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename dogfood/20260319-lifecycle/screenshots/{01-create.png => 01-create.svg} (100%) rename dogfood/20260319-lifecycle/screenshots/{02-list.png => 02-list.svg} (100%) rename dogfood/20260319-lifecycle/screenshots/{03-after-input.png => 03-after-input.svg} (100%) rename dogfood/20260319-lifecycle/screenshots/{04-inspect.png => 04-inspect.svg} (100%) rename dogfood/20260319-lifecycle/screenshots/{05-resize.png => 05-resize.svg} (100%) rename dogfood/20260319-lifecycle/screenshots/{06-destroy.png => 06-destroy.svg} (100%) diff --git a/dogfood/20260319-lifecycle/screenshots/01-create.png b/dogfood/20260319-lifecycle/screenshots/01-create.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/01-create.png rename to dogfood/20260319-lifecycle/screenshots/01-create.svg diff --git a/dogfood/20260319-lifecycle/screenshots/02-list.png b/dogfood/20260319-lifecycle/screenshots/02-list.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/02-list.png rename to dogfood/20260319-lifecycle/screenshots/02-list.svg diff --git a/dogfood/20260319-lifecycle/screenshots/03-after-input.png b/dogfood/20260319-lifecycle/screenshots/03-after-input.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/03-after-input.png rename to dogfood/20260319-lifecycle/screenshots/03-after-input.svg diff --git a/dogfood/20260319-lifecycle/screenshots/04-inspect.png b/dogfood/20260319-lifecycle/screenshots/04-inspect.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/04-inspect.png rename to dogfood/20260319-lifecycle/screenshots/04-inspect.svg diff --git a/dogfood/20260319-lifecycle/screenshots/05-resize.png b/dogfood/20260319-lifecycle/screenshots/05-resize.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/05-resize.png rename to dogfood/20260319-lifecycle/screenshots/05-resize.svg diff --git a/dogfood/20260319-lifecycle/screenshots/06-destroy.png b/dogfood/20260319-lifecycle/screenshots/06-destroy.svg similarity index 100% rename from dogfood/20260319-lifecycle/screenshots/06-destroy.png rename to dogfood/20260319-lifecycle/screenshots/06-destroy.svg From 1e3085a8dda7378ebff4ae7ac887cf5ac559ef3f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:19:10 +0000 Subject: [PATCH 26/38] test: cover wait timeout result --- test/integration/io-loop.test.ts | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index b01559d..1d7df85 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -222,6 +222,44 @@ describe('io-loop integration', { timeout: 30000 }, () => { } }); + it('wait --idle-ms times out when timeout expires first', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec cat']); + await sleep(500); + + const typeResult = runCli(['type', sessionId, 'keepalive', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + + const sendKeysResult = runCli( + ['send-keys', sessionId, 'Enter', '--json'], + { + AGENT_TERMINAL_HOME: testHome, + }, + ); + expect(sendKeysResult.status).toBe(0); + expect(sendKeysResult.stderr).toBe(''); + + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '60000', '--timeout', '300', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(true); + } finally { + destroySession(testHome, sessionId); + } + }); + it('signal SIGTERM terminates session', async () => { let sessionId = ''; From eb4317c281eeb3512b72975048c8b491659930c1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:23:36 +0000 Subject: [PATCH 27/38] Fix create CLI error handling --- src/cli/commands/create.ts | 31 ++++++++++++----- src/cli/main.ts | 4 +-- src/host/lifecycle.ts | 68 +++++++++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index 44d7b0c..cc46f50 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -35,15 +35,30 @@ interface CommandOptions { } export async function runCreateCommand(options: CommandOptions): Promise { - const { sessionId } = await allocateSession({ - command: options.command, - shellCommand: options.shellCommand, - cwd: options.cwd, - cols: options.cols, - rows: options.rows, - }); + let sessionId: string; + + try { + const allocatedSession = await allocateSession({ + command: options.command, + shellCommand: options.shellCommand, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); + sessionId = allocatedSession.sessionId; + + launchHost(sessionId); + } catch (error) { + if (error instanceof CliError) { + throw error; + } - launchHost(sessionId); + throw makeCliError(ERROR_CODES.INTERNAL_ERROR, { + message: + error instanceof Error ? error.message : 'Failed to create session.', + cause: error, + }); + } const home = resolveHome(); const sessionDirectory = sessionDir(home, sessionId); diff --git a/src/cli/main.ts b/src/cli/main.ts index 983ab7d..63e04a2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -26,7 +26,7 @@ function parseIntegerOption(value: string): number { async function main(): Promise { const program = new Command() .name('agent-terminal') - .description('Terminal CLI') + .description('CLI for managing and controlling terminal sessions') .showHelpAfterError(); program @@ -51,7 +51,7 @@ async function main(): Promise { .description('Create a session') .option( '--command ', - 'Command string to run (defaults to the user shell)', + 'Shell executable (defaults to $SHELL or sh)', process.env.SHELL ?? process.env.ComSpec ?? 'sh', ) .option('--cwd ', 'Working directory', process.cwd()) diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts index 6e60b13..96fbf95 100644 --- a/src/host/lifecycle.ts +++ b/src/host/lifecycle.ts @@ -58,6 +58,32 @@ function hasErrorCode(error: unknown, code: string): boolean { return isNodeError(error) && error.code === code; } +function makeInvalidDimensionError( + label: 'cols' | 'rows', + value: unknown, +): CliError { + return makeCliError(ERROR_CODES.INVALID_DIMENSIONS, { + message: `${label} must be a positive integer, got: ${String(value)}`, + details: { + [label]: value, + }, + }); +} + +function makeInvalidCwdError( + cwd: unknown, + cause?: unknown, +): CliError { + return makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: + typeof cwd === 'string' && cwd.length > 0 + ? `Working directory does not exist or is not accessible: ${cwd}` + : 'Working directory must be a non-empty string.', + details: { cwd }, + cause, + }); +} + function assertPositiveInteger(value: number, label: string): void { invariant( Number.isInteger(value) && value > 0, @@ -250,9 +276,23 @@ export async function allocateSession( 'config must be an object', ); invariant(Array.isArray(config.command), 'command must be an array'); - assertNonEmptyString(config.cwd, 'cwd'); - assertPositiveInteger(config.cols, 'cols'); - assertPositiveInteger(config.rows, 'rows'); + if ( + typeof config.cols !== 'number' || + !Number.isInteger(config.cols) || + config.cols <= 0 + ) { + throw makeInvalidDimensionError('cols', config.cols); + } + if ( + typeof config.rows !== 'number' || + !Number.isInteger(config.rows) || + config.rows <= 0 + ) { + throw makeInvalidDimensionError('rows', config.rows); + } + if (typeof config.cwd !== 'string' || config.cwd.length === 0) { + throw makeInvalidCwdError(config.cwd); + } const sessionId = ulid(); assertNonEmptyString(sessionId, 'sessionId'); @@ -262,11 +302,23 @@ export async function allocateSession( await mkdir(sessionDirectory, { recursive: true }); const resolvedCwd = resolve(config.cwd); - const cwdStats = await stat(resolvedCwd); - invariant( - cwdStats.isDirectory(), - 'cwd must resolve to an existing directory', - ); + try { + const cwdStats = await stat(resolvedCwd); + invariant( + cwdStats.isDirectory(), + 'cwd must resolve to an existing directory', + ); + } catch (error) { + if (error instanceof CliError) { + throw error; + } + + throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: `Working directory does not exist or is not accessible: ${resolvedCwd}`, + details: { cwd: resolvedCwd }, + cause: error, + }); + } const effectiveCommand = config.command.length > 0 ? [...config.command] : [config.shellCommand]; From ee54509bd40377dbc2e30eb455ad061292f38bda Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:24:10 +0000 Subject: [PATCH 28/38] Fix formatting for review fix commits --- dogfood/20260319-nvim-demo/inspect-final.json | 5 +---- src/host/lifecycle.ts | 5 +---- test/integration/io-loop.test.ts | 4 +++- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/dogfood/20260319-nvim-demo/inspect-final.json b/dogfood/20260319-nvim-demo/inspect-final.json index 48eca06..80a3205 100644 --- a/dogfood/20260319-nvim-demo/inspect-final.json +++ b/dogfood/20260319-nvim-demo/inspect-final.json @@ -9,10 +9,7 @@ "createdAt": "2026-03-19T18:23:54.983Z", "updatedAt": "2026-03-19T18:25:58.079Z", "status": "exited", - "command": [ - "nvim", - "." - ], + "command": ["nvim", "."], "cwd": "/home/coder/.mux/src/agent-terminal/planning-ws01", "cols": 100, "rows": 30, diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts index 96fbf95..c0c2623 100644 --- a/src/host/lifecycle.ts +++ b/src/host/lifecycle.ts @@ -70,10 +70,7 @@ function makeInvalidDimensionError( }); } -function makeInvalidCwdError( - cwd: unknown, - cause?: unknown, -): CliError { +function makeInvalidCwdError(cwd: unknown, cause?: unknown): CliError { return makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { message: typeof cwd === 'string' && cwd.length > 0 diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index 1d7df85..0be39ea 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -252,7 +252,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(envelope.ok).toBe(true); expect(envelope.result.timedOut).toBe(true); } finally { From 6089c24cdaa6c7d010d9d6812e0b9dca3927fe00 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:53:41 +0000 Subject: [PATCH 29/38] Fix idle wait and signal race handling --- src/host/hostMain.ts | 14 +++++++++++++- src/host/rpcServer.ts | 6 ++++++ test/integration/io-loop.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 740e4c4..12c6384 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -294,6 +294,16 @@ export async function runHost(sessionId: string): Promise { childPid !== null && childPid > 0, 'child PID must be set for signal delivery', ); + + try { + process.kill(childPid, 0); + } catch { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Child process is no longer running.', + details: { childPid }, + }); + } + process.kill(childPid, signal as (typeof ALLOWED_SIGNALS)[number]); await eventLog.append('signal', { signal }); @@ -357,10 +367,12 @@ export async function runHost(sessionId: string): Promise { 'idleMs must be a positive integer', ); + const idleAnchor = Date.now(); waitCondition = new Promise((resolve) => { const checkInterval = setInterval( () => { - const elapsed = Date.now() - lastOutputAt; + const effectiveLastOutput = Math.max(lastOutputAt, idleAnchor); + const elapsed = Date.now() - effectiveLastOutput; if (elapsed < idleDuration) { return; } diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index 19018b3..5971130 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -12,6 +12,8 @@ import { } from '../protocol/messages.js'; import { invariant } from '../util/assert.js'; +const MAX_UNIX_SOCKET_PATH = 104; + const UNKNOWN_REQUEST_ID = 'unknown'; export type MethodHandler = (params: unknown) => Promise; @@ -158,6 +160,10 @@ export class RpcServer { invariant(this.server === null, 'RPC server is already listening.'); await this.removeStaleSocketIfNeeded(); + invariant( + this.socketPath.length <= MAX_UNIX_SOCKET_PATH, + `Socket path exceeds Unix domain socket limit of ${String(MAX_UNIX_SOCKET_PATH)} bytes: ${this.socketPath} (${String(this.socketPath.length)} bytes)`, + ); invariant( !(await socketPathExists(this.socketPath)), `RPC socket path must not exist before listen(): ${this.socketPath}`, diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index 0be39ea..be12575 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -222,6 +222,32 @@ describe('io-loop integration', { timeout: 30000 }, () => { } }); + it('wait --idle-ms measures idle from call time, not host startup', async () => { + let sessionId = ''; + + try { + sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec cat']); + await sleep(2000); + + const start = Date.now(); + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '1000', '--timeout', '5000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 30000, + ); + const elapsed = Date.now() - start; + + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + expect(elapsed).toBeGreaterThan(800); + } finally { + destroySession(testHome, sessionId); + } + }); + it('wait --idle-ms times out when timeout expires first', async () => { let sessionId = ''; From 684de3a9c37450373e312c3c3b28262a060e6740 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 10:54:13 +0000 Subject: [PATCH 30/38] Fix formatting for idle-wait fix --- test/integration/io-loop.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index be12575..7af3b44 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -239,7 +239,9 @@ describe('io-loop integration', { timeout: 30000 }, () => { expect(waitResult.status).toBe(0); expect(waitResult.stderr).toBe(''); - const envelope = JSON.parse(waitResult.stdout) as SuccessEnvelope; + const envelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; expect(envelope.ok).toBe(true); expect(envelope.result.timedOut).toBe(false); expect(elapsed).toBeGreaterThan(800); From e314d3ac616782b58a46fb57ca4d6096325f56d7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 11:55:08 +0000 Subject: [PATCH 31/38] Add RPC buffer size limits --- src/host/rpcClient.ts | 11 +++++++++++ src/host/rpcServer.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/host/rpcClient.ts b/src/host/rpcClient.ts index 7bdbb40..e1bab2f 100644 --- a/src/host/rpcClient.ts +++ b/src/host/rpcClient.ts @@ -16,6 +16,7 @@ import { import { invariant } from '../util/assert.js'; const DEFAULT_TIMEOUT_MS = 5_000; +const MAX_RPC_BUFFER_BYTES = 1_048_576; const HOST_UNREACHABLE_SOCKET_CODES = new Set([ 'ECONNREFUSED', 'ENOENT', @@ -174,6 +175,16 @@ export async function sendRpc( return; } + if (buffer.length + chunk.length > MAX_RPC_BUFFER_BYTES) { + rejectWithCliError( + makeCliError(ERROR_CODES.RPC_ERROR, { + message: 'RPC response exceeds maximum buffer size.', + details: { method, socketPath }, + }), + ); + return; + } + buffer += chunk; const newlineIndex = buffer.indexOf('\n'); diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index 5971130..873a523 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -13,6 +13,7 @@ import { import { invariant } from '../util/assert.js'; const MAX_UNIX_SOCKET_PATH = 104; +const MAX_RPC_BUFFER_BYTES = 1_048_576; const UNKNOWN_REQUEST_ID = 'unknown'; @@ -262,6 +263,18 @@ export class RpcServer { return; } + if (buffer.length + chunk.length > MAX_RPC_BUFFER_BYTES) { + handled = true; + this.sendResponse( + socket, + buildErrorResponse( + extractRequestId(undefined), + 'RPC request exceeds maximum buffer size.', + ), + ); + return; + } + buffer += chunk; const newlineIndex = buffer.indexOf('\n'); From baba7ffc3e6588b9e367515ed63d7cfbeae801e2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 11:55:10 +0000 Subject: [PATCH 32/38] Fix session cleanup on launch failure --- src/cli/commands/create.ts | 11 ++++++++++- src/host/lifecycle.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index cc46f50..ab6917d 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -1,3 +1,4 @@ +import { rm } from 'node:fs/promises'; import { setTimeout as delay } from 'node:timers/promises'; import { CliError } from '../errors.js'; @@ -35,7 +36,7 @@ interface CommandOptions { } export async function runCreateCommand(options: CommandOptions): Promise { - let sessionId: string; + let sessionId: string | undefined; try { const allocatedSession = await allocateSession({ @@ -49,6 +50,14 @@ export async function runCreateCommand(options: CommandOptions): Promise { launchHost(sessionId); } catch (error) { + if (sessionId !== undefined) { + const home = resolveHome(); + await rm(sessionDir(home, sessionId), { + recursive: true, + force: true, + }).catch(() => undefined); + } + if (error instanceof CliError) { throw error; } diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts index c0c2623..d30858c 100644 --- a/src/host/lifecycle.ts +++ b/src/host/lifecycle.ts @@ -535,6 +535,10 @@ export async function reconcileSession( return; } + if (manifest.childPid !== null && isProcessAlive(manifest.childPid)) { + killProcessBestEffort(manifest.childPid); + } + const reconciledManifest: SessionRecord = { ...manifest, status: 'exited', From b0fe29321b28b0a3a2e4e2626bc153025e41caa1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 11:55:19 +0000 Subject: [PATCH 33/38] Set default wait timeout and reject empty type text --- src/cli/commands/wait.ts | 15 +++++++++++---- src/protocol/messages.ts | 2 +- test/unit/protocol/messages.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index ce27072..dd8de0b 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -22,6 +22,8 @@ interface CommandOptions { timeout: number | undefined; } +const DEFAULT_WAIT_TIMEOUT_MS = 600_000; + function isPositiveInteger(value: number | undefined): value is number { return value !== undefined && Number.isInteger(value) && value > 0; } @@ -70,9 +72,13 @@ export async function runWaitCommand(options: CommandOptions): Promise { }); } - if (options.timeout !== undefined && !isPositiveInteger(options.timeout)) { + if ( + options.timeout !== undefined && + options.timeout !== 0 && + !isPositiveInteger(options.timeout) + ) { throw makeCliError(ERROR_CODES.INVALID_DURATION, { - message: '--timeout must be a positive integer.', + message: '--timeout must be a non-negative integer (0 for infinite).', details: { timeout: options.timeout, }, @@ -104,13 +110,14 @@ export async function runWaitCommand(options: CommandOptions): Promise { return; } + const effectiveTimeout = options.timeout ?? DEFAULT_WAIT_TIMEOUT_MS; const params = { exit: options.waitForExit || undefined, idleMs: options.idleMs ?? undefined, - timeoutMs: options.timeout ?? undefined, + timeoutMs: effectiveTimeout === 0 ? undefined : effectiveTimeout, }; const clientTimeout = - options.timeout !== undefined ? options.timeout + 5_000 : 0; + effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; const result = (await sendRpc( socketPath(sessionDirectory), 'wait', diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 5663db6..e2e77ef 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -59,7 +59,7 @@ export type InspectResult = z.infer; export const TypeParamsSchema = z .object({ - text: z.string(), + text: z.string().min(1), }) .strict(); export type TypeParams = z.infer; diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index 4c8c010..c3288a3 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -9,6 +9,7 @@ import { RpcRequestSchema, RpcResponseSchema, SendKeysParamsSchema, + TypeParamsSchema, WaitParamsSchema, WaitResultSchema, } from '../../../src/protocol/messages.js'; @@ -151,6 +152,14 @@ describe('RPC message schemas', () => { expect(result.success).toBe(false); }); + it('rejects empty type text', () => { + const result = TypeParamsSchema.safeParse({ + text: '', + }); + + expect(result.success).toBe(false); + }); + it('rejects zero-valued wait durations', () => { expect( WaitParamsSchema.safeParse({ From ed4997728e14f003bfde58a148e6918e7def798c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 11:59:52 +0000 Subject: [PATCH 34/38] refactor: share test helpers across suites --- test/e2e/hello-prompt.test.ts | 6 +- test/e2e/helpers.ts | 137 +++------------------ test/e2e/resize-demo.test.ts | 6 +- test/helpers.ts | 180 ++++++++++++++++++++++++++++ test/integration/cli.test.ts | 45 +++---- test/integration/event-log.test.ts | 134 ++------------------- test/integration/io-loop.test.ts | 165 ++----------------------- test/integration/lifecycle.test.ts | 97 ++------------- test/integration/pty-basics.test.ts | 159 ++---------------------- 9 files changed, 261 insertions(+), 668 deletions(-) create mode 100644 test/helpers.ts diff --git a/test/e2e/hello-prompt.test.ts b/test/e2e/hello-prompt.test.ts index 02b10ea..dd3b0a4 100644 --- a/test/e2e/hello-prompt.test.ts +++ b/test/e2e/hello-prompt.test.ts @@ -12,6 +12,7 @@ import { runCliJson, type SessionRecord, type SuccessEnvelope, + type WaitResult, } from './helpers.js'; interface CreateResult { @@ -22,11 +23,6 @@ interface InspectResult { session: SessionRecord; } -interface WaitResult { - exitCode?: number; - timedOut: boolean; -} - function testEnv(home: string): Record { return { AGENT_TERMINAL_HOME: home }; } diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts index 5280d40..b516335 100644 --- a/test/e2e/helpers.ts +++ b/test/e2e/helpers.ts @@ -1,73 +1,28 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import process from 'node:process'; + +import { readEvents, runCli } from '../helpers.js'; + +export { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type EventRecord, + type SessionRecord, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; export const DEFAULT_CLI_TIMEOUT_MS = 30_000; export const DEFAULT_IDLE_MS = 500; export const DEFAULT_WAIT_TIMEOUT_MS = 10_000; -export interface CommandResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export interface SuccessEnvelope { - ok: true; - command: string; - timestamp: string; - result: TResult; -} - -export interface EventRecord { - seq: number; - ts: string; - type: string; - payload: Record; -} - -export interface SessionRecord { - version: 1; - sessionId: string; - createdAt: string; - updatedAt: string; - status: string; - command: string[]; - cwd: string; - cols: number; - rows: number; - hostPid: number | null; - childPid: number | null; - exitCode: number | null; - exitSignal: string | null; -} - -export function runCli( - args: string[], - env: Record, - timeout = DEFAULT_CLI_TIMEOUT_MS, -): CommandResult { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - env: { ...process.env, ...env }, - encoding: 'utf8', - timeout, - }, - ); - - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status ?? 1, - }; -} - function withJsonFlag(args: string[]): string[] { const commandSeparatorIndex = args.indexOf('--'); @@ -87,7 +42,7 @@ export function runCliJson( args: string[], env: Record, ): TResult { - const { stdout } = runCli(withJsonFlag(args), env); + const { stdout } = runCli(withJsonFlag(args), env, DEFAULT_CLI_TIMEOUT_MS); assert(stdout.length > 0, 'expected JSON output from CLI command'); @@ -102,62 +57,6 @@ export async function createIsolatedHome(): Promise { return mkdtemp(join(tmpdir(), 'agent-terminal-e2e-home-')); } -export async function cleanupHome(home: string): Promise { - if (home.length === 0) { - return; - } - - try { - const sessionsDir = join(home, 'sessions'); - const entries = await readdir(sessionsDir).catch((): string[] => []); - - for (const entry of entries) { - const manifestFile = join(sessionsDir, entry, 'session.json'); - - try { - const manifest = JSON.parse( - await readFile(manifestFile, 'utf8'), - ) as Record; - - for (const pidKey of ['childPid', 'hostPid'] as const) { - const pid = manifest[pidKey]; - if (typeof pid === 'number' && pid > 0) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // best-effort cleanup, ignore errors - } - } - } - } catch { - // best-effort cleanup, ignore errors - } - } - } catch { - // best-effort cleanup, ignore errors - } - - await rm(home, { recursive: true, force: true }); -} - -export async function readEvents( - home: string, - sessionId: string, -): Promise { - const eventsPath = join(home, 'sessions', sessionId, 'events.jsonl'); - const content = await readFile(eventsPath, 'utf8'); - - if (content.trim().length === 0) { - return []; - } - - return content - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EventRecord); -} - export async function readOutput( home: string, sessionId: string, diff --git a/test/e2e/resize-demo.test.ts b/test/e2e/resize-demo.test.ts index 95d6787..2c911ab 100644 --- a/test/e2e/resize-demo.test.ts +++ b/test/e2e/resize-demo.test.ts @@ -12,6 +12,7 @@ import { runCliJson, type SessionRecord, type SuccessEnvelope, + type WaitResult, } from './helpers.js'; interface CreateResult { @@ -22,11 +23,6 @@ interface InspectResult { session: SessionRecord; } -interface WaitResult { - exitCode?: number; - timedOut: boolean; -} - function testEnv(home: string): Record { return { AGENT_TERMINAL_HOME: home }; } diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..6cb0312 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,180 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { expect } from 'vitest'; + +const DEFAULT_CLI_TIMEOUT_MS = 30_000; + +interface CommandResult { + stdout: string; + stderr: string; + status: number | null; + exitCode: number; +} + +export interface SuccessEnvelope { + ok: true; + command: string; + timestamp: string; + result: TResult; +} + +export interface SessionRecord { + version: 1; + sessionId: string; + createdAt: string; + updatedAt: string; + status: string; + command: string[]; + cwd: string; + cols: number; + rows: number; + hostPid: number | null; + childPid: number | null; + exitCode: number | null; + exitSignal: string | null; +} + +export interface EventRecord { + seq: number; + ts: string; + type: string; + payload: Record; +} + +export interface WaitResult { + exitCode?: number; + timedOut: boolean; +} + +export function runCli( + args: string[], + env: Record = {}, + timeout = DEFAULT_CLI_TIMEOUT_MS, +): CommandResult { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', ...args], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout, + }, + ); + + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + exitCode: result.status ?? 1, + }; +} + +export async function cleanupHome(home: string): Promise { + if (home.length === 0) { + return; + } + + try { + const sessionsDir = join(home, 'sessions'); + const entries = await readdir(sessionsDir).catch((): string[] => []); + + for (const entry of entries) { + const manifestFile = join(sessionsDir, entry, 'session.json'); + + try { + const manifest = JSON.parse( + await readFile(manifestFile, 'utf8'), + ) as Record; + + for (const pidKey of ['childPid', 'hostPid'] as const) { + const pid = manifest[pidKey]; + if (typeof pid === 'number' && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // best-effort cleanup, ignore errors + } + } + } + } catch { + // best-effort cleanup, ignore errors + } + } + } catch { + // best-effort cleanup, ignore errors + } + + await rm(home, { recursive: true, force: true }); +} + +export function createSession( + testHome: string, + command: string[] = ['/bin/sh', '-c', 'exec cat'], +): string { + const result = runCli(['create', '--json', '--', ...command], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.sessionId; +} + +export function destroySession(testHome: string, sessionId: string): void { + if (sessionId.length === 0) { + return; + } + + runCli(['destroy', sessionId, '--force', '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); +} + +export function inspectSession( + testHome: string, + sessionId: string, +): SessionRecord { + const result = runCli(['inspect', sessionId, '--json'], { + AGENT_TERMINAL_HOME: testHome, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + session: SessionRecord; + }>; + expect(envelope.ok).toBe(true); + return envelope.result.session; +} + +export async function readEvents( + testHome: string, + sessionId: string, +): Promise { + const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); + const content = await readFile(eventsPath, 'utf8'); + + if (content.trim().length === 0) { + return []; + } + + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as EventRecord); +} + +export async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 43e31a4..041da69 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -1,32 +1,16 @@ -import { spawnSync } from 'node:child_process'; -import process from 'node:process'; - import { describe, expect, it } from 'vitest'; -function runCli(args: string[]): string { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - }, - ); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - return result.stdout; -} +import { runCli, type SuccessEnvelope } from '../helpers.js'; describe('CLI integration', () => { it('prints a JSON envelope for version', () => { - const stdout = runCli(['version', '--json']); - const parsed = JSON.parse(stdout) as { - ok: boolean; - command: string; - result: { cliVersion: string }; - }; + const result = runCli(['version', '--json']); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ + cliVersion: string; + }>; expect(parsed.ok).toBe(true); expect(parsed.command).toBe('version'); @@ -34,12 +18,13 @@ describe('CLI integration', () => { }); it('prints a JSON envelope for doctor', () => { - const stdout = runCli(['doctor', '--json']); - const parsed = JSON.parse(stdout) as { - ok: boolean; - command: string; - result: { checks: Array<{ ok: boolean; name: string }> }; - }; + const result = runCli(['doctor', '--json']); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ + checks: Array<{ ok: boolean; name: string }>; + }>; expect(parsed.ok).toBe(true); expect(parsed.command).toBe('doctor'); diff --git a/test/integration/event-log.test.ts b/test/integration/event-log.test.ts index eb514f1..39bf9c6 100644 --- a/test/integration/event-log.test.ts +++ b/test/integration/event-log.test.ts @@ -1,131 +1,19 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -interface SuccessEnvelope { - ok: true; - command: string; - result: TResult; -} - -interface EventRecord { - seq: number; - ts: string; - type: string; - payload: Record; -} - -interface WaitResult { - exitCode?: number; - timedOut: boolean; -} - -function runCli( - args: string[], - env?: Record, - timeout = 15000, -): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { ...process.env, ...env }, - timeout, - }, - ); - - return { - stdout: result.stdout, - stderr: result.stderr, - status: result.status, - }; -} - -async function cleanupHome(home: string): Promise { - try { - const sessionsDir = join(home, 'sessions'); - const entries = await readdir(sessionsDir).catch((): string[] => []); - - for (const entry of entries) { - const manifestFile = join(sessionsDir, entry, 'session.json'); - - try { - const raw = await readFile(manifestFile, 'utf8'); - const manifest = JSON.parse(raw) as Record; - - for (const pidKey of ['childPid', 'hostPid'] as const) { - const pid = manifest[pidKey]; - if (typeof pid === 'number' && pid > 0) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // best-effort cleanup, ignore errors - } - } - } - } catch { - // best-effort cleanup, ignore errors - } - } - } catch { - // best-effort cleanup, ignore errors - } - - await rm(home, { recursive: true, force: true }); -} - -function createSession( - testHome: string, - command: string[] = ['/bin/sh', '-c', 'exec cat'], -): string { - const result = runCli(['create', '--json', '--', ...command], { - AGENT_TERMINAL_HOME: testHome, - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ - sessionId: string; - }>; - expect(envelope.ok).toBe(true); - return envelope.result.sessionId; -} - -async function readEvents( - testHome: string, - sessionId: string, -): Promise { - const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); - const content = await readFile(eventsPath, 'utf8'); - - return content - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EventRecord); -} - -function destroySession(testHome: string, sessionId: string): void { - if (sessionId.length === 0) { - return; - } - - runCli(['destroy', sessionId, '--force', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +import { + cleanupHome, + createSession, + destroySession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; function runMixedActionSequence(testHome: string, sessionId: string): void { const env = { AGENT_TERMINAL_HOME: testHome }; diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index 7af3b44..7005ae5 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -1,161 +1,20 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -interface SuccessEnvelope { - ok: true; - command: string; - result: TResult; -} - -interface SessionRecord { - version: 1; - sessionId: string; - status: string; - command: string[]; - cwd: string; - cols: number; - rows: number; - hostPid: number | null; - childPid: number | null; - exitCode: number | null; - exitSignal: string | null; - createdAt: string; - updatedAt: string; -} - -interface EventRecord { - seq: number; - ts: string; - type: string; - payload: Record; -} - -interface WaitResult { - exitCode?: number; - timedOut: boolean; -} - -function runCli( - args: string[], - env?: Record, - timeout = 15000, -): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { ...process.env, ...env }, - timeout, - }, - ); - - return { - stdout: result.stdout, - stderr: result.stderr, - status: result.status, - }; -} - -async function cleanupHome(home: string): Promise { - try { - const sessionsDir = join(home, 'sessions'); - const entries = await readdir(sessionsDir).catch((): string[] => []); - - for (const entry of entries) { - const manifestFile = join(sessionsDir, entry, 'session.json'); - - try { - const raw = await readFile(manifestFile, 'utf8'); - const manifest = JSON.parse(raw) as Record; - - for (const pidKey of ['childPid', 'hostPid'] as const) { - const pid = manifest[pidKey]; - if (typeof pid === 'number' && pid > 0) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // best-effort cleanup, ignore errors - } - } - } - } catch { - // best-effort cleanup, ignore errors - } - } - } catch { - // best-effort cleanup, ignore errors - } - - await rm(home, { recursive: true, force: true }); -} - -function createSession( - testHome: string, - command: string[] = ['/bin/sh', '-c', 'exec cat'], -): string { - const result = runCli(['create', '--json', '--', ...command], { - AGENT_TERMINAL_HOME: testHome, - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ - sessionId: string; - }>; - expect(envelope.ok).toBe(true); - return envelope.result.sessionId; -} - -async function readEvents( - testHome: string, - sessionId: string, -): Promise { - const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); - const content = await readFile(eventsPath, 'utf8'); - - return content - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EventRecord); -} - -function inspectSession(testHome: string, sessionId: string): SessionRecord { - const result = runCli(['inspect', sessionId, '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ - session: SessionRecord; - }>; - expect(envelope.ok).toBe(true); - return envelope.result.session; -} - -function destroySession(testHome: string, sessionId: string): void { - if (sessionId.length === 0) { - return; - } - - runCli(['destroy', sessionId, '--force', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +import { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; let testHome = ''; diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index 46d246a..5afb508 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -1,16 +1,16 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -interface SuccessEnvelope { - ok: true; - command: string; - result: TResult; -} +import { + cleanupHome, + runCli, + type EventRecord, + type SessionRecord, + type SuccessEnvelope, +} from '../helpers.js'; interface ErrorEnvelope { ok: false; @@ -30,82 +30,6 @@ interface SessionSummary { createdAt: string; } -interface SessionRecord extends SessionSummary { - version: 1; - updatedAt: string; - cwd: string; - cols: number; - rows: number; - hostPid: number | null; - childPid: number | null; - exitCode: number | null; - exitSignal: string | null; -} - -interface EventRecord { - seq: number; - ts: string; - type: string; - payload: { - data?: string; - exitCode?: number; - }; -} - -function runCli( - args: string[], - env?: Record, -): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { ...process.env, ...env }, - timeout: 15000, - }, - ); - return { - stdout: result.stdout, - stderr: result.stderr, - status: result.status, - }; -} - -async function cleanupHome(home: string): Promise { - try { - const sessionsDir = join(home, 'sessions'); - const entries = await readdir(sessionsDir).catch((): string[] => []); - - for (const entry of entries) { - const manifestFile = join(sessionsDir, entry, 'session.json'); - - try { - const raw = await readFile(manifestFile, 'utf8'); - const manifest = JSON.parse(raw) as Record; - - for (const pidKey of ['childPid', 'hostPid'] as const) { - const pid = manifest[pidKey]; - if (typeof pid === 'number' && pid > 0) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // best-effort cleanup, ignore errors - } - } - } - } catch { - // best-effort cleanup, ignore errors - } - } - } catch { - // best-effort cleanup, ignore errors - } - - await rm(home, { recursive: true, force: true }); -} - let testHome = ''; describe('lifecycle integration', { timeout: 30000 }, () => { @@ -270,7 +194,10 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(outputEvents.length).toBeGreaterThan(0); const allOutput = outputEvents - .map((event) => event.payload.data ?? '') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) .join(''); expect(allOutput).toContain('marker-test-output'); diff --git a/test/integration/pty-basics.test.ts b/test/integration/pty-basics.test.ts index b05e197..59471fe 100644 --- a/test/integration/pty-basics.test.ts +++ b/test/integration/pty-basics.test.ts @@ -1,156 +1,19 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -interface SuccessEnvelope { - ok: true; - command: string; - result: TResult; -} - -interface SessionRecord { - version: 1; - sessionId: string; - status: string; - command: string[]; - cwd: string; - cols: number; - rows: number; - hostPid: number | null; - childPid: number | null; - exitCode: number | null; - exitSignal: string | null; - createdAt: string; - updatedAt: string; -} - -interface EventRecord { - seq: number; - ts: string; - type: string; - payload: Record; -} - -function runCli( - args: string[], - env?: Record, - timeout = 15000, -): { stdout: string; stderr: string; status: number | null } { - const result = spawnSync( - process.execPath, - ['--import', 'tsx', './src/cli/main.ts', ...args], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { ...process.env, ...env }, - timeout, - }, - ); - - return { - stdout: result.stdout, - stderr: result.stderr, - status: result.status, - }; -} - -async function cleanupHome(home: string): Promise { - try { - const sessionsDir = join(home, 'sessions'); - const entries = await readdir(sessionsDir).catch((): string[] => []); - - for (const entry of entries) { - const manifestFile = join(sessionsDir, entry, 'session.json'); - - try { - const raw = await readFile(manifestFile, 'utf8'); - const manifest = JSON.parse(raw) as Record; - - for (const pidKey of ['childPid', 'hostPid'] as const) { - const pid = manifest[pidKey]; - if (typeof pid === 'number' && pid > 0) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // best-effort cleanup, ignore errors - } - } - } - } catch { - // best-effort cleanup, ignore errors - } - } - } catch { - // best-effort cleanup, ignore errors - } - - await rm(home, { recursive: true, force: true }); -} - -function createSession( - testHome: string, - command: string[] = ['/bin/sh', '-c', 'exec cat'], -): string { - const result = runCli(['create', '--json', '--', ...command], { - AGENT_TERMINAL_HOME: testHome, - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ - sessionId: string; - }>; - expect(envelope.ok).toBe(true); - return envelope.result.sessionId; -} - -async function readEvents( - testHome: string, - sessionId: string, -): Promise { - const eventsPath = join(testHome, 'sessions', sessionId, 'events.jsonl'); - const content = await readFile(eventsPath, 'utf8'); - - return content - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EventRecord); -} - -function inspectSession(testHome: string, sessionId: string): SessionRecord { - const result = runCli(['inspect', sessionId, '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(''); - - const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ - session: SessionRecord; - }>; - expect(envelope.ok).toBe(true); - return envelope.result.session; -} - -function destroySession(testHome: string, sessionId: string): void { - if (sessionId.length === 0) { - return; - } - - runCli(['destroy', sessionId, '--force', '--json'], { - AGENT_TERMINAL_HOME: testHome, - }); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +import { + cleanupHome, + createSession, + destroySession, + inspectSession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, +} from '../helpers.js'; let testHome = ''; From 17f0ee73a58d067fa9cfbc66f38f45e8b667f3ce Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 12:00:55 +0000 Subject: [PATCH 35/38] Fix formatting for wait timeout change --- src/cli/commands/wait.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index dd8de0b..8619fd4 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -116,8 +116,7 @@ export async function runWaitCommand(options: CommandOptions): Promise { idleMs: options.idleMs ?? undefined, timeoutMs: effectiveTimeout === 0 ? undefined : effectiveTimeout, }; - const clientTimeout = - effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; + const clientTimeout = effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; const result = (await sendRpc( socketPath(sessionDirectory), 'wait', From 81a52079ff30b87e306c12b6ca60191468da55a4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 13:21:59 +0000 Subject: [PATCH 36/38] Fix CLI command error envelopes --- src/cli/commands/paste.ts | 9 ++ src/cli/commands/type.ts | 9 ++ src/cli/main.ts | 256 ++++++++++++++++++++++++-------------- 3 files changed, 178 insertions(+), 96 deletions(-) diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index ca7089f..1386c89 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -1,3 +1,4 @@ +import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; @@ -20,6 +21,14 @@ interface CommandOptions { } export async function runPasteCommand(options: CommandOptions): Promise { + if (options.text.length === 0) { + throw new CliError('INVALID_INPUT', 'Text must not be empty.', { + details: { + text: options.text, + }, + }); + } + const home = resolveHome(); const sessionDirectory = sessionDir(home, options.sessionId); const manifestFile = manifestPath(sessionDirectory); diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index 6ae2b6e..7ea6451 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -1,3 +1,4 @@ +import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; @@ -20,6 +21,14 @@ interface CommandOptions { } export async function runTypeCommand(options: CommandOptions): Promise { + if (options.text.length === 0) { + throw new CliError('INVALID_INPUT', 'Text must not be empty.', { + details: { + text: options.text, + }, + }); + } + const home = resolveHome(); const sessionDirectory = sessionDir(home, options.sessionId); const manifestFile = manifestPath(sessionDirectory); diff --git a/src/cli/main.ts b/src/cli/main.ts index 63e04a2..a747723 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -23,6 +23,35 @@ function parseIntegerOption(value: string): number { return Number.parseInt(value, 10); } +function wrapAction( + commandName: string, + fn: (...args: Args) => Promise, +): (...args: Args) => Promise { + return async (...args: Args) => { + try { + await fn(...args); + } catch (error: unknown) { + if (error instanceof CliError) { + const json = process.argv.includes('--json'); + emitFailure({ + command: commandName, + json, + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + details: error.details, + }, + }); + process.exitCode = 1; + return; + } + + throw error; + } + }; +} + async function main(): Promise { const program = new Command() .name('agent-terminal') @@ -33,17 +62,21 @@ async function main(): Promise { .command('version') .description('Print version') .option('--json', 'Emit a JSON command envelope', false) - .action(async (options: { json: boolean }) => { - await runVersionCommand(options); - }); + .action( + wrapAction('version', async (options: { json: boolean }) => { + await runVersionCommand(options); + }), + ); program .command('doctor') .description('Check env') .option('--json', 'Emit a JSON command envelope', false) - .action(async (options: { json: boolean }) => { - await runDoctorCommand(options); - }); + .action( + wrapAction('doctor', async (options: { json: boolean }) => { + await runDoctorCommand(options); + }), + ); // --- Session lifecycle --- program @@ -59,25 +92,28 @@ async function main(): Promise { .option('--rows ', 'Initial rows', parseIntegerOption, 24) .option('--json', 'Emit a JSON command envelope', false) .action( - async ( - command: string[], - options: { - command: string; - cwd: string; - cols: number; - rows: number; - json: boolean; + wrapAction( + 'create', + async ( + command: string[], + options: { + command: string; + cwd: string; + cols: number; + rows: number; + json: boolean; + }, + ) => { + await runCreateCommand({ + json: options.json, + command, + shellCommand: options.command, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + }); }, - ) => { - await runCreateCommand({ - json: options.json, - command, - shellCommand: options.command, - cwd: options.cwd, - cols: options.cols, - rows: options.rows, - }); - }, + ), ); program @@ -85,20 +121,27 @@ async function main(): Promise { .description('List sessions') .option('--all', 'Include exited sessions', false) .option('--json', 'Emit a JSON command envelope', false) - .action(async (options: { all: boolean; json: boolean }) => { - await runListCommand(options); - }); + .action( + wrapAction('list', async (options: { all: boolean; json: boolean }) => { + await runListCommand(options); + }), + ); program .command('inspect ') .description('Inspect a session') .option('--json', 'Emit a JSON command envelope', false) - .action(async (sessionId: string, options: { json: boolean }) => { - await runInspectCommand({ - json: options.json, - sessionId, - }); - }); + .action( + wrapAction( + 'inspect', + async (sessionId: string, options: { json: boolean }) => { + await runInspectCommand({ + json: options.json, + sessionId, + }); + }, + ), + ); program .command('destroy ') @@ -106,13 +149,16 @@ async function main(): Promise { .option('--force', 'Skip graceful shutdown', false) .option('--json', 'Emit a JSON command envelope', false) .action( - async (sessionId: string, options: { force: boolean; json: boolean }) => { - await runDestroyCommand({ - json: options.json, - sessionId, - force: options.force, - }); - }, + wrapAction( + 'destroy', + async (sessionId: string, options: { force: boolean; json: boolean }) => { + await runDestroyCommand({ + json: options.json, + sessionId, + force: options.force, + }); + }, + ), ); // --- Session control --- @@ -121,13 +167,16 @@ async function main(): Promise { .description('Type text into a session') .option('--json', 'Emit a JSON command envelope', false) .action( - async (sessionId: string, text: string, options: { json: boolean }) => { - await runTypeCommand({ - json: options.json, - sessionId, - text, - }); - }, + wrapAction( + 'type', + async (sessionId: string, text: string, options: { json: boolean }) => { + await runTypeCommand({ + json: options.json, + sessionId, + text, + }); + }, + ), ); program @@ -135,13 +184,16 @@ async function main(): Promise { .description('Paste text into a session') .option('--json', 'Emit a JSON command envelope', false) .action( - async (sessionId: string, text: string, options: { json: boolean }) => { - await runPasteCommand({ - json: options.json, - sessionId, - text, - }); - }, + wrapAction( + 'paste', + async (sessionId: string, text: string, options: { json: boolean }) => { + await runPasteCommand({ + json: options.json, + sessionId, + text, + }); + }, + ), ); program @@ -149,13 +201,16 @@ async function main(): Promise { .description('Send keys to a session') .option('--json', 'Emit a JSON command envelope', false) .action( - async (sessionId: string, keys: string[], options: { json: boolean }) => { - await runSendKeysCommand({ - json: options.json, - sessionId, - keys, - }); - }, + wrapAction( + 'send-keys', + async (sessionId: string, keys: string[], options: { json: boolean }) => { + await runSendKeysCommand({ + json: options.json, + sessionId, + keys, + }); + }, + ), ); program @@ -165,17 +220,20 @@ async function main(): Promise { .requiredOption('--rows ', 'Rows', parseIntegerOption) .option('--json', 'Emit a JSON command envelope', false) .action( - async ( - sessionId: string, - options: { cols: number; rows: number; json: boolean }, - ) => { - await runResizeCommand({ - json: options.json, - sessionId, - cols: options.cols, - rows: options.rows, - }); - }, + wrapAction( + 'resize', + async ( + sessionId: string, + options: { cols: number; rows: number; json: boolean }, + ) => { + await runResizeCommand({ + json: options.json, + sessionId, + cols: options.cols, + rows: options.rows, + }); + }, + ), ); program @@ -183,13 +241,16 @@ async function main(): Promise { .description('Send a signal to a session') .option('--json', 'Emit a JSON command envelope', false) .action( - async (sessionId: string, signal: string, options: { json: boolean }) => { - await runSignalCommand({ - json: options.json, - sessionId, - signal, - }); - }, + wrapAction( + 'signal', + async (sessionId: string, signal: string, options: { json: boolean }) => { + await runSignalCommand({ + json: options.json, + sessionId, + signal, + }); + }, + ), ); // --- Observation --- @@ -200,28 +261,31 @@ async function main(): Promise { .option('--idle-ms ', 'Wait for output idle period', parseIntegerOption) .option( '--timeout ', - 'Maximum wait time in milliseconds', + 'Maximum wait time in milliseconds (0 for infinite)', parseIntegerOption, ) .option('--json', 'Emit a JSON command envelope', false) .action( - async ( - sessionId: string, - options: { - exit: boolean; - idleMs?: number; - timeout?: number; - json: boolean; + wrapAction( + 'wait', + async ( + sessionId: string, + options: { + exit: boolean; + idleMs?: number; + timeout?: number; + json: boolean; + }, + ) => { + await runWaitCommand({ + json: options.json, + sessionId, + waitForExit: options.exit, + idleMs: options.idleMs, + timeout: options.timeout, + }); }, - ) => { - await runWaitCommand({ - json: options.json, - sessionId, - waitForExit: options.exit, - idleMs: options.idleMs, - timeout: options.timeout, - }); - }, + ), ); program From 7a0583a16b4ba68dc680f7547000b1429f894361 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 13:22:47 +0000 Subject: [PATCH 37/38] Fix formatting for error envelope fix --- src/cli/main.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index a747723..de603fb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -151,7 +151,10 @@ async function main(): Promise { .action( wrapAction( 'destroy', - async (sessionId: string, options: { force: boolean; json: boolean }) => { + async ( + sessionId: string, + options: { force: boolean; json: boolean }, + ) => { await runDestroyCommand({ json: options.json, sessionId, @@ -203,7 +206,11 @@ async function main(): Promise { .action( wrapAction( 'send-keys', - async (sessionId: string, keys: string[], options: { json: boolean }) => { + async ( + sessionId: string, + keys: string[], + options: { json: boolean }, + ) => { await runSendKeysCommand({ json: options.json, sessionId, @@ -243,7 +250,11 @@ async function main(): Promise { .action( wrapAction( 'signal', - async (sessionId: string, signal: string, options: { json: boolean }) => { + async ( + sessionId: string, + signal: string, + options: { json: boolean }, + ) => { await runSignalCommand({ json: options.json, sessionId, From 5777b202533473b0b658d27a6bcd7ece9f80b173 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 20 Mar 2026 14:10:00 +0000 Subject: [PATCH 38/38] Register INVALID_INPUT error code --- src/cli/commands/paste.ts | 4 ++-- src/cli/commands/type.ts | 4 ++-- src/protocol/errors.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/paste.ts b/src/cli/commands/paste.ts index 1386c89..964d602 100644 --- a/src/cli/commands/paste.ts +++ b/src/cli/commands/paste.ts @@ -1,4 +1,3 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; @@ -22,7 +21,8 @@ interface CommandOptions { export async function runPasteCommand(options: CommandOptions): Promise { if (options.text.length === 0) { - throw new CliError('INVALID_INPUT', 'Text must not be empty.', { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Text must not be empty.', details: { text: options.text, }, diff --git a/src/cli/commands/type.ts b/src/cli/commands/type.ts index 7ea6451..9149c0f 100644 --- a/src/cli/commands/type.ts +++ b/src/cli/commands/type.ts @@ -1,4 +1,3 @@ -import { CliError } from '../errors.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; @@ -22,7 +21,8 @@ interface CommandOptions { export async function runTypeCommand(options: CommandOptions): Promise { if (options.text.length === 0) { - throw new CliError('INVALID_INPUT', 'Text must not be empty.', { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Text must not be empty.', details: { text: options.text, }, diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts index fd95fc3..82015b1 100644 --- a/src/protocol/errors.ts +++ b/src/protocol/errors.ts @@ -13,6 +13,7 @@ export const ERROR_CODES = { INVALID_SIGNAL: 'INVALID_SIGNAL', INVALID_KEYS: 'INVALID_KEYS', INVALID_DURATION: 'INVALID_DURATION', + INVALID_INPUT: 'INVALID_INPUT', STORAGE_READ_ERROR: 'STORAGE_READ_ERROR', STORAGE_WRITE_ERROR: 'STORAGE_WRITE_ERROR', MANIFEST_VALIDATION_ERROR: 'MANIFEST_VALIDATION_ERROR', @@ -33,6 +34,7 @@ export const DEFAULT_ERROR_MESSAGES: Record = { [ERROR_CODES.INVALID_SIGNAL]: 'Signal is invalid.', [ERROR_CODES.INVALID_KEYS]: 'Key sequence is invalid.', [ERROR_CODES.INVALID_DURATION]: 'Duration value is invalid.', + [ERROR_CODES.INVALID_INPUT]: 'Invalid input provided.', [ERROR_CODES.STORAGE_READ_ERROR]: 'Failed to read session storage.', [ERROR_CODES.STORAGE_WRITE_ERROR]: 'Failed to write session storage.', [ERROR_CODES.MANIFEST_VALIDATION_ERROR]: 'Session manifest is invalid.',