Venom
RFC-0014venom-spec

Bounded history-range capture from authoritative panes

Field Value
Status Accepted
Accepted 2026-11-07, explicitly approved by the maintainer
Authors commixon (commixon)
Created 2026-11-07
Linear COM-249; implementation COM-250
Supersedes None
Superseded by None

Summary

Add one read-only venomx verb, pane.capture_rows, that returns a range of physical rows (scrollback and screen) from a pane’s authoritative terminal as plain UTF-8 text. Rows are addressed by the (epoch, absolute row) pairs of RFC-0006 §5.3. Every reply is bounded by row, byte, cell and frame limits. A range that does not fit one reply is returned in pages through an explicit, stateless next row; nothing is ever silently truncated. Evicted rows, rows that don’t exist yet and stale epochs fail with defined errors. The verb needs read on the pane under RFC-0005, has no side effects, and never touches a client’s viewport. RFC-0007 §5 pane.capture is unchanged. COM-250 implements it in venomx.

Motivation

RFC-0007 §5 gives agents pane.capture: the active visible screen only. It says that history-range capture is deferred. Agents that drive builds, test runs and REPLs in panes need the output that has scrolled off:

  • venom watches agent panes and wants the last few hundred rows of a failed make test, not 24.
  • Agents that poll a pane need to read “everything since I last looked” without re-reading the whole scrollback each time.
  • Scripts want a full transcript of a pane’s retained history.

The only way to get history today is an RFC-0006 emulating attach, which means a binary-frame client, a ghostty decoder of the same pin and a read attach that RFC-0006’s skill guidance tells agents not to use. A plain-text verb gives the same rows to CLI and MCP callers.

Without a defined contract, each caller invents its own: wrapping commands in tee, raising the screen size, or attaching. All of them lose rows silently when output is fast, which is exactly when the output matters. COM-250 is blocked on this RFC.

Design

1. Examples

A headless daemon is running (venomx serve). The human opts into owner authority as in RFC-0007 §1. IDs assume a fresh daemon; the pane is 20×4 so the examples stay short.

VENOMX_TOKEN=@owner venomx session create --json
VENOMX_TOKEN=@owner venomx window create --session s0 --cols 20 --rows 4 \
  --argv '["/bin/sh","-c","seq 1 1000; exec sleep 1000"]' --json
venomx pane capture-rows --pane p0 --tail 3 --json

seq prints 1 … 1000, one per row, so N is on absolute row N − 1 and the cursor sits on the empty row 1000. The last command returns the three newest rows:

{"pane":"p0","screen":"primary","epoch":1,"offset":4893,"cols":20,"rows":4,"bounds":{"oldest":0,"active":997,"end":1001},"start":998,"end":1001,"done":true,"lines":["999                 ","1000                ","                    "]}

Every line is one physical row of exactly 20 columns, trailing spaces included, with no LF, styles or escape sequences. offset is the pane byte offset the read is as of (4893 bytes: 2893 digits and 1000 CRLF pairs).

Reading a fixed range page by page. The first call pins the range; each later call passes the same epoch and end and starts at next:

$ venomx pane capture-rows --pane p0 --epoch 1 --start 0 --end 1001 --max-rows 2 --json
{"pane":"p0","screen":"primary","epoch":1,"offset":4893,"cols":20,"rows":4,"bounds":{"oldest":0,"active":997,"end":1001},"start":0,"end":1001,"next":2,"done":false,"lines":["1                   ","2                   "]}
$ venomx pane capture-rows --pane p0 --epoch 1 --start 2 --end 1001 --max-rows 2 --json
{"pane":"p0","screen":"primary","epoch":1,"offset":4893,"cols":20,"rows":4,"bounds":{"oldest":0,"active":997,"end":1001},"start":2,"end":1001,"next":4,"done":false,"lines":["3                   ","4                   "]}

A complete transcript as a shell loop (the default selection is all retained rows of the current epoch):

page=$(venomx pane capture-rows --pane p0 --json) || exit
epoch=$(jq -r .epoch <<<"$page"); end=$(jq -r .end <<<"$page")
while :; do
  jq -r '.lines[]' <<<"$page"
  [ "$(jq -r .done <<<"$page")" = true ] && break
  page=$(venomx pane capture-rows --pane p0 --epoch "$epoch" \
    --start "$(jq -r .next <<<"$page")" --end "$end" --json) || exit
