Skip to content

.tty / .ttyz Format Reference

.tty / .ttyz is one format with two encodings — the way a container image is one layout whether it lives as a directory of blobs or a docker save tarball:

  • .tty — the recording bundle: a plain directory (mysession.tty/) holding a manifest and the recording's members as ordinary files. This is the live encoding — a session under capture appends to an open tail segment inside the bundle — and the source-of-truth encoding at rest. Uncompressed, full stop: recovery and tail-follow stay trivial on plain appended bytes.
  • .ttyz — the sealed encoding: the same members packed into a single ZIP container (ZIP64-capable). Portable, immutable, seekable. Compression happens only at seal — a ZIP's central directory is written at close, which is why the sealed form seals.

One reader, both encodings, encoding-blind consumers. readRecording accepts a live .tty bundle directory or a sealed .ttyz file — nothing else — and produces an identical Recording from either. No downstream consumer can tell which encoding it was handed; packRecording / unpackRecording are the only places encoding exists as a concept.

.tty/.ttyz supersedes the retired .rec container. .rec was deleted, not aliased: no .rec artifact was ever shipped, so there is nothing to migrate.

The bundle layout

mysession.tty/                  ← the bundle: live, or sealed-at-rest
  manifest.json                 ← the member index (see below)
  commands.jsonl                ← a commands member  (optional)
  io.jsonl                      ← an io member, jsonl encoding (optional)
  io/000…000-000…123.hts        ← an io member, hts1 encoding (optional, many)
  io/current                    ← the open tail of a LIVE bundle (at most one)
  facts/000…000-000…456.jsonl   ← a facts member (optional, many)
  habcp.log                     ← a habcp member: the habitat journal's live tail (optional)
  habcp-000…001-000…456.log     ← habcp members: sealed journal segments (optional, many)
  frames/index.jsonl            ← a frames member (optional)
  frames/00001.png              ← frame rasters, beside their index
  checkpoints/000…123.json      ← checkpoint members (optional, many)

Member paths are declarative: the manifest names each member's path, type, and encoding, and the reader dispatches on the manifest — never on file-name conventions. A frames member may live at frames/index.jsonl or at the bundle root as index.jsonl; both are valid bundles because the manifest says where to look. (This is what makes an upgraded legacy frame-trace directory — its index.jsonl and PNGs byte-identical, plus one added manifest.json — a valid .tty bundle with zero artifact churn.)

manifest.json

jsonc
{
  "ttyVersion": 1, // format version of this manifest schema
  "recordingVersion": 1, // Recording model version
  "cols": 80, // terminal columns at recording start
  "rows": 24, // terminal rows at recording start
  "durationMicros": 5200000, // total duration, integer µs
  "reproducible": true, // frames projection regenerable from io?
  "originWallMs": 1754620000000, // OPTIONAL: wall-clock ms of µs-origin 0
  "sourceResolution": "us", // OPTIONAL, write-new (D10): declared source clock resolution
  "fingerprint": {
    /* RendererFingerprint */
  }, // OPTIONAL, frames-bearing only

  // Sealed members. Every listed member is complete and immutable.
  "members": [
    { "path": "io.jsonl", "type": "io", "encoding": "jsonl" },
    {
      "path": "io/00000000000000000000-00000000000000012345.hts",
      "type": "io",
      "encoding": "hts1",
      "micros": [0, 3100000],
    }, // OPTIONAL: time range covered, µs
    { "path": "facts/00000000000000000000-00000000000000000456.jsonl", "type": "facts", "encoding": "jsonl" },
    { "path": "commands.jsonl", "type": "commands", "encoding": "jsonl" },
    { "path": "frames/index.jsonl", "type": "frames", "encoding": "trace-index" },
    { "path": "checkpoints/00000000000000012345.json", "type": "checkpoint", "encoding": "json" },
    { "path": "recording.jsonl", "type": "recording", "encoding": "jsonl" }, // write-new (D10): the io-Recording writer's alternative to io.jsonl
  ],

  // The ONE open segment of a LIVE bundle. Absent in a sealed bundle —
  // sealing rotates the tail into `members`. Tail-follow readers poll this
  // file; its bytes are append-only between manifest rewrites.
  "tail": { "path": "io/current", "type": "io", "encoding": "hts1" },
}
  • The manifest is written atomically (write-temp + rename) and rewritten only on member rotation or seal — never per append. Appends go to the tail file; the manifest names it once.
  • A bundle with a tail is live; a bundle without one is at rest. Sealing = rotate the tail into members, drop tail, then (for .ttyz) pack.
  • micros ranges are on the recording's monotonic µs clock and are advisory acceleration for windowed reads; the member bytes are authoritative.
  • originWallMs anchors the µs origin to a wall clock. It is required when any member's encoding stamps wall-clock time (hts1); the reader rebases those stamps onto the µs clock against this origin. When absent, the first event of the earliest hts1 member defines µs 0. Disclosure: a consumer that reports times states which clock they came from.

