Skip to content

Latest commit

 

History

History
503 lines (385 loc) · 68.8 KB

USAGE.md

File metadata and controls

503 lines (385 loc) · 68.8 KB

Usage

agent-tty is designed around isolated homes, JSON envelopes, observable waits, semantic snapshots, and renderer-backed artifacts. Use public agent-tty ... commands in user-facing docs and automation; when developing inside this source tree, translate examples locally to npx tsx src/cli/main.ts ....

Core Workflow

AGENT_HOME="$(mktemp -d)"
agent-tty --home "$AGENT_HOME" doctor --json

SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId')
agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'printf "ready\n"' --json
agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --text 'ready' --json
agent-tty --home "$AGENT_HOME" snapshot "$SESSION_ID" --format text --json
agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json
agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format webm --json
agent-tty --home "$AGENT_HOME" destroy "$SESSION_ID" --json

Recommended sequence:

  1. Create an isolated home with --home.
  2. Run doctor --json before screenshot or recording workflows.
  3. Create a session with create --json.
  4. Use run for shell setup and multiline bootstrap commands.
  5. Use wait for observable terminal state instead of blind sleeps.
  6. Use snapshot for semantic inspection.
  7. Use screenshot or record export for reviewer-facing artifacts.
  8. Destroy the session when the workflow is done.

Essential Commands

# Environment and skills
agent-tty version --json
agent-tty --home <path> doctor --json
agent-tty skills list
agent-tty skills get agent-tty

# Lifecycle
agent-tty --home <path> create --json -- /bin/bash
agent-tty --home <path> list --json
agent-tty --home <path> inspect <session-id> --json
agent-tty --home <path> destroy <session-id> --json
agent-tty --home <path> gc --json

# In-session control
agent-tty --home <path> run <session-id> 'command here' --json
agent-tty --home <path> type <session-id> 'literal text' --json
agent-tty --home <path> paste <session-id> 'multiline payload' --json
agent-tty --home <path> send-keys <session-id> Enter Ctrl+C --json
agent-tty --home <path> mouse <session-id> press --button left --row 8 --col 20 --json
agent-tty --home <path> resize <session-id> --cols 100 --rows 30 --json
agent-tty --home <path> signal <session-id> SIGTERM --json

# Observation and proof
agent-tty --home <path> wait <session-id> --text 'ready' --json
agent-tty --home <path> wait <session-id> --screen-stable-ms 1000 --json
agent-tty --home <path> snapshot <session-id> --format text --json
agent-tty --home <path> screenshot <session-id> --json
agent-tty --home <path> record export <session-id> --format asciicast --json
agent-tty --home <path> record export <session-id> --format webm --json
agent-tty --home <path> record export <session-id> --format svg --json

Every command that runs checks the Node version against the engines.node range in package.json. Help output, usage errors and invalid global options skip the check. On a Node outside that range, a prerelease build included, the command still runs and writes a warning to stderr, so stdout keeps one JSON envelope. --log-level error hides the warning. doctor fails its node-runtime check against the same range.

run

Use run when you want shell-oriented setup inside an existing session, especially multiline bootstrap scripts or commands that should preserve shell state.

agent-tty run <session-id> [command]
agent-tty run <session-id> --file ./setup.sh
agent-tty run <session-id> 'npm install && npm test' --timeout 60000 --json
agent-tty run <session-id> 'npm run dev' --no-wait

Important flags:

  • --timeout <ms>: wait timeout in milliseconds. Default: 30000.
  • --no-wait: fire-and-forget mode. The command is injected and the CLI returns without waiting for completion.
  • --file <path>: read command text from a file instead of the positional argument.
  • --json: emit a machine-readable command envelope.

Use type when the target application needs literal interactive typing, paste when the target should receive a literal pasted payload, and send-keys for discrete control keys such as Enter, Escape, or Ctrl+C. run is not structured output capture and does not report the child command's exit status.

mouse

mouse sends a cell-addressed event through the terminal modes selected by the child. The libghostty-vt renderer is required because static escape sequences cannot account for tracking mode, wire format, pixel mode, viewport clamping, or motion deduplication.

agent-tty mouse <session-id> press --button left --row 8 --col 20 --json
agent-tty mouse <session-id> move --row 9 --col 24 --json
agent-tty mouse <session-id> release --button left --row 9 --col 24 --json
agent-tty mouse <session-id> press --button wheel-down --row 9 --col 24 --json

Rows and columns are zero-based. press and release require --button; move uses the host's held-button state so press, move, and release form a real drag. Buttons are left, middle, right, wheel-up, wheel-down, wheel-left, and wheel-right. --shift, --alt, and --ctrl add modifiers. An event suppressed by the child's tracking mode succeeds with reported: false and bytesWritten: 0. A cell outside the session's current grid exits 2 with INVALID_INPUT. Missing renderer support exits 12 with CAPABILITY_UNAVAILABLE.

The default surface uses virtual one-pixel cells. For SGR-pixels with a known surface, pass --cell-width and --cell-height in pixels; the requested cell maps to its top-left pixel. For example, row 2, column 3 with a 10x20 cell maps to pixel 30,40. Screen dimensions follow the session's current rows and columns. The same optional cellWidth and cellHeight fields are accepted in batch steps.

The host writes the native buffer directly to the PTY, including non-UTF-8 X10 bytes. Each input_mouse event stores those exact bytes as dataBase64 alongside the action, cell position, modifiers, renderer, and any explicit cell metrics. Suppressed events still receive a sequence number and record an empty payload. The mouse encoder comes with the optional @coder/libghostty-vt-node package, and doctor --json reports it as the mouse-input capability. Without that package, the other commands still work and mouse exits 12.

wait

Use wait to synchronize on terminal state:

agent-tty wait <session-id> --text 'ready' --json
agent-tty wait <session-id> --regex 'READY|DONE' --json
agent-tty wait <session-id> --regex 'READY>$' --scope cursor-line --json
agent-tty wait <session-id> --screen-stable-ms 1000 --json
agent-tty wait <session-id> --idle-ms 500 --json
agent-tty wait <session-id> --exit --json

