Skip to content
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": "4d4a1c04ce17c37d",
"src/host/eventLog.ts#EventLog.open": "19229956e761db8c",
"src/host/rpcServer.ts#RpcServer.listen": "aa62e1cd89eceaa4",
"src/host/rpcServer.ts#RpcServer.close": "2d8092cceb4b8e13",
"src/storage/manifests.ts#readValidatedJsonFile": "6287eb627b09bf80",
Expand Down
4 changes: 3 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh'

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.

To check whether a session's log stopped, run `agent-tty inspect <session-id> --json`. The result has `eventLogStop`, `{ "reason": "bytes" | "events" | "unknown", "seq": <n> }`, only when the log stopped. `reason` is the limit that stopped it, and `seq` is the `seq` of the marker. `unknown` means that the marker is in the log but its label names no limit, as in a log that agent-tty did not write. Without `--json`, `inspect` prints a line such as `Event Log Stopped: at seq 41 (byte limit); later events are not recorded`, and `(reason unknown)` for `unknown`. `inspect` reads the field from `events.jsonl` in the same read that counts `eventCount`, so it works for a live session and for an exited one, and `eventLogStop.seq` is never above `lastEventSeq`.

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 --text` and `wait --regex` for text that appears later time out. A render wait that times out on a stopped log says so, in its message and in `error.details.eventLogStop`: `The event log stopped at seq 41 (byte limit), so output after it is not seen.` A `batch` `wait` step adds the same sentence. On an exited session, `wait` replays the stored log, and a wait that the replayed screen does not satisfy fails with `REPLAY_ERROR`. Its message ends with the same sentence, and `error.details.eventLogStop` carries the same facts. A CLI older than the host that runs the session (for example after a downgrade) cannot read that timeout result, and reports `RPC_ERROR` instead of `WAIT_TIMEOUT`.
- `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.
Expand Down
12 changes: 12 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ Useful flags:

On timeout, a standalone `wait` exits `11` and prints an error envelope: `ok: false`, `error.code: "WAIT_TIMEOUT"`, and the wait result under `result` (`timedOut: true`, and `matched: false` for render waits). Without `--json`, it prints `WAIT_TIMEOUT: <message>` to stderr. When the wait lasted its whole timeout, the message names it, for example `Wait timed out after 30000 ms (the default; pass --timeout <ms>, or --timeout 0 to wait without a limit).` Inside `batch`, a timed-out `wait` step is a step failure with the same `WAIT_TIMEOUT` exit code under fail-fast, and its message names the step's timeout, for example `Render wait at step 0 timed out after 1000 ms.`

A render wait reads the session's event log. When that log has stopped at its limit (see [the event log limit](TROUBLESHOOTING.md#a-recording-ends-early-or-an-export-names-the-event-log-limit)), a timed-out render wait adds `The event log stopped at seq 41 (byte limit), so output after it is not seen.` to its message, with and without `--json`, and `error.details.eventLogStop` holds `{ "reason": "bytes", "seq": 41 }`. The host reports the stop with its timeout result, so a wait that times out at the moment the log stops names it too. A `batch` `wait` step adds the same sentence to its message. On an exited session, `wait` replays the stored log, and a wait that the replayed screen does not satisfy fails with `REPLAY_ERROR`. Its message ends with the same sentence, and `error.details.eventLogStop` carries the same facts. `reason` is `unknown` when the stop marker names no limit, and the sentence then reads `(reason unknown)`. `--idle-ms` and `--exit` waits do not read the log, so they add nothing. A wait that matches is not changed.

`WAIT_TIMEOUT` only means that the timeout ran out. When 10 render polls in a row fail (about 3 s, for example because the renderer cannot start), a render `wait` stops with the renderer's error instead, also with `--timeout 0`: the same `error.code` and exit code that `snapshot` gives for that failure, usually `RPC_ERROR` (exit `9`, `retryable: true`), and no `result`. A `batch` `wait` step fails with that code, and its step record has no `timedOut`. Fewer failed polls in a row are retried.