Member types

typecarries→ Recording
iodirection-tagged raw byte eventsio track
commandstimed intent (keys, resize, sleeps)commands track
framesrendered frame index + rastersframes projection
factssession fact events (annotation source)not loaded — exposed to annotation/windowed readers
habcpthe habitat's control-plane journal (NDJSON rows; tail + sealed segments)not loaded — opaque; hab-side readers own row semantics
checkpointserialized terminal state at an offsetnot loaded — seek keyframes for players and reattach
recordingone TtyEventRow per line (D10, write-new — the io-shaped writer's door)output/input → io track; control/mark/exit have no track here — tallied, see below

facts and checkpoint members are first-class bundle members that the Recording model deliberately does not carry: facts are the annotation source (verdicts and extracted facts are sidecars, never mutations of the artifact), and checkpoints are derived seek keyframes (streams are stored; snapshots are derived). readBundle surfaces them; readRecording returns the Recording and tallies what it did not load — never a silent skip.

habcp members are DELIBERATELY OPAQUE at this layer: this format knows the kind string and that the content is one JSON object per line, and nothing else — no habcp row schema crosses into this spec or its readers (the same layering law that keeps this format vterm-free). The session fact lane and every row semantic are defined hab-side; see the habitat's own habcp.md specification. A habcp member naming the journal's live tail (habcp.log) is the one declared mutable member — consumers read it to EOF.

recording is the write-new (D10) alternative to io: the io-shaped writer's own member, one TtyEventRow per line covering the full Event vocabulary, not just bytes. The Trace door reads it too, through ioEventFromEvent: output/input land in the io track exactly as an io member's rows would; control/mark/exit rows have no Trace track to land in, so readBundle tallies them under TtySkipTally.recordingNonIo rather than dropping them silently. See Writing an io Recording below.

Loading as an io Recording

The table above is the Trace-shaped door (readBundle/readRecording, recording/recording.ts's commands + io + frames tracks). loadBundle / loadRecording (recording/native/tty-format.ts) load the same manifest into the io-shaped Recording instead — a header plus Event[] (@termless/core/io) — minted under a new name in unterm phase A3 slice 5; readRecording itself takes over this shape at phase A4a, when the Trace alias is deleted (the two names coincide from that point on).

member→ io Event
io, hts1 encoding — output/inputone Event per frame, the raw payload bytes as data — no UTF-8 decode
io, hts1 encoding — resizea captured, non-derived control/resize Event — the source recorded it, the same frame the Trace door reads into its commands track
io, hts1 encoding — lifecycle/truncationno Event form (same as the Trace door) — tallied
io, jsonl encodingeach row through eventFromIoEvent
recording, jsonl encodingeach row through eventFromEventRow (D10) — the inverse of eventRowFromEvent; output/input's base64 data decodes back to raw bytes
commands, frames, facts, habcpnot loaded — tallied by path, the same treatment readBundle gives them today
checkpointloaded: a derived mark per record, plus a derived control/resize on a geometry change — see Checkpoint member below

Checkpoint members are loaded (unterm A3 slice 5b) — see Checkpoint member below for the record contract (D7) and the mark / derived-resize rules (D8, D9; @cto ruling Q1: reconstruction is marked, capture is not). recording members are loaded natively too (D10) — see Writing an io Recording below for the row contract; unlike a checkpoint's derived events, a recording member's rows carry derived (or not) exactly as its writer set it, since they are Events, not a reconstruction.

The derived rule: output/input events never carry derived — they are always a byte-for-byte capture, hts1 or jsonl. A captured hts1 resize frame also carries no derived — it happened, exactly as recorded, same as output/input. derived: true is reserved for events this door reconstructs from other evidence rather than reads directly off the wire: a checkpoint record's mark, and the control/resize D9 sometimes synthesizes beside it — see Checkpoint member below.

The sourceResolution rule (unterm A3 ruling: declared, never assumed): the manifest's own sourceResolution field (D10) wins when a writer set one — an io Recording's writeRecording overload states its header's value honestly. Only a manifest that doesn't say falls back to inferring from encodings, unchanged from before this field existed: "ms" when any loaded io member is hts1, "us" otherwise — decided by the io members alone, never by a checkpoint's derived mark/control Events. A bundle with no events at all throws; see below.

What is talliedTtyLoadSkipped: the commands / frames / facts / habcp member paths not loaded at all, plus lifecycle / truncation counts for hts1 frames with no Event form. Never a silent drop. checkpoint member paths are no longer part of this tally — they are loaded (see Checkpoint member below).

A bundle that yields no events at all (frames-only, commands-only, or a checkpoint member with zero records) throws, naming the members it has — the io shape has nothing to say about it.

Checkpoint member

The normative record contract (D7), and the mark / derived-resize rules this door applies to it (D8, D9; unterm A3 slice 5b). The only real producer is hab-tty: one checkpoint member whose JSON content is a single record or a JSON array of records.

typescript
interface TtyCheckpointRecord {
  at: number // wall-ms, rebased exactly like an hts1 frame
  reason?: string
  throughOffset?: number // the journal offset this checkpoint covers through (inclusive)
  size?: { cols: number; rows: number } // write-new: the hab producer adds this in a later slice
  snapshot?: unknown // a vterm.js Snapshot — raw v1, or a v2 envelope `{ format, data }`
}

snapshot is never decoded — this format imports no engine (see Member encodings below) — so a record's geometry (D7) is resolved without it wherever possible:

  • size, when present, wins outright.
  • Else, when snapshot is a raw v1 snapshot — a plain object { version: 1, cols, rows, … }, not a { format, data } envelope — its own top-level cols/rows: two fields vterm.js documents as that shape's persisted wire form, read as JSON and never decoded.
  • Else the geometry is unknown, and no derived resize can be computed from that record.

Marks (D8). Every record becomes one derived: true mark Event, named checkpoint:<reason> when the record has one, else checkpoint:<n> — its 0-based index within its member's JSON document. The mark is anchored onto the timeline by throughOffset when the record declares one and at least one loaded io event carries an hts1 frame offset at all: onto the last such event whose offset is ≤ throughOffset, taking that event's own at. In every other case — no throughOffset, no io event has an offset to compare against, or no loaded event's offset qualifies — the mark falls back to the record's own rebased at.

Derived resizes (D9). A derived: true control/resize Event immediately precedes a record's mark whenever that record's geometry is known, differs from the geometry last known, and no captured resize happened since. "The geometry last known" starts at the manifest's cols/rows (when both are > 0) and is updated, in timeline order, by every capturedresize Event and by every geometry-bearing record — so a capture always wins over a reconstruction: one truth per resize, a reconstruction never doubles a capture that already covers the same change.

Records across every checkpoint member in the bundle are processed in one global timeline order — by throughOffset when present, else by rebased at — since the D9 geometry-tracking state spans all of them, not just one member's own records.

Writing an io Recording

writeRecording gains a second overload (D10, unterm A3 slice 5b part ii) alongside the existing Trace-shaped one. Which one runs is decided by the shape of recording, not by an option:

typescript
writeRecording("out.tty", ioRecording) // IoRecording (header + Event[]) -> one `recording` member
writeRecording("out.tty", traceRecording) // Trace (commands/io/frames) -> unchanged from before this slice
  • recording.jsonl (type: "recording", jsonl encoding) — one TtyEventRow per line: every Event's own fields, verbatim, via eventRowFromEvent, except output/input whose raw bytes become base64 text so the row stays plain JSON. control, mark, and exit carry no byte payload at all, so they ride unchanged — TtyEventRow is Event itself for those three.
  • Manifest fieldscols/rows from the header's size; durationMicros from the header's duration, else the last event's at (0 for an empty recording); reproducible: true unconditionally (an io Recording never writes a frames member, so there is no projection that could diverge from it). Two fields round-trip the header honestly, write-new as of this slice: originWallMs from header.timestamp when present, sourceResolution from header.sourceResolution when present. Both are read back by loadBundle — see Loading as an io Recording above for the sourceResolution priority rule.
  • The Trace overload is unchanged — same members, same manifest fields, the same bytes it always wrote. The two overloads share one exported name but not one options contract: WriteRecordingOptions.pngSourceDir (frame rasters to copy) only ever means something for the Trace overload — an io Recording has no frames projection to copy rasters for, so that overload's signature does not accept the option at all, rather than silently ignoring it if passed.
  • Reading it backloadBundle reads a recording member natively, each row through eventFromEventRow (the inverse of eventRowFromEvent). readBundle (the deprecated Trace door) reads the same member through ioEventFromEvent: output/input land in its io track; control/mark/ exit rows have no Trace track to land in, so they are tallied under TtySkipTally.recordingNonIo — never a silent drop.

Member encodings

encodingmember typeswire shape
jsonlio, commands, facts, habcp, recordingone JSON object per line; io rows are IoEvent (µs at), commands rows are Command, recording rows are TtyEventRow (D10); habcp rows are opaque here
hts1iothe binary journal framing (below); wall-ms at, rebased on read
trace-indexframesthe frozen TraceFrame rows of index.jsonl, rasters beside it
jsoncheckpointone JSON document — a record or an array of records, see Checkpoint member above
zstd-seekablereserveddeclared for large sealed io members; not yet implemented — a reader encountering it must fail loud, not skip

The hts1 io encoding

The binary journal framing written by always-on session recorders. Each frame:

"HTS1"            4 bytes  magic
version           1 byte   (= 1)
headerLen         u32 BE
payloadLen        u32 BE
header            headerLen bytes of JSON
payload           payloadLen bytes, raw

Header: { kind, offset, at, owner?, size?, state?, detail?, retainedFromOffset? } where kindoutput | input | resize | lifecycle | truncation and at is wall-clock milliseconds. Caps: header ≤ 64 KiB, payload ≤ 16 MiB. A truncated trailing frame is a tear — the reader stops cleanly at the last complete frame. Bad magic or version is corruption — the reader throws. These two error paths never collapse into one.

Mapping into the Recording (identical to the retired bridge, by construction):

  • output → io event, direction: "out" (payload decoded UTF-8)
  • input → io event, direction: "in" (owner is dropped — the io track is direction-tagged bytes, not peer-attributed; it survives in the source member)
  • resize → a commands resize (a size change is intent; io has no size concept)
  • lifecycle, truncation → no track; tallied and surfaced, never silently dropped
  • at wall-ms values rebase to the µs clock against originWallMs (or the first event when absent); a stamp before the origin throws — a wall clock running backwards is corruption, not a thing to clamp

The sealed encoding — .ttyz

A standard ZIP holding exactly the bundle's files, paths preserved:

  • ZIP64-capable: entries or offsets beyond 32-bit limits write ZIP64 records; small archives stay plain ZIP readable by any tool.
  • Per-member compression: text members DEFLATE; already-compressed rasters (.png) are STORED.
  • Reproducible: fixed timestamps, sorted entry order — the same bundle always seals to the same bytes.
  • Sealing a live bundle first rotates the open tail into a sealed member; a .ttyz never contains a tail.

Reading

typescript
import { readRecording, readBundle } from "@termless/core"

const rec = readRecording("session.tty") // or "session.ttyz" — identical result
const { recording, manifest, skipped } = readBundle("session.tty")
  • readRecording(path) — the encoding-blind door. Returns the Recording.
  • readBundle(path) — the manifest-aware door for annotation, windowed, and player consumers: the Recording, the parsed manifest (facts/checkpoint members included), and the skip tally.
  • Both accept both encodings; both produce identical Recordings from a bundle and its own seal.

Writing

typescript
import { writeRecording, packRecording, unpackRecording } from "@termless/core"

writeRecording("out.ttyz", recording) // Recording → sealed .ttyz
writeRecording("out.tty", recording) // Recording → at-rest .tty bundle
packRecording("live.tty", "out.ttyz") // seal a bundle (rotates the tail)
unpackRecording("in.ttyz", "work.tty") // unseal to a bundle

Live capture (a writer appending to a bundle's tail) is the session runtime's job — termless defines the layout and reads it; the always-on writer conforms to it.

Session access — the live-read contract (spec; implementation follows)

This section names the vocabulary a live session reader conforms to, unifying live and archived reads over one contract. It is the contract for readers, written ahead of the reader itself — termless/session (reserved, unbuilt) is where it lands. Nothing above this section changes; this adds a read-time vocabulary layered on top of the bundle/member model already specified.

Frame element — the unit of a session's ordered timeline:

typescript
{ t_us: number, dir: "in" | "out" | "err" | "resize", data: Uint8Array }

This is termless's IoEvent widened for live reads, not a second Frame type: t_us is the same integer-µs clock as Recording's Micros, and data is the same raw bytes. It widens today's on-disk IoEvent.direction ("in" | "out" only, per Member encodings above) by two tags: "err" splits stderr from stdout where a session's transport distinguishes them, and "resize" folds a size change into the same timeline element instead of a separate commands row. A fold from this element back to the Terminal read contract (see TestTerminal for the contract's live-session wrapper) is how selectors/matchers query a live seat and an archive with the same code.

Cursor (opaque) — a position marker a live-read call returns and accepts back to resume from exactly that point. Unrelated to, and not to be confused with, the terminal-caret Cursor type ({ col, row, visible, style }) documented elsewhere in this package — same word, two different concepts, one about a read position in a byte stream and the other about where the emulator draws the caret. Callers must not construct or inspect a live-read Cursor; it round-trips opaque.

SessionClose — the tombstone element a session's timeline ends with, written exactly once, the moment the session ends. It is the only way a reader learns "this stream will not grow further" — there is no other end-of-session signal.

follow governs what a read does at the end of what is currently written:

  • follow: false halts there — at the tombstone if one is present, otherwise at the current end of the written stream. A snapshot read, not a wait.
  • follow: true parks past the current end instead of returning: it does not deliver EOF on a live (not-yet-closed) stream. It resumes the instant more is appended, and only actually ends when the SessionClose tombstone arrives.

Append-only segments — the same append-only-tail discipline as the bundle's tail member above (manifest.json), generalized to the live-read API: a segment is only ever grown, never rewritten in place, so a reader mid-read never observes bytes it already read change under it.

Rotation hands a new handle — when a segment rotates (the same event as "Sealing = rotate the tail into members" above, or an equivalent live rotation), a reader following across the rotation is handed a new handle rather than being left to continue silently on the old one. A reader that does not ask for the new handle stops at the rotation boundary; it does not silently keep reading stale state.

Refusals along this surface are permission-shaped (EACCES-like), not exceptions of convenience — a reader denied access is told so the same way a filesystem would, not with a bespoke error shape per caller.