Useful flags:

  • --text <string>: wait for text to appear in rendered output.
  • --regex <pattern>: wait for a regex match in rendered output.
  • --scope <scope>: where --text/--regex match — screen (default, whole visible screen) or cursor-line (only the row the cursor is on). See Echo-Match.
  • --screen-stable-ms <ms>: wait for the rendered screen to be stable.
  • --after-seq <n>: only match renderer snapshots produced after this Event Log sequence — thread an input command's returned seq here so the wait cannot match pre-input screen state. -1 is the sequence before the first event, which a snapshot of a session that has printed nothing reports.
  • --idle-ms <ms>: wait for output idleness.
  • --exit: wait for the process to exit.
  • --timeout <ms>: maximum wait time in milliseconds. The default is 30000 (30 s). 0 waits without a limit. A window that cannot finish within the timeout is rejected with INVALID_DURATION: an --idle-ms must be shorter than the timeout, and a --screen-stable-ms must be more than one render poll (200 ms) shorter, with a timeout of more than two polls (400 ms), because the stable clock starts at the first snapshot and a later snapshot must confirm it. With the default timeout, --screen-stable-ms can be at most 29799. --timeout 0 skips the check.

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), 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

A whole-screen wait can be satisfied by the terminal's echo of a just-typed command: run <sid> 'echo Done' followed by wait --text Done matches the echoed command line itself, not the command's output. Use --scope cursor-line to restrict --text/--regex matching to the row the cursor is currently on. Once Enter is pressed the cursor moves past the echoed line, so a cursor-line wait cannot match the echo. It is ideal for waiting on prompts, which render exactly at the cursor:

agent-tty wait <session-id> --regex 'READY>$' --scope cursor-line --json

Rendered lines are right-trimmed of trailing ASCII spaces, so anchor prompt regexes without the trailing space: a prompt displayed as READY> matches READY>$, not READY> $.

A standalone cursor-line wait issued right after an input command (type, send-keys, run --no-wait) can still match the pre-input screen: input commands return once the input is logged, before the application's response is necessarily rendered. If the cursor row already matched before the input — for example a repeated prompt in an echo-disabled application — the wait returns without the application having responded. Thread the input command's returned seq into --after-seq so the wait only observes screen state produced after the input; inside batch, wait steps get this anchoring automatically from the Wait Baseline:

SEQ=$(agent-tty send-keys <session-id> Enter --json | jq -r '.result.seq')
agent-tty wait <session-id> --regex 'READY>$' --scope cursor-line --after-seq "$SEQ" --json

For waiting on output text that scrolls past the cursor, prefer a distinctive output token or combine --text with --screen-stable-ms.

Screen Hash

snapshot results (both --format structured and --format text) and a matched wait result carry an optional screenHash: a lowercase 64-character hex SHA-256 of the visible screen text. Compare it across two calls to tell whether the visible screen actually changed — equal hashes mean identical visible content, even if the event-log sequence advanced on a no-op repaint.

  • It hashes the visible screen only. It is not a hash of the --format text output, which also includes scrollback, so the hash ignores scrollback growth.
  • It is distinct from the screenshot result's pixel sha256: screenHash is content identity, the screenshot sha256 is pixel identity, and the two are not interchangeable.
  • A wait that times out (or finds the host unreachable with no observed screen) omits screenHash, so a missing hash unambiguously means "no screen was observed" rather than an error.

batch

Use batch to run an ordered sequence of input-and-wait steps against one session in a single invocation, instead of coordinating separate run/type/paste/send-keys/wait calls. Each wait step is anchored to a Wait Baseline — it only considers screen state produced after the preceding input step — so a batch cannot race ahead and match a stale screen the way a hand-written shell loop can.

agent-tty batch <session-id> '[steps]' --json
agent-tty batch <session-id> --file ./steps.json --json
agent-tty batch <session-id> '[steps]' --keep-going --json

Steps are a JSON array; each step is exactly one verb. The shape mirrors the rest of the CLI:

[
  { "run": "nvim --clean", "noWait": true },
  { "wait": { "screenStableMs": 1000 } },
  { "sendKeys": ["i"] },
  { "mouse": { "action": "press", "button": "left", "row": 8, "col": 20 } },
  { "type": "hello" },
  { "sendKeys": ["Escape"] },
  { "type": ":wq" },
  { "sendKeys": ["Enter"] },
  { "wait": { "text": "written" } }
]
  • type / paste: a string of literal text.
  • sendKeys: a non-empty array of key names — individual named keys or single characters (e.g. ["Enter"], ["Ctrl+C"], ["Escape", "Enter"]). Multi-character literal text such as :wq is not a key name; send it with a type step.
  • mouse: the same action, button, row, col, and optional modifiers, cellWidth, and cellHeight fields as the mouse command. It uses the batch command's renderer.
  • run: a command string, with optional noWait (fire-and-forget) and timeout (ms). A run step is a waited run by default.
  • wait: the same conditions as the wait command — text, regex, scope, screenStableMs, cursorRow, cursorCol, and timeout (ms). An omitted timeout is 30000, the same default as the wait command, and 0 waits without a limit. A screenStableMs that is not more than one render poll (200 ms) shorter than the step's timeout, or a timeout of two polls (400 ms) or less, is rejected with INVALID_INPUT and details.stepIndex.

Input source and flags:

  • A positional [steps] JSON array xor --file <path> — supply exactly one. Passing both, or neither, is an INVALID_INPUT error.
  • --keep-going: attempt every step regardless of failures. By default a batch is fail-fast — the first failed step (a timed-out wait, or input to a session that is no longer commandable) stops the run, and the remaining steps are recorded not-run. A batch is not atomic: already-applied input cannot be undone.
  • --json: emit a machine-readable command envelope.

The --json result is a per-step envelope:

{
  "ok": true,
  "command": "batch",
  "result": {
    "steps": [
      {
        "index": 0,
        "kind": "run",
        "status": "completed",
        "seq": 4,
        "noWait": true,
        "runOutcome": "started",
        "durationMs": 12
      },
      {
        "index": 1,
        "kind": "wait",
        "status": "completed",
        "waitBaseline": 4,
        "matched": true,
        "timedOut": false,
        "capturedAtSeq": 9,
        "durationMs": 1003
      }
    ],
    "completedCount": 2,
    "failedIndices": []
  }
}

