Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/verify-session-lifecycle/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
"src/host/sessionState.ts#SessionState.requestDestroy": "f66074e815527ff1",
"src/host/sessionState.ts#SessionState.recordDestroyed": "495b3a20c56b6330",
"src/host/sessionState.ts#SessionState.recordExit": "a4f3f6706ae953c3",
"src/host/eventLog.ts#EventLog.open": "2bc70b7c6b669f20",
"src/host/eventLog.ts#EventLog.open": "4d4a1c04ce17c37d",
"src/host/rpcServer.ts#RpcServer.listen": "aa62e1cd89eceaa4",
"src/host/rpcServer.ts#RpcServer.close": "2d8092cceb4b8e13",
"src/storage/manifests.ts#readValidatedJsonFile": "6287eb627b09bf80",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ The `agent-tty` CLI (`src/cli/main.ts`) starts one detached host process per ses

- The CLI JSON envelope is the stable automation surface. `src/protocol/envelope.ts` defines it, and `src/cli/exitCodes.ts` maps known error codes to fixed exit codes. Unmapped codes, such as `SKILL_NOT_FOUND`, exit 1. [src/protocol/envelope.ts:CommandEnvelope] [src/cli/exitCodes.ts:exitCodeForError]
- Every `.object(...)` schema in `src/protocol/schemas.ts` and `src/protocol/messages.ts` ends in `.strict()`, so unknown keys fail validation. [src/protocol/schemas.ts:SessionRecordSchema] [src/protocol/messages.ts:RpcRequestSchema]
- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB). `EventLog.append` checks no byte size. It caps only the buffer at `MAX_EVENT_BUFFER_ENTRIES` (250,000 entries), so a live session can grow the file past 50 MB and then fail to reopen. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE]
- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size and never holds more than `MAX_EVENT_BUFFER_ENTRIES` (250,000) entries. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of the size and `EVENT_LOG_TAIL_RESERVE_ENTRIES` (2) short of the entry cap. The first record that does not fit makes the log append one `marker` record whose label starts with `EVENT_LOG_LIMIT_LABEL_PREFIX` and reads `agent-tty: event log limit reached (<n> bytes); later events are not recorded` (`<n> events` in place of `<n> bytes` when the entry cap stopped the log), and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. Live features that replay the log see the program as it was at the stop: `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request encodes against the mouse modes in the stored log. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE]
- Session, socket and artifact paths come from guarded builders that reject path separators and `.` or `..` session IDs. [src/storage/sessionPaths.ts:sessionDir] [src/storage/artifactPaths.ts:artifactPath]
- `session.json`, the artifact manifest and the Home registry are written through `writeTextFileAtomic`: a temp file with mode `0o600`, then a rename. [src/storage/manifests.ts:writeTextFileAtomic]
- `gc` deletes only sessions whose status is collectable (`exited`, `failed` or `destroyed`) after `reconcileSession`, and it re-reads the manifest before each delete. [src/cli/commands/gc.ts:gcSessions] [src/protocol/sessionStatusPolicy.ts:getSessionStatusPolicy]
Expand Down
15 changes: 15 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,18 @@ If it still appears:
- The program running inside the session set the marker itself.

To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh's styled default) when creating the session.

## A Recording Ends Early, or an Export Names the Event Log Limit

Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker reads `agent-tty: event log limit reached (52428800 bytes); later events are not recorded` when the byte limit stopped the log, and `agent-tty: event log limit reached (250000 events); later events are not recorded` when the event limit did. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. `mark` refuses a label that starts with `agent-tty: event log limit reached` with `INVALID_INPUT`, because only the host writes that marker.

Commands on a live session read the stored log too, so after the marker they see the session as it was at the marker:

- `wait --text` and `wait --regex` for text that appears later time out.
- `wait --screen-stable-ms` can report a stable screen while the program keeps printing, because the stored screen no longer changes.
- `mouse` encodes against the mouse modes in the stored log. A mode that the program turned on after the marker is not known, so the event succeeds with `reported: false` and writes nothing. A mode that the program turned off after the marker is still on in the stored log, so the program receives mouse bytes that it no longer expects.
- `wait --idle-ms` and `wait --exit` watch the live program and are not affected.

To keep a long session recordable, send its verbose output to a file instead of the terminal, or print in larger chunks.

A command that fails with a message such as `its event log is 81336613 bytes, over the 52428800-byte limit that commands can read` names a log that an older version of agent-tty wrote past the limit. No command can read that log. `inspect` and `destroy` still work on the session.
2 changes: 1 addition & 1 deletion src/cli/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ This directory holds one handler module per agent-tty CLI command. `src/cli/main
- `wait` rejects a mix of legacy flags (`--exit`, `--idle-ms`) and render flags. `--text` and `--regex` are mutually exclusive. The default timeout is `DEFAULT_WAIT_TIMEOUT_MS` (30,000 ms), which the `main.ts` help text names. An `--idle-ms` at or above the effective timeout (explicit or default), or a `--screen-stable-ms` whose `screenStableMinimumMs` is not shorter than it, throws `INVALID_DURATION`. `--timeout 0` skips the check. [wait.ts:runWaitCommand] [wait.ts:assertWindowFitsTimeout] [main.ts:main]
- A timed-out `wait` throws `WAIT_TIMEOUT` with the wait result attached, so its error envelope keeps `result`. Without `--json`, and only when the wait lasted its whole timeout (`timeoutMs > 0`, measured by the CLI), the message names the timeout and, for the default, how to change it. With `--json`, the message stays `Wait timed out before its condition was met.` [wait.ts:throwIfTimedOut] [wait.ts:timeoutMessage]
- `snapshot` and `screenshot` try the live RPC only when the manifest status is `running`. They fall back to offline replay only on `HOST_UNREACHABLE` and rethrow other errors. [snapshot.ts:runSnapshotCommand] [screenshot.ts:runScreenshotCommand]
- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`, `svg`). `--animate` needs `svg`, and `svg` refuses `--timing`. [record-export.ts:runRecordExportCommand]
- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`, `svg`). `--animate` needs `svg`, and `svg` refuses `--timing`. An event log over the size limit fails with `EXPORT_ERROR` in every format, a message that names the log size and the limit, and `eventLogBytes` and `maxEventLogBytes` in `details`. [record-export.ts:runRecordExportCommand]
- `batch` parses the whole plan with `parseBatchPlan` before it resolves the session target. [batch.ts:runBatchCommand]
- `create` polls readiness up to `READINESS_MAX_ATTEMPTS` (50) times, `READINESS_POLL_INTERVAL_MS` (100 ms) apart. [create.ts:waitForReadyHost]
- `doctor` reports the `node-runtime` check as failed when the Node version is outside the `engines.node` range of `package.json`. It keeps running the other checks, reports `ok: false` and sets `process.exitCode = 1`. [doctor.ts:runNodeRuntimeCheck] [doctor.ts:runDoctorCommand]
Expand Down
19 changes: 18 additions & 1 deletion src/cli/commands/record-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
type WebmExportResult,
} from '../../export/webm.js';
import { captureGridFrames } from '../../replay/gridFrames.js';
import { readEventLogRecords } from '../../storage/eventLogCodec.js';
import {
EventLogTooLargeError,
readEventLogRecords,
} from '../../storage/eventLogCodec.js';
import {
getBuiltinProfile,
hashProfile,
Expand Down Expand Up @@ -579,6 +582,20 @@ export async function runRecordExportCommand(
throw error;
}

if (error instanceof EventLogTooLargeError) {
throw makeCliError(ERROR_CODES.EXPORT_ERROR, {
message: `Failed to export session "${options.sessionId}" as ${format}: its event log is ${String(error.size)} bytes, over the ${String(error.maxSize)}-byte limit that commands can read.`,
details: {
sessionId: options.sessionId,
format,
out: options.out,
eventLogBytes: error.size,
maxEventLogBytes: error.maxSize,
},
cause: error,
});
}

throw makeCliError(ERROR_CODES.EXPORT_ERROR, {
message: `Failed to export session "${options.sessionId}" as ${format}.`,
details: {
Expand Down
4 changes: 2 additions & 2 deletions src/export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This directory converts a session's event log into recording exports. `generateA

## Interface

- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM` [asciicast.ts:AsciicastHeader].
- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM`. `width` and `height` are the creation size of the session (`manifest.creationCols` and `manifest.creationRows`, or `manifest.cols` and `manifest.rows` when they are absent), the size that replay starts from. A later `resize` event becomes an `r` line, so a resized session keeps its first size in the header [asciicast.ts:AsciicastHeader].
- `AsciicastExport`: `contents`, `header`, `capturedAtSeq`, `durationMs`, `outputEventCount`, `resizeEventCount` and `markerCount` [asciicast.ts:AsciicastExport].
- `generateAsciicast(sessionId, manifest, events, toolVersion?)` returns an `AsciicastExport`. It runs synchronously [asciicast.ts:generateAsciicast].
- `WebmExportOptions`: `sessionId`, `sessionDir`, `manifest`, `events`, `outputPath`, and optional `targetSeq`, `profileName`, `timingMode` and `rendererName` [webm.ts:WebmExportOptions].
Expand All @@ -17,7 +17,7 @@ This directory converts a session's event log into recording exports. `generateA

## Invariants

- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, positive `manifest.cols` and `manifest.rows`, and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp].
- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, a positive initial width and height (`manifest.creationCols ?? manifest.cols` and `manifest.creationRows ?? manifest.rows`, as in `buildReplayInput`), and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp].
- Event timestamps must parse to finite numbers [asciicast.ts:parseTimestamp]. They must not decrease from one event to the next [asciicast.ts:generateAsciicast].
- Event mapping: `output` becomes `o`, `resize` becomes `r` with `COLSxROWS`, and `marker` becomes `m`. Input, run, signal and exit events are skipped. An unknown type reaches `unreachable()` [asciicast.ts:generateAsciicast].
- `generateWebmExport` asserts a non-empty `sessionId`, absolute `sessionDir` and `outputPath`, and positive initial dimensions. An empty event list gives a blank video with `capturedAtSeq` -1 and `durationMs` 0 [webm.ts:generateWebmExport].
Expand Down
12 changes: 8 additions & 4 deletions src/export/asciicast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ export function generateAsciicast(
manifest.sessionId === sessionId,
'manifest sessionId must match the export sessionId',
);
invariant(manifest.cols > 0, 'manifest cols must be positive');
invariant(manifest.rows > 0, 'manifest rows must be positive');
// The header names the size that the first output was written at: the
// creation size, as in `buildReplayInput`. Later sizes arrive as `r` lines.
const initialCols = manifest.creationCols ?? manifest.cols;
const initialRows = manifest.creationRows ?? manifest.rows;
invariant(initialCols > 0, 'initial cols must be positive');
invariant(initialRows > 0, 'initial rows must be positive');

const fallbackTimestampMs = parseTimestamp(
manifest.createdAt,
Expand All @@ -82,8 +86,8 @@ export function generateAsciicast(

const header: AsciicastHeader = {
version: 2,
width: manifest.cols,
height: manifest.rows,
width: initialCols,
height: initialRows,
timestamp: Math.floor(firstTimestampMs / 1000),
title: sessionId,
sessionId,
Expand Down
Loading
Loading