done

Errors:

$ venomx pane capture-rows --pane p0 --epoch 0 --start 0 --end 10 --json
{"error":{"code":"conflict.epoch","message":"row epoch 0 is no longer current","data":{"screen":"primary","epoch":1,"oldest":0,"end":1001}}}
$ echo $?
5
$ venomx pane capture-rows --pane p0 --epoch 1 --start 990 --end 2000 --json
{"error":{"code":"not_found.rows","message":"rows 1001 to 1999 do not exist yet","data":{"reason":"future","screen":"primary","epoch":1,"oldest":0,"end":1001}}}
$ echo $?
3
$ venomx pane capture-rows --pane p0 --tail 3 --start 0 --json
{"error":{"code":"invalid_params.rows","message":"tail cannot be combined with epoch, start or end","data":{"param":"tail","reason":"exclusive"}}}
$ echo $?
2

The same verb on the wire (RFC-0002 NDJSON, after hello):

{"id":4,"verb":"pane.capture_rows","params":{"pane":"p0","tail":1}}
{"id":4,"ok":{"pane":"p0","screen":"primary","epoch":1,"offset":4893,"cols":20,"rows":4,"bounds":{"oldest":0,"active":997,"end":1001},"start":1000,"end":1001,"done":true,"lines":["                    "]}}

2. Schema

One new verb in the venomx RFC-0004 format-1 manifest. Wire name pane.capture_rows, CLI venomx pane capture-rows, MCP tool pane_dcapture__rows. It is not streaming and declares the read right on pane:<pane>.

Parameters:

.{
    .{ .name = "pane", .summary = "Pane to read", .type = .{ .kind = .string }, .required = true },
    .{ .name = "epoch", .summary = "Row epoch that start and end belong to; required with start or end", .type = .{ .kind = .integer, .min = 0, .max = 4294967295 } },
    .{ .name = "start", .summary = "First absolute row (inclusive); default: oldest retained row", .type = .{ .kind = .integer, .min = 0, .max = 9007199254740991 } },
    .{ .name = "end", .summary = "Absolute row after the last one (exclusive); default: current end", .type = .{ .kind = .integer, .min = 0, .max = 9007199254740991 } },
    .{ .name = "tail", .summary = "Select at most this many newest rows of the current epoch; excludes epoch, start and end", .type = .{ .kind = .integer, .min = 1, .max = 9007199254740991 } },
    .{ .name = "max_rows", .summary = "Most rows in this reply", .type = .{ .kind = .integer, .min = 1, .max = 10000 }, .default = 1000 },
    .{ .name = "max_bytes", .summary = "Most UTF-8 bytes of line text in this reply", .type = .{ .kind = .integer, .min = 1, .max = 16777216 }, .default = 262144 },
}

Result fields, all required except next:

Field Type Meaning
pane string The pane read
screen "primary" | "alternate" The screen that was active at the read (§3)
epoch integer Row epoch of every row number in this result
offset integer Pane byte offset the read is as of: it reflects exactly PTY bytes [0, offset) (RFC-0006 §5.1)
cols, rows integer Pane size at offset
bounds object {oldest, active, end} Retained rows at the read: [oldest, end), with the screen at [active, end)
start integer Absolute row of lines[0] (or where it would be, if lines is empty)
end integer End of the selection (exclusive), pinned by the first page
next integer Present only when done is false: the start of the next page, equal to start + len(lines)
done boolean True when this page reaches end
lines array of strings Rows [start, start + len(lines)), oldest first, in §5’s text form

The manifest MUST give these as complete object descriptors (the closed screen string uses choices). Format 1 has no unions or mutual exclusion, so the handler enforces §3’s rules with invalid_params.rows. No shared generator work is required.

3. Rows, epochs and selection

Row addresses are RFC-0006 §5.3’s: an absolute row is the index of a physical row on the pane’s active screen, stable while older rows are pruned and newer ones are written, and meaningful only within its epoch. The epoch changes on any resize that changes the dimensions, on clearing scrollback (CSI 3 J), on full reset (RIS), on every switch between the primary and alternate screens, and when venom-term loses its row anchor. The bounds are venom-term’s Vt.rows(): oldest is the oldest retained row, end the row after the newest, active = end − rows.