Each step record carries its index, kind, status (completed | failed | not-run | interrupted), and durationMs. Input steps report the Event Log seq they produced, and mouse steps also report reported and bytesWritten; wait steps report the waitBaseline they were anchored to plus matched / timedOut / matchedText / capturedAtSeq, and a matched wait step also carries the screenHash of the screen it observed (see Screen Hash). completedCount and failedIndices summarize the run. A fail-fast batch with a failed step exits with that step's exit code (e.g. 11 for a WAIT_TIMEOUT) and, with --json, prints an error envelope: ok: false, the step's error.code and message, error.details.stepIndex, and the same per-step result. Its retryable is always false, because a batch is not atomic and a rerun repeats the input of the steps that already ran. --keep-going exits 1 if any step failed and keeps ok: true. If the process is interrupted by SIGINT/SIGTERM, batch flushes a success envelope (ok: true) with the per-step results so far, the in-flight step marked interrupted and later steps not-run, then exits 130 (SIGINT) or 143 (SIGTERM).

The Wait Baseline fixes stale-match only. It does not fix echo-match: a whole-screen wait can still match the terminal's echo of a just-typed command (the echo renders after the baseline). Use "scope": "cursor-line" on the wait step to restrict matching to the cursor row (see Echo-Match), or wait for a distinctive output token. Interrupting a batch mid-wait leaves that wait's command still running on the session (the wait is abandoned, not cancelled), exactly like a caller timeout on run.

Screenshots And Recording Exports

Screenshots and WebM export use the ghostty-web reference visual renderer through Playwright/Chromium. When its page crashes during a replay, the error message says ghostty-web crashed and names a known cause (see Troubleshooting). Semantic snapshot, screen-hash, and render-backed wait paths prefer libghostty-vt when the optional native package is available and fall back to ghostty-web otherwise. Run doctor --json first in new environments.

On a session that has printed nothing yet, snapshot returns an empty screen of the session's size and screenshot returns a blank image. record export gives a header-only asciicast, a blank WebM or a blank SVG of the session's size, with durationMs: 0. An animated SVG of such a session reports durationMs: 1000, the hold of its one blank frame. All of them report capturedAtSeq: -1, and the default files are named screenshot--1-<profile>.png, recording--1-<format>.<ext> and recording--1-svg-<profile>[-animated].svg.

A live screenshot, and a live snapshot --renderer ghostty-web, first replay the session's event log into a renderer of the host. With --renderer libghostty-vt, the screenshot replays the log a second time, in a ghostty-web renderer that it boots for the screenshot. The more events the log holds, the longer that takes. The host reports progress when a renderer has booted and after each slice of a replay, and a slice aims at half a second. The command waits for as long as the replay moves forward, also when the replay belongs to a command that started earlier. It fails with HOST_TIMEOUT (exit 5) after 5 s in which the host has reported no progress, for example when the renderer hangs.

The screenshot hides the cursor unless you pass --show-cursor. --hide-cursor is the default, and the result reports cursorVisible for the choice.

agent-tty screenshot <session-id> --profile reference-dark --json
agent-tty screenshot <session-id> --show-cursor --json
agent-tty screenshot <session-id> --out ./proof.png --json
agent-tty record export <session-id> --format asciicast --out ./session.cast --json
agent-tty record export <session-id> --format webm --timing accelerated --out ./session.webm --json
agent-tty record export <session-id> --format svg --json
agent-tty record export <session-id> --format svg --animate --out ./session.svg --json

--out <path> on screenshot and record export writes the file to that path, so it outlives the Home. A relative path resolves against the working directory, and a file already at the path is replaced. Missing parent folders are created, like mkdir -p, and they stay when the capture fails afterwards. A parent you cannot write to or search, or a path inside the Home's sessions/ folder, fails with EXPORT_ERROR (exit 7) before the capture. agent-tty checks the folders it would create against sessions/ before it creates any. If a parent folder is swapped during that step, one empty folder can be left in the other place. agent-tty checks the parent folder again just before it moves the file into place. If the folder was replaced or removed, the command fails with EXPORT_ERROR (exit 7) and does not write the file. This holds even when the new folder is a fine place for the file: a script that runs rm -rf out && mkdir out while an export of out/proof.png runs gets exit 7. A replacement after that check and before the move is not caught. A replacement during the write, also one that is undone before the check, can leave a <file>.tmp-<id> temp file in the folder that was at that path during the write. screenshot --out still records the capture in the session's artifact manifest and returns the --out path as artifactPath. When the copy to --out fails, screenshot removes that capture and its manifest entry again. A second record export to the same path, the default path at the same sequence or the same --out, replaces the manifest entry of the first. When the file changed between the second export's write and its manifest update, for example when a file server set a new modification time, both entries stay. When another export or a tool left a file of another size at the path in that time, the export exits 0 and adds no manifest entry, because its bytes are gone.

The screenshot result lists missingGlyphs: each code point that the PNG draws as an empty .notdef box, with the zero-based row and col of each cell. A box appears when no font has the glyph, and also when Chromium draws a whole cluster (a letter plus a mark) with the first bundled font because no font has every part of it. The bundled fonts are a Latin JetBrains Mono subset and Symbols Nerd Font Mono, and after the host monospace font Noto Sans Symbols 2 and Noto Sans Math, which draw math, arrows, braille, legacy computing and other symbols that the host fonts lack. The Noto files leave out every code point with the Unicode Emoji property, also those that default to text presentation such as U+1F321, so a host color emoji font still draws them. The check is best effort: an empty array means it found no box (see Known limits below), and a result without missingGlyphs means the check did not run or failed. The list holds at most 4096 cells, and one screenshot checks at most 4096 distinct glyphs within a 1-second time budget. When a limit or the budget stops the check early, the result also has "missingGlyphsTruncated": true, the list can miss boxes, and the human output prints Glyph check incomplete. Glyphs that a host font draws are not listed, so the same session can list different code points on hosts with different fonts. The text snapshot still has the right characters.

Known limits: a letter that the font builds from a smaller precomposed letter plus a mark it lacks, such as Vietnamese ế (drawn as ê without the acute), is listed although the PNG shows a letter, not a box. With --show-cursor, a glyph under the block cursor is listed although the cursor hides it, and so is a glyph in a concealed (SGR 8) cell. The check uses the regular font style, so a bold or italic cell whose host font face differs in coverage can be judged wrong, and a grapheme that Chromium splits over several fonts is judged per code point, which can miss a box.

