Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
025f41d
fix: let the webm replay deadline follow the recording length
ThomasK33 Nov 6, 2026
ff8f2d2
perf: wait for one frame per flush when replaying output in ghostty-web
ThomasK33 Nov 6, 2026
8d586e8
fix: stop the webm replay on a stall instead of a deadline
ThomasK33 Nov 6, 2026
b121f75
test: watch for the stall through the backend's dispose, not the expo…
ThomasK33 Nov 6, 2026
313eef9
docs: describe the stall watchdog and the progress reports
ThomasK33 Nov 6, 2026
898cda9
docs: keep the progress bullet clear of the batching change
ThomasK33 Nov 6, 2026
ccd7119
test: keep a recording going through several waits that pass the long…
ThomasK33 Nov 6, 2026
3407142
docs: state the measured bridge call time next to the stall window
ThomasK33 Nov 6, 2026
3a8c8d9
fix: keep max-speed WebM frames per moment and paint each ghostty-web…
ThomasK33 Nov 6, 2026
4a65f4d
docs: keep the WebM frame explanation in the timing paragraph of USAG…
ThomasK33 Nov 6, 2026
9da052f
fix: keep events with one timestamp in one max-speed flush when the f…
ThomasK33 Nov 6, 2026
060eed1
test: wait for the last screencast frame before counting frames
ThomasK33 Nov 6, 2026
60c6553
Merge remote-tracking branch 'origin/main' into perf-batch-r3
ThomasK33 Nov 6, 2026
078fe21
perf: note painted graphemes in the page call of a flush, bound max-s…
ThomasK33 Nov 6, 2026
0663f72
test: pin a failed painted-grapheme note
ThomasK33 Nov 6, 2026
e1a7278
docs: describe the max-speed frame budget and the in-call painted-gra…
ThomasK33 Nov 6, 2026
028a1ab
perf: size the max-speed frame budget from the measured flush cost
ThomasK33 Nov 6, 2026
dfde02d
docs: update the max-speed frame budget comment with the measured flu…
ThomasK33 Nov 6, 2026
e64b021
test: pin the max-speed frame budget, resize hold and note, painted-s…
ThomasK33 Nov 6, 2026
3da5a57
docs: say that max-speed shows most steps of a 30 ms progress bar
ThomasK33 Nov 6, 2026
bc68da9
test: wait for a quiet first screen before counting screencast frames…
ThomasK33 Nov 6, 2026
e39bd15
docs: say that max-speed also holds a frame after a resize
ThomasK33 Nov 7, 2026
e819920
Merge remote-tracking branch 'origin/main' into pr358-optA
ThomasK33 Nov 8, 2026
e98841c
docs: do not claim that max-speed shows most steps of a 30 ms progres…
ThomasK33 Nov 8, 2026
8721085
Merge #358 head (e98841cd) into the WebM stall watchdog branch
ThomasK33 Nov 8, 2026
21b0ac1
fix: report progress after the resize call of a timed WebM replay
ThomasK33 Nov 8, 2026
211e73b
fix: keep awaiting the hook between two calls of a flush
ThomasK33 Nov 8, 2026
b7ed95f
docs: say how long a healthy WebM replay stays silent
ThomasK33 Nov 8, 2026
7cc480a
Merge origin/main (bf1d2843) into the WebM stall watchdog branch
ThomasK33 Nov 8, 2026
2d58cba
Merge origin/main (934760db, #358) into the WebM stall watchdog branch
ThomasK33 Nov 9, 2026
00cb184
fix: wait a long recorded gap in full and report the last event of a …
ThomasK33 Nov 9, 2026
1b29fb3
test: stop a hung replay after a wait or a window longer than a timer…
ThomasK33 Nov 9, 2026
4f38867
fix: say exactly where a WebM replay reports and how long it stays si…
ThomasK33 Nov 9, 2026
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: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,8 @@ agent-tty record export <session-id> --format webm --json | jq -c '.result.missi

WebM export replays with recorded wall-clock timing by default. Pass `--timing accelerated` (idle gaps clamped to 400ms) or `--timing max-speed` for a time-compressed video. A frame of the video shows the screen after the output that arrived within one frame hold of the first output of that frame: 16 ms in `recorded` and `max-speed` mode, 100 ms in `accelerated` mode. Output events that close together share one frame, so a burst of output does not cost one frame per event, and a state that the next output of the frame replaces is not shown. In `recorded` and `accelerated` mode, an event that changes no pixel (an input, a marker, a run-complete, a signal or the exit) also ends a frame. In `max-speed` mode it does not: only recorded time ends a frame, so output on both sides of an input event that is less than 16 ms apart shares one frame. `max-speed` removes the idle time. It makes a frame at most once per 16 ms of recorded time, so a progress bar that updates every 30 ms shows many of its steps and not only its last one. It makes at most about 2,400 frames, so a recording longer than 38 s gets one frame per 1/2,400 of its recorded time instead (a 1-hour recording: one per 1.5 s), and its export time depends on its length and not on its number of events. `max-speed` waits for one animation frame after each frame but holds no further time between two frames, so two frames that follow each other can end up in one video frame. The video has 25 frames per second, so it can skip a state that lasts less than one video frame (40 ms). A step of 30 ms is shorter than that, and the number of steps that survive depends on how fast the host runs the export.

With recorded timing the replay waits out every recorded gap, so an export takes at least as long as the recording. It takes longer when the recording is dense or resizes a large terminal, because each frame costs time in the browser and each resize of a large terminal costs seconds. On a shared host, a recording of 7.4 s with 7,401 events took 473 s at `--timing accelerated`, and a recording of 0.8 s with 400 resizes of a 600x150 terminal took 910 s at recorded timing. The export has no total time limit. It stops only when the renderer makes no progress for 5 minutes, and then fails with `EXPORT_ERROR`. The message names the window and the last event that the replay reached, and `details` holds `stallWindowMs`, `lastSeq` and `timingMode`. Waiting out a recorded gap is not a stall.

SVG export renders styled grid frames from the event log through the native `libghostty-vt` backend with no browser and no ffmpeg, so it requires the optional `@coder/libghostty-vt-node` package (there is no `ghostty-web` fallback). The output is deterministic: exporting the same session twice produces byte-identical, diffable SVG. `--format svg` writes a still image of the final screen and ignores event timing, so it also works on a log whose clock stepped backward (`--animate` refuses such a log and names the event where the clock stepped back); add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG).