### Echo-Match
Expand Down Expand Up @@ -325,6 +327,16 @@ agent-tty record diff <session-id> <session-id> --at-seq-a 0 --json
- The line diff is LCS-minimal for any realistic screen. As a bounded-memory safeguard, when the differing region (after matching the common prefix and suffix) is so large that the product of its two side lengths exceeds a 4,000,000-cell budget (for example 1,000 × 4,001 lines), that region degrades to a plain delete-then-add block instead of a minimal diff — `identical`, both `screenHash` values, and full-screen reconstruction remain exact; only diff minimality is reduced.
- The command works entirely offline from `events.jsonl`; sessions may be running or exited. The exit code is `0` whether or not the screens differ — automation should read `identical` from the JSON result.

## `inspect`

```bash
agent-tty inspect <session-id> --json
```

`inspect` prints the session record and what the Home holds for it: `eventCount`, `lastEventSeq`, `eventLogBytes`, `artifacts`, `terminationCategory`, `rendererRuntime` and, for a live host, `host`.

`eventLogStop` appears only when the session's event log stopped at its limit (see [the event log limit](TROUBLESHOOTING.md#a-recording-ends-early-or-an-export-names-the-event-log-limit)). It is `{ "reason": "bytes" | "events" | "unknown", "seq": <n> }`: `reason` names the limit that stopped the log (52,428,800 bytes, or 250,000 events, or `unknown` when the marker names no limit), and `seq` is the `seq` of the marker that the host wrote when it stopped. Events after that `seq` are not recorded, so snapshots, waits and exports show the session as it was then. `inspect` reads `eventCount`, `eventLogBytes` and the field in one read of `events.jsonl`, so they describe the same bytes, and a live session and an exited one report the same. It reads logs that are over the size limit that other commands read. The field is absent for a log that has not stopped. Without `--json`, one line reports it, for example `Event Log Stopped: at seq 41 (byte limit); later events are not recorded`.

## `destroy`

`destroy` ends the session's program and every process that the program left in its process group, then records the session as `destroyed`.
Expand Down
2 changes: 1 addition & 1 deletion src/batch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This directory parses and runs a Batch: an ordered JSON list of `type`, `paste`,
- A non-wait step that produced a seq, failed or not, becomes the `afterSeq` baseline for the next wait [executor.ts:executeBatch].
- A thrown error that is not a `CliError` becomes an `INTERNAL_ERROR` failed record [executor.ts:reframeAsInternalError].
- A waited `run` that does not complete fails with `WAIT_TIMEOUT` or `SESSION_NOT_RUNNING` [executor.ts:runRunStep].
- A `wait` that times out or does not match fails with `WAIT_TIMEOUT` [executor.ts:runWaitStep]. When it timed out, the message names the step's timeout in ms. A `wait` whose RPC rejects (for example after repeated renderer failures) fails with that error's code [executor.ts:executeBatch].
- A `wait` that times out or does not match fails with `WAIT_TIMEOUT` [executor.ts:runWaitStep]. When it timed out, the message names the step's timeout in ms. The host sends `eventLogStop` with a timed-out `wait` result. When it does, the message ends with `The event log stopped at seq <n> (<byte limit|event limit|reason unknown>), so output after it is not seen.` Every failed `wait` step takes the stop from its own result, so a `--keep-going` batch never rereads the event log. A `wait` whose RPC rejects (for example after repeated renderer failures) fails with that error's code [executor.ts:executeBatch].
- All record schemas are `.strict()` [result.ts:BatchResultSchema].
- `buildPartialBatchResult` marks the first unrecorded step `interrupted` and the rest `not-run` [result.ts:buildPartialBatchResult].
- `createRpcStepDriver` asserts a non-empty `socketPath` and validates every RPC response with its result schema [stepDriver.ts:createRpcStepDriver].
Expand Down
8 changes: 7 additions & 1 deletion src/batch/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import type { StepDriver } from './stepDriver.js';