agent-tty screenshot <session-id> --json | jq -c '.result.missingGlyphs'
# [{"codePoint":"U+0378","cells":[{"row":0,"col":6}]}]

record export --format webm lists missingGlyphs as well: each code point that a bundled font drew as a box in at least one frame of the video. The entries have no cells, because a cell names a place in one frame. The check reads the screen after each output batch and each resize of the replay, so a box that a later frame clears is still listed, even when it shows for less than one video frame. The check runs while the video holds its last frame and gets that hold as its time budget, so a normal recording is not longer. A screen with thousands of distinct glyphs that the bundled fonts lack can make the check run past the hold and add a fraction of a second. The limits, the caps and missingGlyphsTruncated work as for screenshot. An empty array means the check found no box, and a result without missingGlyphs means the check did not run or failed. Asciicast results never have the field.

agent-tty record export <session-id> --format webm --json | jq -c '.result.missingGlyphs'
# [{"codePoint":"U+0378"}]

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: recorded time, a resize and the end of the recording end 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. Recorded time ends a frame at most once per 16 ms, so a progress bar that updates every 30 ms shows many of its steps and not only its last one. Recorded time ends 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). A resize that finds output waiting ends one more frame, and the limits above do not count it: 100,000 outputs that each have a resize 1 ms after them make 100,000 frames. A resize adds at most one frame, and none when no output is waiting. So the export time of a recording follows its length and its number of resizes, and not its number of output 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. The screen before a resize is the exception: max-speed keeps it on the page for one video frame (40 ms) before the resize, so that the video shows it. The video can still miss it in rare cases.

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, exporting a recording of 7.4 s with 7,401 events at --timing accelerated took 477 s, of which the replay took 477 s. Exporting a recording of 0.8 s with 400 resizes of a 600x150 terminal at recorded timing took 595 s and 2,582 s in two runs, depending on the load of the host. The replay took 592 s and 985 s of that. The second export spent the other 1,597 s outside the replay, in the browser start, the glyph check or the video finalize. The replay is the stage that the stall window below watches. 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.

SVG exports always embed the pinned JetBrains Mono latin subset. They also embed Symbols Nerd Font Mono, Noto Sans Symbols 2 and Noto Sans Math, each only when a rendered glyph needs it. The SVG uses the same font stack as the reference renderer, so a glyph outside the first two faces renders with the viewer's monospace font first, and with an embedded Noto face when that font lacks it. Glyphs in no bundled face (notably CJK and most emoji) render with the viewer's fonts. Text content and layout metrics stay deterministic even when fallback glyph shapes vary: every text run is pinned to the terminal grid via textLength, so columns never shift.