The verb reads the screen that is active when it runs. While a full-screen program holds the alternate screen, screen is "alternate", the epoch is the alternate screen’s, and bounds.oldest = bounds.active: there is no history to read. Primary-screen addresses taken earlier are stale, and stay stale after the program exits, because both switches start a new epoch. Reading the primary screen’s history during alternate-screen use is deferred (Open question 1, COM-268).

Within one epoch the daemon MUST guarantee:

  1. end never decreases and oldest never decreases.
  2. A row below active (history) never changes text. Only rows in [active, end) can change.

Both follow from how a terminal works: programs can only write to the screen, and every operation that rewrites or removes history (reflow, CSI 3 J, RIS, anchor loss) starts a new epoch. If venom-term ever reports a change that would break either rule, the daemon MUST treat it as an epoch change.

Selection. Exactly one of these forms, checked in this order:

Params given Selection [S, E)
tail = n, with none of epoch, start, end [max(oldest, end − n), end) of the current epoch
none of tail, epoch, start, end [oldest, end) of the current epoch
epoch, optionally start and end [start ?? oldest, end ?? end) in epoch, which MUST be current
  • tail together with any of epoch, start, end fails invalid_params.rows (exclusive). start or end without epoch fails invalid_params.rows (epoch_required). end < start fails invalid_params.rows (order).
  • A given epoch that is not the current one fails conflict.epoch.
  • S < oldest fails not_found.rows (evicted). E > end fails not_found.rows (future). The daemon MUST NOT clamp either side: clamping would silently drop rows the caller asked for.
  • tail is “at most n”: with fewer rows retained, the selection is shorter, and the result’s start and bounds.oldest show it. This is the meaning of the request, not truncation of a selected range.
  • S = E is valid and returns lines: [], done: true.

4. Read boundary, output and resize

The read runs on the pane’s own thread between two Vt.feed calls, as RFC-0006 §5.1 requires for snapshots. One page is therefore a consistent view of the terminal after exactly the PTY bytes [0, offset): output arriving during the call is applied after it. Bounds, size, epoch, screen and lines in one result all describe that same instant.

A resize is applied at a pane offset N (RFC-0006 §8). A read sees either the size and epoch before N or the ones after it, never a mix, and cols, rows and epoch say which. A pinned selection in the old epoch then fails conflict.epoch; tail and default selections read the new epoch.

Across pages. The daemon keeps no state between calls: each page is a new read at its own offset. The guarantees of §3 make paging safe:

  • History rows returned by one page are still valid in later pages of the same epoch, so pages never overlap, skip or reorder rows.
  • Rows that were in [active, end) when their page was read may change afterwards. A caller that needs the whole selection at one instant MUST fit it in one page (raise max_rows and max_bytes, §6).
  • Fast output can prune rows between pages. The next call then fails not_found.rows (evicted) with the current oldest, so the caller learns exactly which rows, [next, oldest), are gone. It may continue from oldest explicitly.
  • Any epoch change between pages fails the next call with conflict.epoch.

Rows written after the first page are outside the pinned end and are not returned; read them with a new call starting at the old end.

5. Text

Each element of lines is one physical row, rendered exactly as RFC-0007 §5 pane.capture renders a row:

  • The cell text of every column, left to right: the full grapheme cluster of each cell, a space for each empty cell, trailing spaces kept.
  • Width-zero continuation cells (the right half of a wide character) are omitted, so a row’s display width is always cols.
  • No LF, CR or other C0/C1 controls, no DEL, no escape sequences, styles, hyperlinks or images. The string is valid UTF-8.

A wrapped logical line is several rows; no row records whether it wrapped. Logical-line (unwrapped) addressing that survives reflow is COM-233 and is not part of this RFC.

For the same instant, lines for [active, end) on the active screen are byte-identical to pane.capture’s text split on LF. The JSON encoder escapes ", \ and nothing else beyond what JSON requires; it MUST NOT escape non-ASCII characters (so byte counts in §6 match the wire closely).

6. Limits and pagination

A page holds rows [start, start + k) for the largest k such that all of these hold:

Limit Value
Rows k ≤ max_rows (default 1000, at most 10000)
Line bytes Sum of the UTF-8 byte lengths of the k lines ≤ max_bytes (default 256 KiB, at most 16 MiB)
Cells k × cols ≤ 4194304 (2²² cells, so at most 64 rows at the 65535-column maximum)
Frame The complete encoded reply, envelope included, fits the connection’s negotiated max_frame (RFC-0002; default 1 MiB)

If k reaches E − start, the page is the last one (done: true, no next). Otherwise next = start + k and done: false. The daemon MUST NOT shorten or drop any line to make it fit: a line is returned whole or not at all.

If the selection is nonempty and its first row alone does not fit the line-byte or frame limit, the call fails unsupported.result_size with data.row and data.bytes (that row’s UTF-8 length). The caller can retry with a larger max_bytes, or reconnect with a larger max_frame. The cell limit always admits one row. A row’s bytes are bounded (cols cells, each with a bounded grapheme), so this error only occurs with small explicit limits or very wide panes of heavy graphemes.

The cell limit bounds the work done on the pane thread per call, which delays that pane’s output processing (never other panes). The daemon SHOULD export rows in bulk rather than cell by cell; a 10000-row, 250- column page SHOULD complete within 50 ms. There is no daemon-side cursor, cache or snapshot to expire.

7. Grants, viewports and side effects

  • read on pane:<pane>, through RFC-0005 containment (a concrete session or window scope includes its panes; a kind wildcard like window:* does not), exactly as pane.capture. The check runs before pane lookup and before §3’s parameter rules, so a caller without the right gets denied.read whether or not the pane exists and whatever the other parameters are. Unknown callers get RFC-0005’s configured default (read on * unless changed).
  • The verb reads only the authoritative Vt. It MUST NOT move, scroll or resize any viewport, change the pane size or size-policy activity (RFC-0006 §8), affect any stream’s offset, backlog, backfill or resync, write to the PTY, or emit an event. Viewports, scroll positions and selections stay client-local (RFC-0007 §4).
  • Exited panes are readable: the Vt and history are retained (RFC-0007 §3). Panes restored as archives by RFC-0010 are readable like any other pane; their hydrated text starts in a new epoch.
  • Like pane.capture, the text can contain anything a program printed, including secrets. The daemon MUST NOT log, audit or count line text; the audit log records only RFC-0005’s denied records, which name the resource and never params.

8. Errors

New product errors declared on pane.capture_rows:

Code Exit When Data
invalid_params.rows 2 §3 combination rules {"param":string,"reason":"exclusive"|"epoch_required"|"order"}
not_found.rows 3 Selection reaches outside [oldest, end) {"reason":"evicted"|"future","screen","epoch","oldest","end"}, current values
conflict.epoch 5 Given epoch is not current {"screen","epoch","oldest","end"}, current values
unsupported.result_size 10 First row does not fit (§6) {"row":integer,"bytes":integer}

Also declared, with their RFC-0007 meanings: not_found.pane (3) and denied.read (4, RFC-0005 data). Schema validation errors (invalid_params.range and the rest) are RFC-0004’s. A row address above 9007199254740991 cannot be represented in a valid result and fails as RFC-0004 internal.result; at a million rows per second that takes 285 years. The error data reveals the pane’s bounds and screen, which is pane state, only after the read check has passed.

9. Compatibility and venom-term

  • pane.capture (RFC-0007 §5), its unsupported.result_size error and its active-screen rule are unchanged. RFC-0010’s checkpoint text (always the primary screen) is unchanged.
  • RFC-0006 frames, SCROLLBACK backfill and size policy are unchanged.
  • This is a compatible addition under PROCESS.md “Changing an RFC”. On acceptance, RFC-0007’s History records that this RFC defines the history-range capture its §5 deferred.

venom-term. COM-250 needs no new library behavior. venom-term already provides everything this RFC uses:

Need venom-term API (docs/API.md)
Epoch and bounds Vt.rows() → {epoch, oldest, end, active}
Row text, RFC-0007 rendering Vt.exportRows(allocator, epoch, start, end), one LF-terminated line per row, StaleRows on a wrong epoch or pruned row
Epoch changes on resize, CSI 3 J, RIS, screen switch, anchor loss Documented rows() semantics