Animated SVG capture retains every distinct frame's styled grid in memory, so it is bounded by a total-cell budget (the V8 heap limit divided by 400 bytes, at most 20M cells or roughly 10,000 distinct 80x24 frames, adapting to terminal size); recordings that exceed it fail with a clear error suggesting a still SVG or WebM export instead. The SVG text is also bounded (the V8 heap limit divided by 8 bytes, at most 2^28 characters or about 268 MB of mostly ASCII text), so a dense, colorful animation that fits the cell budget can still fail with the same kind of error.
Expand Down
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.` 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`. A `GhosttyWebCrashedError` from a WebM export fails with `EXPORT_ERROR` and adds its message to the fixed message. [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`. A `webm` replay whose renderer makes no progress for the stall window fails with `EXPORT_ERROR`, a message that names the window and the last event that the replay reached, and `stallWindowMs`, `lastSeq` and `timingMode` in `details`. A `GhosttyWebCrashedError` from a WebM export fails with `EXPORT_ERROR` and adds its message to the fixed message. [record-export.ts:runRecordExportCommand]
- `batch` parses the whole plan with `parseBatchPlan` before it resolves the session target. [batch.ts:runBatchCommand]
- `create` polls readiness with an `inspect` RPC every `READINESS_POLL_INTERVAL_MS` (100 ms) for as long as the host process that it spawned runs (`hostRuns`). A probe that fails with `HOST_UNREACHABLE`, `HOST_TIMEOUT` or an `RPC_ERROR` whose cause is `EPIPE` (the host closed its server as the probe connected, as a host does whose program has already exited) counts as not ready yet (`isClosedByHost`). `create` rethrows that last error as soon as the host process has exited, and after `READINESS_DEADLINE_MS` (30 s) in any case. Any other error ends the wait at once. It checks the host process before it reads the manifest, so a session that exited before the host did is a result, not an error. [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
20 changes: 20 additions & 0 deletions src/cli/commands/record-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { generateAsciicast } from '../../export/asciicast.js';
import { renderGridFramesToSvg } from '../../export/svg.js';
import {
generateWebmExport,
WebmReplayStallError,
type WebmExportResult,
} from '../../export/webm.js';
import { captureGridFrames } from '../../replay/gridFrames.js';
Expand Down Expand Up @@ -597,6 +598,25 @@ export async function runRecordExportCommand(
});
}

if (error instanceof WebmReplayStallError) {
const position =
error.lastSeq >= 0
? `after event ${String(error.lastSeq)}`
: 'at the start of the replay';
throw makeCliError(ERROR_CODES.EXPORT_ERROR, {
message: `Failed to export session "${options.sessionId}" as ${format}: the renderer made no progress for ${String(error.stallWindowMs / 1000)} s ${position} (${error.timingMode} timing).`,
details: {
sessionId: options.sessionId,
format,
out: options.out,
stallWindowMs: error.stallWindowMs,
lastSeq: error.lastSeq,
timingMode: error.timingMode,
},
cause: error,
});
}

throw makeCliError(ERROR_CODES.EXPORT_ERROR, {
message: `Failed to export session "${options.sessionId}" as ${format}.${
error instanceof GhosttyWebCrashedError ? ` ${error.message}` : ''
Expand Down
5 changes: 3 additions & 2 deletions src/export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ This directory converts a session's event log into recording exports. `generateA
- `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].
- `WebmExportDeps`: optional `backendFactory` and `replayTimeoutMs`, for tests and callers that supply their own backend [webm.ts:WebmExportDeps].
- `WebmExportDeps`: optional `backendFactory` and `stallWindowMs`, for tests and callers that supply their own backend. `stallWindowMs` replaces the 5-minute stall window (see Invariants) [webm.ts:WebmExportDeps].
- `WebmExportResult`: `capturedAtSeq`, `durationMs`, `outputEventCount`, `resizeEventCount`, `cols`, `rows`, `profileName`, `timingMode` and `rendererBackend`, plus the optional `missingGlyphs` and `missingGlyphsTruncated` of `RecordedGlyphFields` [webm.ts:WebmExportResult].
- `generateWebmExport(options, deps?)` returns a `Promise<WebmExportResult>` [webm.ts:generateWebmExport].
- `WebmReplayStallError` is the error that `generateWebmExport` rejects with when the renderer makes no progress for the stall window. It carries `stallWindowMs`, `lastSeq` (the last event that the replay reached, or -1 for none) and `timingMode` [webm.ts:WebmReplayStallError].
- `renderGridFramesToSvg({ profile, frames, animate, heapSizeLimit? })` returns SVG text from styled grid frames, which `captureGridFrames` in `src/replay/gridFrames.ts` builds. It runs synchronously and leaves the file write to the caller [svg.ts:renderGridFramesToSvg].

## Invariants
Expand All @@ -26,7 +27,7 @@ This directory converts a session's event log into recording exports. `generateA
- A requested `libghostty-vt` renderer is replaced with `ghostty-web`. A code comment says `libghostty-vt` is semantic-only [webm.ts:generateWebmExport].
- The default backend factory accepts only `ghostty-web` [webm.ts:createDefaultBackend].
- Timing presets: `accelerated` caps gaps at 400 ms and holds frames at least 100 ms. `max-speed` uses a frame hold of 16 ms, but holds it only after the last flush of the replay and before and after a resize (see the `ghostty-web` README). Each mode holds the final frame (1000 ms, or 500 ms for `max-speed`) [webm.ts:buildReplayTimingOptions].
- The timed replay fails after `REPLAY_TIMEOUT_MS` (5 minutes) [webm.ts:REPLAY_TIMEOUT_MS].
- The timed replay has no total time limit. It fails with `WebmReplayStallError` only when the renderer makes no progress for `REPLAY_STALL_WINDOW_MS` (5 minutes). The backend reports progress through the `onProgress` argument of `replayWithTiming`: each report restarts the window. A report that comes before a wait on the timing schedule carries the length of that wait, and the window starts after it, so a long recorded gap is not a stall. A stretch without a report is one page call or a fixed hold, never the whole replay: a call carries at most 1000 chunks, and a report follows it. The stretches are the start to the first report (the replay sizes the terminal and holds one frame), one page call (the call that ends a flush also writes the chunks that the earlier calls of the flush held, so the event log limit of 50 MiB bounds it), and the last report to the end (the final frame hold of 1000 ms, or 500 ms at `max-speed`, with the glyph probes inside it, and then a read of the screen). Examples that I measured at 600x150 on a shared host under load: the start took 1.3 to 2.0 s, the end 1.8 to 2.8 s, a flush of 11,000 events of 4 KB 8.4 s, and one stretch inside the loop 4.4 s. These are examples and not bounds. The longest, 8.4 s, is under 3% of the window. No timer runs longer than 2^31-1 ms (the longest delay that a timer runs): a longer span is a chain of timers. The watchdog starts no timer once the replay has ended, failed or stalled. Boot and finalize run outside the watchdog [webm.ts:replayWithStallWatchdog, webm.ts:REPLAY_STALL_WINDOW_MS].
- `renderGridFramesToSvg` needs the profile font size `SVG_FONT_SIZE` (14) and draws fixed 8.4x18 px cells, so equal input gives byte-identical output [svg.ts:renderGridFramesToSvg]. It always embeds the JetBrains Mono subset, and embeds each other bundled face only when a glyph needs it [svg.ts:embeddedFontAssets]. Output past `defaultMaxSvgOutputLength` of the V8 heap limit (the heap limit divided by 8 bytes, at most `MAX_SVG_OUTPUT_LENGTH`) fails with `EXPORT_ERROR` [svg.ts:defaultMaxSvgOutputLength].

## Side Effects
Expand Down
Loading
Loading