SVG export has known fidelity limits, because the native backend's snapshot cells do not yet carry every attribute (coder/libghostty-vt-node#15):

  • Only bold, italic, underline, and foreground/background colors are rendered. Reverse video (SGR 7), strikethrough (SGR 9), dim (SGR 2), and hidden text (SGR 8) render as plain text, so hidden text stays visible. Reverse video is the one most often visible in TUIs (selection bars, status lines, fuzzy-finder highlights). Use a PNG screenshot or WebM export when those attributes matter.
  • The cursor is always drawn. SVG frames show a block cursor even after the application hides it with ESC[?25l. PNG screenshots hide the cursor unless you pass --show-cursor.
  • Viewers without SMIL show only the final frame. Animated SVGs play in browsers. Viewers that ignore SMIL animation (librsvg-based previews, Inkscape) show the final frame as a still image.

Use --renderer ghostty-web, AGENT_TTY_RENDERER=ghostty-web, or Home config.json { "defaultRenderer": "ghostty-web" } to force legacy all-browser rendering. Use --renderer libghostty-vt only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record ghostty-web as the actual video producer.

ghostty-web provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals.

record diff

computeScreenHash (see Screen Hash) can only say whether two screens differ. Use record diff to see what changed: it replays two recorded sessions offline from their event logs and prints an LCS line diff of the canonical visible screens.

agent-tty record diff <session-id-a> <session-id-b> --json
agent-tty record diff <session-id> <session-id> --at-seq-a 0 --json
  • --at-seq-a <seq> / --at-seq-b <seq>: replay each side up to an Event Log sequence (default: latest). Diffing a session against itself at an earlier sequence shows how its screen evolved. -1 is the sequence of an empty event log, so it is valid only for a session that has no events yet.
  • The JSON result carries identical, per-side sessionId/capturedAtSeq/cols/rows/screenHash, and a diff array of { op: equal | delete | add, text, aRow?, bRow? } entries over the visible screen lines (0-based rows, no trimming or normalization — the same canonical lines that screenHash hashes). A side with an empty event log reports the pre-event blank screen with capturedAtSeq: -1 (no event was replayed).
  • Human output is a unified-style diff with ---/+++ headers naming each side's session, sequence, and hash prefix.
  • 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

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). 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. On Linux it also ends those processes when the program has already exited or the session has failed. A group whose processes each fork their successor and end too fast for any scan of /proc to show or read one, a fork chain, gets SIGSTOP first. Its stopped processes stay in view, so destroy can prove the group and kill it. A frozen group that agent-tty cannot prove is the session's gets SIGCONT. When the freeze misses a fork chain, as when another process resumes it before each check, the wait still finds the group id, and destroy fails with HOST_TIMEOUT instead of recording the session as destroyed. Under a PID 1 that never reaps, a group whose scans show only zombies reads ended, so there such a missed chain can outlive a destroy that reports success. destroy --force kills the host and the program's group without asking the host first. When a read of the program's group fails with an error that every later read repeats, such as program.json that refuses the read (EACCES) or a /proc scan that fails (EIO), gc keeps the session and reports "failed to check the session's program group (destroy --force removes it)", or "failed to reconcile session (destroy --force removes it)" for a session whose host died. Those errors are EACCES, EPERM, EIO, EISDIR, ENOTDIR, ELOOP and ENAMETOOLONG. A plain destroy fails on the same read. When the read of program.json or the first check of the group fails that way twice, 100 ms apart, destroy --force stops the host alone, without the group, and records the session as destroyed once the host has ended, so the next gc removes it. A process that the program left can outlive such a destroy --force. When the second read passes, destroy --force stops the group as usual. A read that fails after that first check, a group that reads alive, or a host that does not end (HOST_TIMEOUT) still fails it. Other errors, such as EMFILE, ENOMEM or EAGAIN, can pass, so gc gives no such hint and destroy --force fails on them too. A host.json that cannot be read counts as missing, so the group reads as out of view, as in another pid namespace: destroy --force fails with HOST_TIMEOUT, and its message names host.json and the error. When the read was refused (EACCES or EPERM), make that file readable and run destroy again. Until then, gc removes such a session once it has ended, but a session whose host died while it ran stays running, because its group reads alive. list, inspect, destroy and gc reconcile a session whose host died: they kill the program's group and record the session as failed (or exited or destroyed). A destroy that runs inside the session, such as one typed into the session's shell, runs again outside the session and prints its result once the session has ended. The session's terminal is closed by then, so redirect stdout to keep the result, for example agent-tty destroy "$AGENT_TTY_SESSION_ID" --json > destroy.json. Ctrl-C or SIGTERM to that destroy reaches the copy outside the session too, and the destroy then exits as the copy did, 130 or 143. A destroy request that already reached the host still ends the session. When destroy records destroyed itself, as with --force, for a session whose host died or for a session that has already ended, it first writes an empty internal file, destroyed, in the session folder. When the host records destroyed, as after a plain destroy of a live session, nobody writes that file. A reconcile that writes over the record at the same moment finds the file right after its own write and records destroyed again. A list, inspect or gc that reads the session between those two writes sees failed, and home list and the dashboard count a session that has ended and has that file as destroyed. If the reconciling process is killed between them, the next reconcile finds the file and records the session as destroyed. When that repair fails, for example on a full disk, list and inspect show the session as it reads. If destroy fails after it wrote the file, for example because its manifest write fails, the file stays, and the next reconcile records the session as destroyed, also when the session had already ended. Nothing deletes the file before gc removes the session folder, because a reconcile that read before the destroy can still write after it. A host that died after it recorded the session as ended leaves its socket. gc, and a destroy of a destroyed session, remove that socket when the manifest names no host pid or the host check below reads the host as ended. create waits for its new host for as long as the host process runs, because a host on a loaded machine can take many seconds to start. When the host process exits, or when it still does not answer after 30 s, create fails and tells the host to stop. It fails with HOST_UNREACHABLE (exit 6) when nothing listens on the socket, with HOST_TIMEOUT (exit 5) when the host accepts the connection but does not answer, and with RPC_ERROR (exit 9) when the host closed the connection of the last check before it answered. create also tells the host to stop when it fails for another reason after it wrote the session's first manifest, for example when it cannot read that manifest again. Each such error names the session in details.sessionId, and in text mode create prints Session <id> failed to start. before the error line. When that first manifest write fails, create removes the session folder. The host waits for the manifest only while create runs, so it then exits without starting the program. On Linux and macOS it sends the host SIGTERM, and the host stops the program the way destroy does and records the session as destroyed once the program's group has ended, also while its start-up is still slow. create exits before the host has ended the session, so a caller that checks at once can still read running or destroying. The host records destroyed right after the program exits on the SIGHUP, or about 2 s later, after the shutdown grace, when the program ignores SIGHUP. A host whose start-up write is still slow records it only after that write. Once the session reads destroyed, a destroy of it exits 4 with SESSION_ALREADY_DESTROYED. A host that has already died, or that gets the SIGTERM before it spawns the program, leaves the session to the reconcile above: the session reads running until the next reconcile records it failed, and no program runs. When the host cannot end the program's group, for example a process of another user, the session stays destroying with the host alive. That host never listened, so a plain destroy then fails with HOST_UNREACHABLE (exit 6), and only destroy --force can end the session. A host stopped with SIGSTOP, or with a blocked event loop, handles the SIGTERM only when it runs again. Until then the program runs and the session reads running. A plain destroy fails because the host does not answer, and only destroy --force ends the session sooner. On Windows, SIGTERM ends the host at once, so the host cannot stop the program itself. There create sends it only once the manifest names the program: the session then reads running with a dead host until the next reconcile records it failed and ends the program. Before the host's start-up write, create sends nothing. For that case, on every platform, create first writes an empty internal file, create-failed, in the session folder, and then reads the manifest. The host checks for that file before it starts the program, and returns then, so no program runs and the next reconcile records the session failed. It checks again right after its start-up write, and then ends the session the way a SIGTERM does on Linux: it stops the program and records destroyed. On Windows, a host whose start-up write lands before create reads the manifest is named by that read and gets the SIGTERM, and a host whose write lands after it finds the file. After its start-up write, the host also checks for the file every 100 ms while create runs, and once after create exits, so it ends the session also when create cannot read the manifest. When the file write fails, create writes it again every 100 ms, up to 50 times, while the host runs and the manifest names no program. When every write fails, for example on a full disk, a Windows host that starts late can still serve the session. This path is not tested on a real Windows host.

On Linux, destroy and reconcile never kill a process group that they cannot prove is the session's. When the host starts the program, it records the program's pid, the boot id and the program's autogroup id in an internal file, program.json. Linux gives each new session a new autogroup id, and child processes keep it. Before each kill, agent-tty checks that a live process in the group has that autogroup id and the program's pid as its session id. If no process passes the check, agent-tty kills nothing, for example when an unrelated program now uses the group id. If /proc refuses to show the boot id, or shows no autogroup id for a live process whose group and session id are the program's pid (it refuses the read, or the file is empty or malformed), agent-tty cannot run the check. It then kills nothing and counts the group as running. When the CLI cannot list /proc at all, as in a chroot or a container without it, it sees no process of the group, and nothing proves a kill. For a session without an autogroup record, the group then counts as running while kill(-pgid, 0) finds a process. A session with an autogroup record but without host.json counts as running whatever the probe finds. A plain destroy still ends a live session through its host, and destroy --force of a session with an autogroup record fails with HOST_TIMEOUT. End it with a plain destroy, or run destroy --force from a process that can read /proc. On Linux, a CLI that cannot prove that it shares the host's pid space cannot prove that the program's group ended either: it cannot read /proc/self/ns/pid (no /proc, or an empty one), its /proc belongs to another pid namespace (/proc/self names another pid than its own), or host.json names another pid namespace or another machine. For a session with an autogroup record and a host.json, such a CLI counts the group as running and sends it no signal. On the host's machine, a plain destroy of a live session still ends it through its host. From another machine the host's socket does not answer, so a plain destroy fails with HOST_UNREACHABLE: run it on the machine that started the session. A destroy of a session whose program exited fails with HOST_TIMEOUT and keeps the session exited, also when no process of the group is left. This trade-off keeps destroy from recording destroyed while a process that the program left runs where the CLI cannot see it. Run such a destroy from a process in the host's pid namespace that can read /proc. gc still removes the folder of an exited session, as it does on every system. If the proven group, or a group that agent-tty cannot check, does not end within the destroy wait (about 5 s), destroy fails with HOST_TIMEOUT and does not record the session as destroyed.