The daemon calls exportRows row by row (or in runs) to apply §6’s byte limit, strips each LF, and maps StaleRows to §3’s errors after re-reading rows(). Today exportRows reads every cell through readCell, allocating per cell; a bulk row export would help meet §6’s SHOULD but is an optimization, not a requirement, so no library issue is opened. §3’s two guarantees are tested by COM-250’s conformance cases (§10, cases 7 and 8) against the real library.

10. Conformance matrix

COM-250 MUST pass each case against a real headless daemon with real PTYs, through both the CLI and MCP. “Expected text” is computed independently by the test from the bytes it wrote, never by asking the daemon. “No side effects” means: no event, no PTY write, no size change, no change to any attached stream’s offset, backlog or viewport.

# Setup Action Expected
1 20×4 pane, seq 1 1000 --tail 3; default selection paged with --max-rows 100 §1’s result; all 1001 rows in 11 pages, next chaining, last page done with no next, rows N−1 = N padded to 20
2 Same pane.capture, then capture-rows --epoch E --start active --end end at the same offset Lines equal text split on LF
3 Pane prints é (precomposed), e+U+0301, 漢字, ZWJ emoji, a tab, "\ --tail covering them Exact UTF-8 graphemes; wide chars once (no continuation cell); tab as spaces; every line’s display width = cols; JSON escapes only " and \; no byte < 0x20 or 0x7F in any line
4 Pane prints SGR colours, OSC 8 link, OSC 2 title, CSI 2J Default selection No ESC or other control in any line; text only
5 Program writes continuously (e.g. yes) 100 concurrent calls with --tail 50 Each result is consistent: end − active = rows, len(lines) = end − start, offset non-decreasing per connection; each line is a whole row
6 Same, with a small scrollback Pinned range near oldest, paged slowly Either all pages succeed, or a call fails not_found.rows evicted with current oldest; never a silently shorter or shifted page
7 History of known rows Read a history page, write more output, re-read the same rows in the same epoch Identical text (history immutability, §3)
8 Writer attach with latest policy Resize the attach viewport during paging; also between two reads Pinned call after the resize fails conflict.epoch; tail reads report new cols, rows, epoch; end and oldest never decrease within one epoch across 1000 reads
9 Pane runs less (alternate screen) over a long output Default selection; then quit less and use an old pinned range screen: "alternate", oldest = active, rows lines; after quitting, conflict.epoch, and a new default read shows screen: "primary" in a new epoch
10 printf '\033[3J'; printf '\033c' Pinned range from before conflict.epoch each time
11 — --tail 1 --epoch 1; --start 0 without epoch; --epoch E --start 5 --end 4 invalid_params.rows: exclusive, epoch_required, order; no side effects
12 — --max-rows 0, --max-rows 10001, --max-bytes 0, --tail 0, --start -1 RFC-0004 invalid_params.range
13 — --epoch E --end end+1; --epoch E --start oldest−1 (after pruning) not_found.rows future; evicted; data holds current bounds
14 — --epoch E --start S --end S lines: [], done: true, start = S
15 Rows of 3-byte characters --max-bytes equal to 2.5 rows Exactly 2 lines and next; with --max-bytes below one row, unsupported.result_size with row and bytes
16 65535×4 pane with history Default selection At most 64 rows per page (cell limit), paging to completion
17 Connection negotiated max_frame 4096 Default selection over 80-column rows Every reply ≤ 4096 bytes, still complete via paging; a row too large for the frame fails unsupported.result_size
18 Caller with read only on pane:p1 Read p0 (exists) and p9 (missing), each also with invalid selection params denied.read for all four, identical apart from resource; no bounds leaked; one audit denied record each, with no params
19 Caller with read on session:s0; caller with read on window:* Read a pane in s0 Allowed; denied.read
20 Two emulating attach clients on one pane, scrolled to different history rows, one with a selection 50 calls of every selection form Both clients’ scroll positions, selections and rendered state unchanged; no RESIZE, RESYNC or extra bytes on their streams; size-policy active writer unchanged
21 events --json subscriber from before the run Any successful or failed call No event caused by the verb
22 Exited pane; RFC-0010 restored archive pane (where enabled) Default selection Readable; archive rows equal the saved plain text; new epoch
23 Canary string printed by the pane All cases, plus doctor, daemon logs and audit log Canary appears only in results
24 Existing RFC-0007 cases for pane.capture Rerun Unchanged results, including unsupported.result_size