import { CliError } from '../cli/errors.js';
import { eventLogStopTimeoutNote } from '../cli/eventLogStop.js';
import { ERROR_CODES, makeCliError } from '../protocol/errors.js';
import { unreachable } from '../util/assert.js';
import { unreachedStepRecord } from './result.js';
Expand Down Expand Up @@ -285,10 +286,15 @@ async function runWaitStep(
...observations,
error: toBatchStepError(
makeCliError(ERROR_CODES.WAIT_TIMEOUT, {
message:
message: [
result.timedOut && step.timeoutMs !== undefined
? `Render wait at step ${String(index)} timed out after ${String(step.timeoutMs)} ms.`
: `Render wait at step ${String(index)} timed out before its condition was met.`,
// The host sends the stop with a timed-out result.
...(result.eventLogStop === undefined
? []
: [eventLogStopTimeoutNote(result.eventLogStop)]),
].join(' '),
}),
),
};
Expand Down
1 change: 1 addition & 0 deletions src/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This directory is the agent-tty command-line front end. `main.ts` is the bin ent
- `emitSuccess`, `emitSuccessSync`, `emitFailure`, `emitCliError`, `argvHasFlag`, `exitAfterOutputFlush`, `formatSuccessOutput`, `writeJsonEnvelope`, `writeHumanLines`, `writeHumanErrorLines`, `writeStdoutSync`, `setColorEnabled`. [output.ts:emitSuccess] [output.ts:emitFailure] [output.ts:writeStdoutSync] [output.ts:argvHasFlag] [output.ts:exitAfterOutputFlush]
- `resolveCommandTarget(options)` returns a `CommandTarget` (`sessionId`, `sessionDirectory`, `manifestPath`, `socketPath`, `manifest`). Also exports `ResolveCommandTargetOptions` and `CommandTargetManifest`. [commandTarget.ts:resolveCommandTarget] [commandTarget.ts:CommandTarget]
- `assertSessionCommandable(manifest, sessionId)` and `checkedSessionDir(home, sessionId)`. [sessionGuards.ts:assertSessionCommandable] [sessionGuards.ts:checkedSessionDir]
- `describeEventLogStop(stop)` words where the stored log stopped, as `at seq <n> (byte limit)`, `at seq <n> (event limit)` or `at seq <n> (reason unknown)`. `eventLogStopTimeoutNote(stop)` is the sentence that a `WAIT_TIMEOUT` message adds. The `unknown` reason reads `(reason unknown)`. `inspect`, `wait` and `batch` use them. None of them reads the event log for a wait: the stop comes with the host's wait result, or from the events of an offline replay. [eventLogStop.ts:describeEventLogStop] [eventLogStop.ts:eventLogStopTimeoutNote]
- `exitCodeForError(code)`. [exitCodes.ts:exitCodeForError]
- `warnIfUnsupportedNode(logger, version)`. [nodeRuntime.ts:warnIfUnsupportedNode]
- `CliError` (`code`, `retryable`, `details`, `result`). `emitCliError` copies `result` into the error envelope when it is set. [errors.ts:CliError] [output.ts:emitCliError]
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This directory holds one handler module per agent-tty CLI command. `src/cli/main
- `mouse` validates its options with `MouseParamsSchema` before it resolves the session, so a malformed `--row` or `--col` is `INVALID_INPUT` with command `mouse`. [mouse.ts:runMouseCommand]
- Text input and `--file` are mutually exclusive. The file must be a regular file per `lstat` and at most `MAX_INPUT_FILE_SIZE` bytes. Violations throw `INVALID_INPUT`. [inputSource.ts:resolveCommandInputText]
- `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]
- 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.` The host sends `eventLogStop` with the result of a render wait that timed out. When it does, in both modes the message ends with `The event log stopped at seq <n> (<byte limit|event limit|reason unknown>), so output after it is not seen.` and `details.eventLogStop` carries `{ reason, seq }`. The CLI never rereads the event log for a wait. `--idle-ms` and `--exit` waits carry no stop. When the host is unreachable, `wait` replays the stored log. A wait that the replayed screen does not satisfy fails with `REPLAY_ERROR`, and its message and `details.eventLogStop` carry the stop that `findEventLogStop` finds in the replayed events. [wait.ts:throwIfTimedOut] [wait.ts:timeoutMessage] [wait.ts:buildOfflineRenderWaitResult]
- `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`. 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]
Expand All @@ -46,7 +46,7 @@ This directory holds one handler module per agent-tty CLI command. `src/cli/main
- Session commands send RPCs over the session socket through `sendRpc`. The method names are `run`, `type`, `paste`, `sendKeys`, `mouse`, `mark`, `resize`, `signal`, `snapshot`, `screenshot`, `wait`, `waitForRender` and `inspect`. `create` also sends `inspect` while it polls. [run.ts:runRunCommand] [send-keys.ts:runSendKeysCommand] [wait.ts:runWaitCommand] [create.ts:runCreateCommand]
- `create` calls `allocateSession` (`src/host/lifecycle.ts`) with a `launch` callback that records the session ID and starts the host process through `launchHost`. If `launchHost` or a later step of `allocateSession` throws, `create` removes the session directory with `fs.rm`. A bad `cwd`, shell path or command segment fails before `allocateSession` creates the directory, so no session directory is left behind. A failure between `mkdir` and the callback, such as a failed `chmod`, leaves the empty session directory in place. Any failure after `allocateSession` returned, a failed readiness poll included, writes the empty `create-failed` marker with `writeCreateFailedMarker` and logs a failed marker write at debug level (`writeMarkerOrLog`). On Windows, while that write fails, the manifest names no program and the host runs (`hostRuns`, where a probe that throws counts as an exited host), it writes the marker again every 100 ms, up to 50 times, and reads the manifest after each write. It then calls the host's `stop()` (on Windows only once the manifest names the program) and leaves the session directory in place. `create` rethrows the error with `details.sessionId` added, and an error that is not a `CliError` becomes `INTERNAL_ERROR`. In text mode it first writes `Session <id> failed to start.` to stderr with `writeHumanErrorLines`. It registers the Home through `upsertHome`. [create.ts:runCreateCommand] [create.ts:writeMarkerOrLog] [create.ts:hostRuns]
- `gc` reads session directories, calls `reconcileSession` (which can rewrite manifests), deletes collectable session directories with `fs.rm` outside dry-run mode, and deregisters Homes through `forgetHome`. [gc.ts:gcSessions] [gc.ts:runGcCommand]
- `inspect` calls `reconcileSession` when the host is unreachable, and for an ended session other than destroyed whose folder holds the destroy marker. When that repair throws, it shows the manifest as it reads. [inspect.ts:runInspectCommand]
- `inspect` calls `reconcileSession` when the host is unreachable, and for an ended session other than destroyed whose folder holds the destroy marker. When that repair throws, it shows the manifest as it reads. It reads `eventCount`, `eventLogBytes` and `eventLogStop` in one pass over `events.jsonl` with `scanEventLog`, for a live host and for offline replay alike, so `eventLogStop.seq` is never above `lastEventSeq`. It leaves `eventLogStop` out when the log holds no stop marker. Without `--json`, an `Event Log Stopped:` line follows `Event Log Bytes:`. [inspect.ts:runInspectCommand] [inspect.ts:formatSessionLines] [src/storage/eventLogCodec.ts:scanEventLog]
- `destroy` and `list` call `destroySession` and `listSessions` from `src/host/lifecycle.ts`. [destroy.ts:runDestroyCommand] [list.ts:runListCommand]
- `snapshot`, `screenshot`, `wait` and `record diff` run offline replay through `withOfflineReplayRenderer` (`src/replay/offlineReplay.ts`). `record diff` sends no RPC. [wait.ts:runOfflineRenderWait] [record-diff.ts:runRecordDiffCommand]
- Offline `snapshot` and `screenshot` write artifact files into the session artifacts directory and append artifact-manifest entries through `publishArtifactWithRollback` (`src/storage/artifactManifest.ts`). `snapshot` writes a temp JSON file through `writeTextFileAtomic`, and `screenshot` gets a temp PNG from the renderer. `publishArtifactWithRollback` links or copies the temp file into place without replacing an existing file. [src/snapshot/capture.ts:captureSnapshotResult] [src/snapshot/capture.ts:persistSnapshotArtifact] [src/screenshot/capture.ts:captureScreenshotResult]
Expand Down
Loading
Loading