destroy --force and reconcile also check the host's pid from session.json before they trust it. On Linux the host records its pid, the boot id, its start time from /proc/self/stat, its pid and time namespaces and the machine id (/etc/machine-id) in an internal file, host.json. create writes it for the new host before session.json names the host, and the host writes it again when it starts. Reconcile counts the host as ended only when no process has its pid, when that process is a zombie, or when host.json names another boot or another start time than that process has. That covers a crashed host that stays a zombie under a container init that never reaps, and a later process that got the same pid. Reconcile then records the session as failed. destroy --force sends the host SIGKILL only when the boot id and the start time of the process match host.json, so it does not signal a process that got the pid before that check. The check and the kill are two system calls. A process that gets the pid between them still gets the signal, which needs the host to end, be reaped and its pid to go to a new process in that gap. When agent-tty cannot read /proc or host.json, it counts a host whose pid exists as running and does not kill it. With host.json but an unreadable /proc/self/ns/pid, it counts the host as running even when no process has its pid. When it runs in another pid namespace than the one that host.json names, or on another machine (another machine id), where the recorded pid can name an unrelated process, it counts the host as running and kills nothing, whatever that pid shows. A CLI whose /proc belongs to another pid namespace (/proc/self names another pid than its own) counts the host as running in the same way, so its destroy --force can fail with HOST_TIMEOUT (item 7 under Limits). Without a session token, it also kills no process group through the program's recorded pid there. In another time namespace than the host's, /proc shows start times with another boottime offset. There agent-tty counts the host as ended only when no process has its pid or that process is a zombie, and it does not kill the host. Without host.json, as for a session that an agent-tty version without it started, agent-tty cannot tell that it runs in another pid namespace. There a pid that no process has, or a zombie, reads as an ended host, and a kill without a session token goes to the group that the program's recorded pid names there. A session with an autogroup record but no host.json, which only unreleased development builds wrote, counts as out of the CLI's pid space: its group counts as running and gets no signal. A hostPid in session.json that differs from the pid in host.json never matches, so the session stays running and destroy --force kills nothing until session.json names the host again. That does not happen in normal use, because the host checks that session.json names its own pid. On macOS and the BSDs, destroy --force kills the host only when /bin/ps shows the pid running _host <session id>, the arguments that agent-tty starts the host with.