Agent-contract impact

  • Verbs/schema: one new verb, pane.capture_rows (§2), in RFC-0004 format 1 with no generator change. CLI, MCP, docs and completions are generated from the manifest. Not streaming; no local dispatch.
  • JSON/exits: standard. New errors in §8; pagination is ordinary result data (next, done).
  • Events: none. Capture emits nothing and observes no events.
  • Skill: the generated skill MUST say: use --tail N for recent output; page with --epoch, --start <next> and the first page’s --end until done; conflict.epoch means the screen was resized, cleared or switched and the caller should re-read with --tail; not_found.rows evicted means output outran the reader, with oldest saying where to continue; lines are physical rows padded to the pane width (trim trailing spaces for display); wrapped lines are split. It SHOULD tell agents to prefer this verb over attaching.
  • Doctor: add capture_rows counters: calls, pages with done false, errors by code (including not_found.rows by reason), and the maximum time a read held a pane thread, with warn above 50 ms. No text, ranges or pane contents.
  • Config: none. Limits are per request with fixed ceilings (§6), so there is nothing for a deployment to tune; scrollback depth stays the existing Vt setting. Revisit if a ceiling proves wrong in practice.
  • Grants: read on the pane, as pane.capture (§7). No new right.
  • Headless/OSC: fully headless; works with no client attached. OSC sequences are never returned as text (§5); their observations remain RFC-0003 events from the authoritative parser.

Alternatives

  • Add range parameters to pane.capture: one verb, but its result is a single text string of the visible screen with no epoch, bounds or pagination fields. Adding them changes the meaning of an accepted verb’s result for callers that pass nothing; a separate verb keeps pane.capture exactly as specified.
  • Fail with unsupported.result_size instead of paging: simplest, but a 10000-row scrollback at 250 columns is about 2.5 MB, larger than the default frame, so full history would be unreachable without renegotiating max_frame.
  • Opaque server-side cursor: hides the addresses, but needs daemon state with an expiry and a cap per caller. (epoch, next, end) is already a complete, stateless cursor, and the caller can reason about eviction with it.
  • A streaming verb (RFC-0004 items): CLI output becomes NDJSON lines, but MCP buffers streams under RFC-0004’s 1024-item and 1 MiB caps, so a long range still needs a page contract, and one long stream holds the pane thread or loses consistency across items anyway.
  • Clamp evicted or future ranges: convenient, but it is exactly the silent truncation this RFC exists to prevent. tail gives the convenient form explicitly.
  • Newest-first pages: matches RFC-0006 backfill, but readers that print or diff text want chronological order, and tail already covers “the most recent rows”.
  • Return one text string with LF separators per page: like pane.capture, but it hides row identity and makes next arithmetic depend on splitting. An array of rows maps one-to-one to addresses.
  • Logical (unwrapped) lines: what agents ultimately want, but it needs line identities that survive reflow, which is COM-233.
  • Do nothing: agents keep attaching or wrapping commands in tee, and lose rows silently under fast output.

Open questions

  1. Primary-screen history while the alternate screen is active. Deferred to COM-268. RFC-0010’s export reads a decoded copy of the primary screen, but those rows have no live epoch and the copy costs a snapshot per call. Until then the verb reads the active screen (§3).
  2. Logical-line addressing. Out of scope; COM-233. This RFC uses physical rows only and does not assume any logical-line identity.
  3. Default selection. Resolved: all retained rows of the current epoch, oldest first (§3), so the no-argument call is the complete transcript and --tail is the explicit “recent” form.
  4. Limits. Resolved: the numbers in §6 (1000 rows and 256 KiB by default; ceilings 10000 rows, 16 MiB, 2²² cells; 50 ms pane-thread target). They match RFC-0006’s max_history_rows default and keep a default page well inside a 1 MiB frame.
  5. Library work. Resolved: none required (§9); a bulk exportRows is an optional optimization.

The maintainer accepted this RFC and its resolved/deferred questions on 2026-11-07. RFC-0007’s History records that this RFC now defines the history-range capture that RFC-0007 §5 deferred.