Limits:

  1. destroy does not kill a process that has left the program's process group. A process leaves the group when it calls setsid (for example setsid cmd or a daemon), or when an interactive shell with job control runs it as a job (for example cmd & typed into bash -i). Stop such a process yourself, or keep it in the program's group.
  2. Some sessions have no autogroup record: sessions on macOS and Windows, on Linux kernels without autogroups (/proc/self/autogroup is missing), on Linux when /proc refuses to show the autogroup, the boot id or the host's pid namespace at start-up, on Linux when the program exits before the host reads its session (a program that exits at once does that when the machine runs the host late), and sessions that an agent-tty version without program.json created. For these sessions, destroy --force, reconcile and the host's own kill at shutdown or at the idle time-out kill the process group (on Windows, the process) that session.json names, without the check, so such a kill can reach an unrelated process that took the group id after the program's group ended. destroy of a session whose program has already exited kills nothing. A program.json that agent-tty cannot read as a record, for example one that a newer version wrote, proves nothing: destroy and reconcile kill nothing for that session. Where /proc belongs to another pid namespace, as after unshare --pid --fork without --mount-proc, and the PID 1 of that namespace never reaps, a destroy of such a session fails with HOST_TIMEOUT after the program's group has ended: no scan of /proc shows the group's zombies, and kill(-pgid, 0) still finds them.
  3. The check and the kill are separate system calls, a few microseconds apart. If every process of the group exits and an unrelated process takes the group id in that time, the kill reaches the unrelated process. No system call kills a process group through a handle, so this window remains. A group that no scan shows, or whose process ends while agent-tty reads it, gets SIGSTOP before each check and SIGCONT when the checks fail. A group that shows a process that job control stopped (kill -STOP, or Ctrl-Z in a shell) gets no SIGSTOP, so that SIGCONT cannot resume the process. gc sends the same two signals to a group whose scans show only zombies while its id still names a process (see item 5). A CLI whose /proc belongs to another pid namespace sends no SIGSTOP. If the unrelated group is a job of an interactive shell, the shell reports the job as stopped, and after the SIGCONT the job runs in the background. If agent-tty itself dies between the two signals (SIGKILL, SIGINT, SIGTERM, SIGHUP, or the out-of-memory killer), an unrelated group that took the id stays stopped, and a later destroy does not resume it. Resume it with kill -CONT -- -<pgid>, where <pgid> is the Child PID that inspect shows.
  4. The host writes program.json a few milliseconds after it starts the program. We timed 150 create calls on Linux, from the return of node-pty's spawn to the renamed program.json, at 1-minute load averages of 38 to 83: the median was 2.8 ms, the 90th percentile 4.3 ms, and the longest 66 ms. If the host dies in that window and the program ignores the hang-up signal, no file names the program, and destroy cannot end it.
  5. gc kills nothing. It keeps the folder of a session that is not destroyed only while it proves that the program's group runs: the autogroup record in program.json proves that a live process of the group is the program's, or the group id still names a process that no scan of /proc shows, such as a fast fork chain. When the scans show only zombies of the group while its id still names a process, as for a fork chain under a PID 1 that never reaps, gc sends the group SIGSTOP, checks it again, and sends SIGCONT, up to twice in each of up to ten checks of the group, and it keeps the session only when such a check proves the group. A group that has ended gets all 20 frozen checks: with about 2,300 processes in /proc, they took about 3.5 s at a load of 90. Another gc or a destroy that runs at the same time can send SIGCONT before a frozen check. Only when that happens before all 20 of them can a live process of the chain escape every scan, and gc then removes the folder of a session whose group still runs. A kept session gets the reason "the session's program group still runs (destroy ends it)". When gc cannot check the group, it removes program.json with the folder, as it does without an autogroup record (see item 2). That happens in another pid namespace than the one that host.json names, for example after a container restart that keeps the agent-tty home, when host.json cannot be read, and when /proc refuses the boot id or the autogroup of a live process of the group, or hides the processes of other users. Otherwise such a session would stay forever, because no destroy can prove its group gone. A process that the program left can then no longer be stopped by agent-tty. Run destroy before gc when a program can leave processes behind.
  6. Two cases where agent-tty cannot run the check end differently from a session that stays running:
    • At the idle time-out, a host that cannot run the check kills nothing, but it still records the session as exited once the program has exited, while a process that the program left can still run. A later destroy then fails with HOST_TIMEOUT, and gc can remove the session folder.
    • When /proc hides the processes of other users (mounted with hidepid=2, hidepid=4, hidepid=invisible or hidepid=ptraceable), a group whose processes agent-tty cannot see counts as running. A destroy that cannot prove such a group gone fails with HOST_TIMEOUT and does not record the session as destroyed.
  7. Off Linux, reconcile counts the host as alive while any process has its pid. A crashed host that stays a zombie, or a process that got its pid, keeps the session running, and destroy --force kills nothing and fails with HOST_TIMEOUT. On Linux, the same happens when /proc is not mounted or refuses to show the host, and for a session that an agent-tty version without host.json started. On Windows, destroy --force never kills the host: it kills the program, and a host that hangs makes it fail with HOST_TIMEOUT. End such a host yourself with the Host PID that inspect shows (taskkill /pid <pid> /f), then run destroy --force again. When agent-tty cannot judge the host (another pid namespace or machine, a /proc of another pid namespace, or a refused read of /proc/self/ns/pid), destroy --force cannot see the host end either. It can fail with HOST_TIMEOUT although the host has recorded the session as exited, and a second destroy --force then records it as destroyed. When /proc is mounted but refuses to show the host and the program's processes (a security module that hides processes of the same user), destroy --force kills nothing and fails with HOST_TIMEOUT also for a healthy host.
  8. A Home that several machines share, for example over NFS, works only from the machine that started each session. From another machine, agent-tty counts the host as running and kills nothing, because the machine id differs. Machines without a machine id (an empty, missing or uninitialized /etc/machine-id, as in many container images), or with the same one (a cloned image), cannot tell each other apart. agent-tty takes a machine id only when it is 32 lowercase hex characters, so it also reads any other value, such as a UUID with dashes or uppercase hex, as no id. There the boot id differs, so the other machine counts a live host as ended, records the session as failed, and gc can then remove its folder. Remote sessions are out of scope (see RELEASE.md).
  9. A host that has ended reads as running forever when the machine id in its host.json differs from this machine's, for example after /etc/machine-id changed or in a cloned image that got a new one. list keeps the session running, destroy --force fails with HOST_TIMEOUT, and gc keeps the folder. First check, on the machine that started the session, that the host is gone: ps -p <pid> with the Host PID that inspect shows prints no _host process for that session. ps sees only the processes of its own machine, and when you delete host.json while the session still runs on another machine, gc can remove its folder. Then delete host.json from the session folder (<home>/sessions/<session id>/host.json). The next list records the session as failed, and gc can remove it. If another process now has that pid, the session stays running, and you can remove the session folder yourself.

Isolation

--home <path> stores manifests, event logs, and artifacts under an isolated agent-tty home. Pass the same --home value to every command in a workflow.

Each session's RPC socket lies outside the Home, at /tmp/agent-tty-<uid>/<8 hex>/<12 hex> (on Windows under /tmp/agent-tty/). inspect --json shows it as host.rpcSocketPath. create, the host and doctor create /tmp/agent-tty-<uid> with mode 0700. When that path is not a directory of your uid that no group or other user can use, for example a symlink or a folder of another user, create fails with STORAGE_WRITE_ERROR (exit 8) and names the path and its owner.

Known limits:

  1. Any user can create /tmp/agent-tty-<uid> before you do, because /tmp is shared. create, and every command that talks to a host that this version started, then fails as above until that user or an admin removes it. agent-tty never connects to a socket in such a folder. A host that an older version started stays reachable at the old path (limit 2).
  2. A host that an older version started listens at /tmp/agent-tty/<8 hex>/<12 hex>. This version still reaches it there when that socket and its folder belong to your uid, no other user can write the folder, and /tmp/agent-tty belongs to you or to root and either no other user can write it or it has the sticky bit. An older version created /tmp/agent-tty with your umask, so under umask 002 that folder has mode 0775. Your group can then write it, and this version does not reach an old host there until you run chmod g-w /tmp/agent-tty. destroy and gc remove that old socket. An older version cannot reach a host that this version started: destroy your sessions before you downgrade.
  3. The socket root belongs to the uid of the CLI. A CLI that runs as another uid, for example sudo agent-tty ... or docker exec -u root, looks under its own root and cannot reach a host that your uid started (HOST_UNREACHABLE). Run every command of a session as the uid that created it.

For tests and automation, prefer an absolute temp directory:

AGENT_HOME="$(mktemp -d)"
agent-tty --home "$AGENT_HOME" doctor --json

Avoid writing automated sessions into the default ~/.agent-tty unless you intentionally want shared local state.

Shell Environment

create spawns the shell with your inherited environment plus TERM (from --term), a default PROMPT_EOL_MARK= (empty) and a default COLORTERM=truecolor. The empty PROMPT_EOL_MARK suppresses the inverse-video % that zsh prints at the end of any output without a trailing newline; without it, agent-tty's hidden per-run completion marker leaves a stray % in snapshots, screenshots, and recordings. The variable is zsh-only and inert in other shells.

The COLORTERM=truecolor default says that the renderer draws 24-bit color. Neovim turns on 24-bit color when it sees that value. When COLORTERM is unset or empty, Neovim asks the terminal instead. agent-tty answers that XTGETTCAP query (Tc, RGB), so Neovim still detects 24-bit color. An inherited COLORTERM describes the terminal that launched agent-tty, not the session's renderer, so the default replaces it.

Like a terminal, agent-tty also answers DSR (ESC [ 5 n), the OSC 10 and OSC 11 color queries, and the DECRQSS SGR query. It writes each reply to the program as typed input. A program that asks, such as Neovim, reads the reply. A shell at its prompt reads the reply as typed text. So when a shell command prints a query, for example printf '\e[5n' or cat of a log that holds query bytes, the reply waits at the next prompt. The next run command then starts with the reply text and fails, for example with necho: command not found after a DSR reply. After an XTGETTCAP or DECRQSS reply, the next command does not run at all. In both cases run still reports completed: true. agent-tty cannot skip these replies: the shell at its prompt holds the terminal in the same mode as a program that waits for a reply. The terminal's ISIG flag and its foreground process group do not tell them apart either: in bash they are the same for a query that printf prints at the prompt and for printf '\e[5n'; read -rs -d n r, which waits for the reply. tmux leaks the DSR reply in the same way. To clear a reply, send ctrl+c before the next command. Do not use ctrl+u: after an XTGETTCAP or DECRQSS reply, bash waits in a history search, and ctrl+u clears only the search text. The next run then searches the history for its own command and can run an older command that contains it again. To print such a log, use cat -v, which shows the escape bytes as text.

The shell also receives two session variables, so scripts inside a session can detect agent-tty and find their own session:

Variable Value
AGENT_TTY_ACTIVE true.
AGENT_TTY_SESSION_ID The session's ID (a ULID, as printed by create --json).

Both replace inherited values, so a session created from inside another session reports its own ID, not the outer one. A PTY that agent-tty spawns without a session (the doctor spawn probe) gets neither variable: inherited values are removed, so it never reports an outer session's ID. Like the PROMPT_EOL_MARK and COLORTERM defaults, they are set at spawn time and are not stored in the session's recorded env.

Any --env value always wins, so you can opt back into the shell's native behavior or override a session variable per session:

# Restore zsh's styled default marker:
agent-tty create --env PROMPT_EOL_MARK='%B%S%#%s%b' -- /bin/zsh

# Hide 24-bit color support from Neovim (an empty COLORTERM would not,
# because Neovim then asks the terminal, and agent-tty answers yes):
agent-tty create --env COLORTERM=256color -- nvim

A lone '%' does not restore the marker (zsh treats it as a prompt escape that expands to nothing); use '%B%S%#%s%b' for the styled default or '%%' for a plain percent. The default is applied at spawn time and is not stored in the manifest, so it does not appear in inspect, list, or create --json output. If your ~/.zshrc assigns PROMPT_EOL_MARK it runs after the environment is imported and wins, so the marker can reappear — remove that line or set the value you want via --env.

Exit Codes

Every command exits with a stable code, so scripts can branch without parsing output. The --json error envelope carries the precise error.code when a command fails before producing a result. A timed-out standalone wait emits an error envelope (ok: false, error.code: "WAIT_TIMEOUT") that still carries the wait result. A fail-fast batch with a failed step does the same with the step's error.code and its per-step result. A --keep-going batch with failures keeps a success envelope and exits 1.

Exit code Meaning
0 Success.
1 Internal or unclassified error.
2 Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input).
3 Session not found.
4 Session is not running or already destroyed.
5 Session host timed out.
6 Session host unreachable.
7 Export failed.
8 Storage read/write or manifest validation error.
9 Protocol or RPC error.
10 Replay failed, also when a ghostty-web page crashes in a screenshot, snapshot or wait replay (WebM export: 7).
11 A standalone wait timed out, or a wait step inside a fail-fast batch timed out (WAIT_TIMEOUT; see wait).
12 A requested runtime capability is unavailable (CAPABILITY_UNAVAILABLE).
130 Interrupted by SIGINT: a batch that flushed its partial result, or a destroy whose copy in a new session SIGINT ended.
143 Ended by SIGTERM: a batch that flushed its partial result, or a destroy whose copy in a new session SIGTERM ended.
128+n Ended by signal n: a destroy whose copy in a new session that signal ended, such as 129 (SIGHUP) or 137 (SIGKILL).

A fail-fast batch exits with the failed step's code (for example 11 for a wait timeout); --keep-going exits 1 if any step failed.

A live session host answers at most 67,108,864 characters per command, counted as JavaScript string length (UTF-16 code units, not bytes: non-ASCII text takes more bytes on the wire). A larger response, such as snapshot --include-cells of a screen with more than about 1.2 million fully styled cells, fails with PROTOCOL_ERROR (exit 9, retryable: false) and details.maxResponseChars.

agent-tty help and agent-tty help <command> exit 0. Without a command, the CLI prints the help on stderr and exits 2.

An unreadable Home config.json gives STORAGE_READ_ERROR (exit 8). Invalid JSON or an invalid field in it gives INVALID_INPUT (exit 2). Both errors carry details.configPath.

An unexpected failure prints an INTERNAL_ERROR envelope and exits 1 once the output is flushed, even when the failed code left a handle open. Add --log-level debug to also print its stack on stderr. A failure while the CLI reads the global options and the config file prints no stack, because the log level is not known yet. With --json, a usage error (unknown command or flag, missing argument or required option) prints an INVALID_INPUT envelope under the command name agent-tty and exits 2. The usage text still goes to stderr.

Anti-Patterns

  • Do not reach for tmux, screen, or ad hoc PTY wrappers first when agent-tty can provide an isolated, inspectable session.
  • Do not rely on blind sleep calls when wait --text, wait --idle-ms, or wait --screen-stable-ms can observe readiness.
  • Do not scrape human-readable output when --json is available.
  • Do not use external screenshot tools as the primary proof path when agent-tty screenshot and agent-tty record export can produce artifacts tied to the session timeline.
  • Do not leave sessions running after the task ends; destroy them explicitly.