Durable session restore and daemon crash recovery
| Field | Value |
|---|---|
| Status | Accepted |
| Accepted | 2026-11-06, explicitly approved by the maintainer |
| Authors | commixon (commixon), with Codex |
| Created | 2026-11-06 |
| Linear | COM-243; implementation COM-244 |
| Supersedes | None |
| Superseded by | None |
History
-
2026-11-10 (venom-org/venom-spec#144, venom-org/venomx#147; normative, narrow compatible change; accepted by venom-run (unattended, delegated by the maintainer) on 2026-11-10): §8’s Secret exclusion case allows one value derived from argv, RFC-0007 §5’s
argv0(the basename ofargv[0], at most 255 bytes), in RFC-0007’spane.listresults,pane.createddata andpanesnapshot entity.argv0is never the directory part ofargv[0](anything up to its last/), the bytes past 255, or another argv element. Those parts, the cwd and the environment stay excluded everywhere. §6’s “no raw argv in errors, events or doctor” rule gets the same one-clause exception. §4 is unchanged: checkpoints don’t keepargv0, so a restored archive pane has none, and a relaunched pane carries its new spawn’s. Durable formats are unchanged. -
2026-11-09 (venomx#60, venom-spec#129; normative, small compatible addition; explicitly accepted by the maintainer on 2026-11-09): durable format 3 (section 4.2). Writers emit format 3; readers accept formats 1, 2 and 3 and refuse every other value. Format 3 split nodes may carry RFC-0007 §4’s
ratio, so a restored split keeps its shares. Format 1 and 2 files still read strictly under their own rules and restore as equal splits. A daemon that only reads formats 1 and 2 refuses a format-3 file asincompatible. -
2026-11-09 (venomx#89, venom-spec#110; explicitly accepted by the maintainer on 2026-11-09): small compatible addition to §5/§6 archive metadata,
cut_rows, and doctor reporting for bounded literal hydration. §2 clarifies the existing count-based text-retention window. Durable formats 1 and 2 are unchanged. -
2026-11-08 (venomx#44, normative, with RFC-0007’s
ratio; explicitly accepted by the maintainer on 2026-11-09): formats 1 and 2 hold no splitratio. A writer omits it and a reader rejects it as an unknown field, so a restored split is equal. Holding ratios needs a new format and its migration (section 4). -
2026-11-07 (COM-261, explicitly accepted by the maintainer): §4 clarifies that
.plaintext always comes from the primary screen, even while the alternate screen is active. This is a compatible clarification; the durable format is unchanged. -
2026-11-07 (COM-404; documents venomx COM-383/COM-309 as merged in 32d5d3d; normative, merged through the COM-404 PR, and on 2026-11-09 the maintainer agreed to mark it accepted): durable format 2 (section 4.1). Writers emit format 2; readers accept formats 1 and 2 and refuse every other value. Format 2 pane records may carry the RFC-0016 §8 float layer (
layer,float) and the RFC-0016 §4 panetags. A format-1 file reads as before: every pane tiled, no tags. Popups are never checkpointed. Restore keeps floats in their window’s float layer with fresh z-orders and re-resolved presets, and restores tags as saved (section 5). Formats 1 and 2 share everything else in section 4.
Summary
Define opt-in, versioned venomx checkpoints and explicit restore of session, window and split topology, retained exited panes, and optionally plain terminal text. Restore creates an inspectable archive, not running programs. Replacing an archived pane with a process requires a separate authorized request with a fresh argv and fresh grants. Each daemon start has fresh authority and stream identity. The maintainer accepted this design on 2026-11-06. COM-244 implements this extension after the COM-238 foundation and shared prerequisites.
The governing contracts are RFC-0001, RFC-0002, RFC-0003, RFC-0004, RFC-0005, RFC-0006 and RFC-0007. This adds an explicit durability mode to RFC-0007’s memory-only foundation. That foundation remains the default; this RFC does not supersede its transport or grant rules. It extends the shutdown persistence phase while preserving the process-termination deadline.
Motivation
After a daemon crash, users need their session organization and an honest view of what was last observed. A saved PID is not a process handle, and a saved shell command is not permission to execute it again. Restoring either blindly can kill an unrelated process or duplicate a deployment, payment or build. Durable state must therefore separate organization, historical evidence and new execution. COM-244 needs a contract for these boundaries before shipping; COM-238 and COM-223 are not expanded by this proposal.
Design
1. Headless workflow and limits
A human enables checkpoints through the ordinary ZON config verbs, then starts
venomx serve. No startup, attach, reconnect or config reload implicitly
restores a checkpoint. With an already running, empty daemon:
VENOMX_TOKEN=@owner venomx recovery list --json
VENOMX_TOKEN=@owner venomx recovery inspect --checkpoint c01234567-89ab-4cde-8fab-0123456789ab --json
VENOMX_TOKEN=@owner venomx recovery restore --checkpoint c01234567-89ab-4cde-8fab-0123456789ab --json
venomx session list --json
venomx pane capture --pane p0 --json
Commands use named flags, work with stdin closed, and require no presentation
client. @owner is a human opt-in under RFC-0005; generated agent skills MUST
NOT recommend it. Example checkpoint and source UUIDs are illustrative.
A restored pane is an archive: no PTY child, input queue, process binding or
live command exists. It has exited=true for existing list/capture/stream
consumers. Its additional recovery metadata distinguishes a known historical
exit from interrupted or unknown process state. A missing exit code MUST NOT
be interpreted as successful exit. Empty sessions and windows are preserved.
A user can explicitly replace one archive leaf with a new pane/process:
VENOMX_TOKEN=@owner venomx pane relaunch --pane p0 --argv '["/bin/sh"]' --ack-orphans=true --json
This is new execution, not a continuation. It uses the daemon’s current cwd, environment and executable resolution as RFC-0007 specifies. It MUST NOT load old argv, cwd, environment, stdin, shell state, credentials or grant profiles from disk. Restoring a checkpoint MUST execute and signal zero processes.
2. Config, storage scope and retention
Config remains ZON; checkpoints are data, never config or executable scripts.
The default section, accepted by config check, is:
.{
.recovery = .{
.enabled = false,
.interval_ms = 30000,
.max_checkpoints = 3,
.max_checkpoint_bytes = 16777216,
.max_age_seconds = 604800,
.terminal_text = .off,
.text_max_rows = 10000,
.text_max_bytes = 1048576,
.text_max_age_seconds = 86400,
},
}
terminal_text is .off or .plain. Plain text is an explicit privacy choice:
application output, display names and screen text can contain secrets. Private
files and expiry limit exposure; they cannot discover arbitrary application
secrets or erase copies/backups. The maintainer selected opt-in terminal text
for this RFC. Daemon credentials, principal registries and raw environments
MUST never be checkpointed, even in .plain mode (section 4).
interval_ms is 1000–3600000; checkpoint count is 1–100; file size is
65536–67108864 bytes; maximum age is 1–2592000 seconds. Text rows are 0–100000,
text bytes 0–16777216, and text age 1–2592000 seconds, no greater than checkpoint
age. Zero rows or bytes disables text. Per-pane text caps count screen and
history together; they do not increase the whole-file limit. The hard reader
bounds in section 4 apply independently of configuration. Unknown enum values,
invalid ranges and inconsistent age limits fail config check.
The fixed store is $XDG_STATE_HOME/venomx/recovery/<socket-key>/, falling back
to $HOME/.local/state/venomx/recovery/<socket-key>/ when XDG_STATE_HOME is
unset. XDG_STATE_HOME MUST be absolute. socket-key is lowercase SHA-256 of the
UTF-8 absolute socket path, after resolving its existing parent directory per
RFC-0002 socket selection. Store identity MUST match that path and the daemon
UID; an alternate socket MUST NOT silently use the default socket’s store.
There is no request-supplied path, cross-store import or network fetch in v1.
Every newly created recovery directory MUST be mode 0700 and files 0600, owned by the daemon UID. Readers/writers MUST use directory-relative, no-follow operations, reject symlinks/nonregular files, unexpected owners, hard links (link count other than one), and group/other permissions. They MUST validate the private recovery subtree and hold directory handles against substitution. A reader validates owner, mode, link count, size and type on the opened file handle and reads that same handle, never reopening a validated path. Readers MUST NOT chmod a suspicious preexisting path into apparent safety. No unsafe store is followed or repaired automatically. Same-UID hostile filesystem access is not a sandbox.
All fields reload live except store identity, which follows startup socket
selection. A reload does not restore, launch, change source or replace topology.
A side-effect-free validation and allocation/event preparation phase precedes
retention cleanup; activation after successful cleanup MUST have no further
fallible work. Explicit reload and watcher activation share this path. Startup applies the
same retention cleanup before readiness; it MUST fail initialization if the
configured text policy cannot be enforced on existing checkpoints.
config.set only persists the requested config; config.check performs no
cleanup. Failed partial cleanup may have deleted some files and MUST be
diagnosed; retaining active policy does not recreate deleted files.
Changing .plain to .off MUST remove stored terminal text by deleting all
checkpoints containing it before reporting successful reload; if deletion or
directory sync fails, reload fails and effective config remains unchanged.
Disabling checkpoints MUST stop writes, but MUST NOT silently delete topology
archives. A stricter age/count limit triggers pruning before successful reload.
Pruning failure is visible through config errors and doctor, not silent success.
Expired checkpoints MUST be unavailable for restore, even before disk pruning.
Prune on startup, before list/inspect/restore, after publication and on each
interval; keep the newest permitted generations by section 3’s order. Do not
prune the last valid retained checkpoint to make room for a failed write.
Retention is by checkpoint generation, not by each pane’s last successful
text capture. After max_checkpoints consecutive generations omit a pane’s
text (for example, export failure or a text budget limit), count pruning can
delete its last text-bearing checkpoint. This window is accepted by the
count-based policy: max_checkpoints remains a hard count bound, and no
per-pane text-bearing generation is exempt. Text remains in the live pane;
a later successful capture can save it again, subject to its original expiry
for an archive. A crash before that capture can lose the last durable text.
No restore silently substitutes text from an older generation.
When disabled, list/inspect/forget of existing checkpoints remain available;
checkpoint/restore require enabled=true. Explicit forget permits deletion
without enabling future writes. Forget of a safely owned regular final file
MAY delete corrupt, incompatible, expired or future-dated data without parsing
its payload; unsafe filesystem objects MUST still be rejected.
3. Atomic write and crash boundaries
Only the daemon holding RFC-0002’s exclusive socket lock may write this store. A second live daemon for that socket MUST fail before touching checkpoints. Checkpoint capture serializes a consistent topology, pane lifecycle state and optional text at one reactor boundary. No partial mutation or half split can be serialized. Encoding/disk I/O runs against that immutable bounded view; PTY processing need not wait for an entire disk write. Only one write is in flight; a periodic tick while busy is coalesced into one later capture. A fresh empty daemon MUST NOT automatically checkpoint until its first successful product mutation, restore or explicit checkpoint request. Its shutdown likewise MUST NOT overwrite/prune recoverable state merely because it started empty. After activation, intentional removal of all resources is checkpointed normally. This prevents idle startup from evicting the archives the user is about to inspect.
A committed checkpoint is a single regular file c<uuid-v4>.json. A writer:
- Allocates a new random ID and increasing
generationwithin the store. The next generation is one greater than the largest valid retained one; exhaustion of the safe JSON integer range fails without writing. - Creates an exclusive 0600
.tmp-<uuid-v4>in that directory, writes the complete envelope, and fsyncs the file. It checks all write/close errors. - Renames to the final previously unused name in the same directory, then fsyncs the directory. Only then may it acknowledge durable success.
- Prunes expired/excess older files and fsyncs the directory after deletions. A prune failure does not undo a published checkpoint; doctor reports it.
No mutable latest pointer is needed. Readers enumerate final filenames,
validate them, and order by generation (UUID lexical order breaks ties).
They MUST reject duplicate generations for restore rather than guess which
was intended. Temporary files are never candidates. On startup, the lock
holder MAY remove safe regular owned temporary files; unsafe entries cause a
store diagnostic, never recursive deletion. A corrupt newest file MUST NOT
silently cause restore of an older one: list marks it invalid; the caller must
explicitly name a valid checkpoint. No parser repair, salvage or migration
occurs during restore.
An interrupted write leaves the previous durable file intact. A file visible after rename but before successful directory fsync may or may not survive power loss. If present and valid it is inspectable, but a failed/lost reply MUST NOT be treated as proof of persistence. No claim is made beyond the filesystem’s fsync guarantees. Disk full, permission failures and size overflow leave the previous checkpoint untouched; a live mutation is not rolled back because its subsequent periodic checkpoint failed. An explicit checkpoint reports failure, while doctor records last attempt and last durable success.
Clean SIGINT/SIGTERM shutdown first follows RFC-0007: reject mutations,
terminate supervised groups with the existing 2000 ms deadline, and reap.
It then attempts a final checkpoint with shutdown="clean" only if cleanup
succeeded, including observed final exits. Final persistence has a separate
2000 ms deadline, cannot delay process termination, and on failure shutdown
reports operational failure. Hold the socket/store lock through this final
phase and release it only after normal socket/owner-file cleanup. When
recovery is enabled, this explicitly extends the total shutdown budget in
RFC-0007 §2 and RFC-0009 §4 to at most 4000 ms: 2000 ms for existing process
termination/drain, then at most 2000 ms for persistence. Disabled recovery
retains the original bound. Uninterruptible kernel I/O is an operational
failure, not proof that bounded shutdown succeeded. A pre-shutdown or periodic checkpoint has
shutdown="open"; it MUST NOT claim clean exit. A crash uses the last
published checkpoint and can lose every change since capture. Neither marker
proves whether escaped descendants or external jobs still exist.
4. Durable formats 1, 2 and 3
The outer JSON envelope has exactly format (integer 1, 2 or 3, see below),
payload (string) and sha256 (64 lowercase hex characters). Writers emit
format 3 (section 4.2); readers accept 1, 2 and 3. SHA-256 covers the exact UTF-8
bytes of the decoded payload string, without a BOM or added newline. Payload
is itself a JSON document with the shape below. The checksum detects damage;
it is not authentication against a process that can rewrite the store.
This is a complete format-1 example payload, before wrapping/checksumming (sections 4.1 and 4.2 have format-2 and format-3 ones):
{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab","generation":1,"socket":"/run/user/1000/venomx.sock","uid":1000,"captured_at":"2026-11-06T22:00:00.000Z","shutdown":"open","sessions":[{"session":"s0","name":"work"}],"windows":[{"window":"w0","session":"s0","name":"shell","revision":1,"nodes":[{"kind":"pane","pane":"p0"}]}],"panes":[{"pane":"p0","window":"w0","session":"s0","cols":80,"rows":24,"state":"running","text":{"availability":"omitted"}}]}
This script creates a valid envelope from a payload saved as payload.json
(set FORMAT = 2 or FORMAT = 3 for a format-2 or format-3 payload):
import hashlib
import json
from pathlib import Path
FORMAT = 1
payload = Path("payload.json").read_text(encoding="utf-8").rstrip("\n")
envelope = {"format": FORMAT, "payload": payload,
"sha256": hashlib.sha256(payload.encode("utf-8")).hexdigest()}
Path("checkpoint-example.json").write_text(
json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8")
This is an offline fixture, not a supported import command. Production files are written only by the daemon’s secure writer.
Required payload fields and validation:
| Field | Type / rule |
|---|---|
checkpoint |
c followed by lowercase UUID v4; matches filename |
generation |
Integer 1–9007199254740991 |
socket, uid |
Absolute socket string and nonnegative UID; match active store |
captured_at |
UTC RFC 3339 with milliseconds |
shutdown |
open or clean |
sessions |
Array of {session,name} with RFC-0007 IDs and names |
windows |
Array of RFC-0007 window objects, including revision,nodes. Split nodes have no ratio in formats 1 and 2; format 3 allows it (section 4.2) |
panes |
Array of pane records described below |
Pane records require string pane,window,session, integers cols,rows in
1–65535, state (running or exited), and text. Only exited may include
exit_code (0–255) or signal (a symbolic signal name supported by the writing platform), mutually
exclusive. Readers accept a signal matching SIG[A-Z0-9]+ even when their
platform cannot generate it; it is historical display data, never a signal
target. Neither field means unknown historical status; never synthesize
one from OSC. Clean checkpoints MUST contain no running pane. Each pane
belongs to exactly one window and session; every tree leaf refers to exactly
one pane in that window; every pane appears exactly once. Containers may be
empty. Reject missing parents, duplicate IDs, invalid preorder indexes,
cycles, invalid axes, extra/orphan nodes and revision/counter overflow.
Arrays are sorted by numeric source ID. IDs are historical labels, not grants.
Text is one of these closed shapes:
{"availability":"omitted"}: no terminal bytes saved (default).{"availability":"expired"}: retained text was discarded for age.{"availability":"plain","saved_at":"2026-11-06T22:00:00.000Z","expires_at":"2026-11-07T22:00:00.000Z","screen":["ready"],"history":["previous output"]}.
For .plain, screen contains exactly rows physical rows (the short shape
above only illustrates field types); history is oldest first. Both come from
the primary screen, even while the alternate screen is active: the alternate
screen is transient program UI, has no history and is never saved, and restore
(section 5) hydrates the primary screen. Capture MUST NOT change the live
terminal to read it. Each string
is UTF-8 plain cell text, without C0/DEL/C1 control characters, terminal escape
sequences, embedded CR/LF, styles, hyperlinks, clipboard or parser continuation.
Tabs are expanded and width-zero continuation cells omitted using RFC-0007’s
capture rules. History is capped by both configured rows and bytes, dropping
oldest rows first. Screen rows are preserved together; if they alone exceed
the text budget, omit the entire text object content with availability omitted.
Do not drop topology to fit text: omit pane text in reverse numeric pane order
until the whole checkpoint fits, or fail if topology alone is too large.
saved_at records the time the text was first captured; expires_at is no
later than saved_at plus the then-configured text age. Recheckpointing archived
text MUST preserve these original timestamps, not extend its lifetime.
On restore, expire text at the earlier of stored expiry and saved_at plus the
current configured age, or when current config forbids text. A backward clock
(now < saved_at) MUST discard text rather than extend its lifetime. A future
checkpoint capture time makes the entire checkpoint unavailable until explicitly
forgotten or the clock catches up. Expired text never prevents topology restore;
its runtime availability becomes expired and contents MUST be discarded.
Readers MUST reject duplicate JSON keys, invalid UTF-8, trailing data, checksum
mismatch, unknown fields at any durable-format level, invalid enums and unsafe
integers. Maximum file length is min(configured limit, 64 MiB); nesting depth
is at most 32, and total sessions/windows/panes/nodes at most 100000. Bounds
MUST be checked before unbounded allocations or traversals. The reader checks
format before anything else in the envelope: a non-integer is corrupt, and
an integer other than 1, 2 or 3 (0, negative, 4 or more) is incompatible
(unsupported.checkpoint_format); no best-effort interpretation. Each file is
read strictly under the rules of its own format: a format-1 file that
contains a format-2 field, or a format-1 or format-2 file that contains a
format-3 field, is corrupt, never upgraded. A future compatible
wire member does not change this strict durable-file rule. Format evolution
requires an explicit new reader and migration specification (section 4.1 is
the one for 1 to 2, section 4.2 the one for 2 to 3), never execution of serialized code.
The writer uses an allowlist, not a dump of in-memory structs. It MUST exclude owner/bearer tokens and hashes, principal IDs/ancestry/bindings, grants and audit logs, PID/PGID/start times, environment, argv, cwd, shell history files, PTY raw rings, event cursors/logs, pane stream offsets/epochs, opaque emulator snapshots, OSC observations and pending input. Names and terminal text are user content; they are not promoted into trusted authority or executable instructions. It also excludes popups (section 4.1), float geometry as resolved against config or a client’s area, presentation clients, popup tickets and registered presets.
4.1 Format 2: floats and pane tags
Format 2 is format 1 plus three optional pane-record fields. Everything else
in section 4 (envelope, checksum, payload fields, topology, text, bounds,
strictness) is identical. Both are written only when they differ from the
default, so a format-2 file of tiled, untagged panes has the same payload as
format 1 with only the envelope’s format changed.
| Pane field | Rule |
|---|---|
layer |
Only the string "float"; tiled panes omit it (writing "tile" is corrupt). Present exactly when float is |
float |
Closed object: required z, integer 1–9007199254740991; optional preset, width, height, anchor, x, y, min_cols, min_rows, border, title |
tags |
Array of 1–16 closed {key,value} objects, sorted strictly ascending by key bytes (so no duplicates); omitted when the pane has no tags (an empty array is corrupt) |
float holds the pane’s RFC-0016 §8.2 spec: its preset name and the
explicit geometry fields its creator passed, never the geometry resolved
against config or a client’s window. A field is present only when it was
given explicitly. preset is a namespaced id (<namespace>.<name>, like
segment ids in RFC-0016 §5.1). width/height are strings, a cell count
("12", 1–65535) or a percent ("60%", 1–100), as in config. anchor and
border are the RFC-0016 §8.2 enum names without the ZON dot ("top_left",
"rounded"). x/y are integers in −4096–4096, min_cols/min_rows in
1–65535, and title is at most 128 bytes of UTF-8 without control
characters. z is the saved z-order; only its relative order is used on
restore (section 5).
A float pane is in its window’s float layer, never in the split tree:
every tree leaf refers to a pane without layer, every such pane appears
exactly once in its window’s tree, and a float pane appears in no tree.
cols/rows are the float’s PTY size at capture.
tags is the pane’s RFC-0016 §4 map, the daemon-set owner included;
keys and values follow RFC-0016 §4 syntax. More than 16 tags is a size
failure (unsupported.checkpoint_size); any other bad tag is corrupt. Tags
are labels for products and presentation, not permissions: restore never
derives grants, ownership or principals from them (section 5).
Popups are never checkpointed. A popup belongs to one presentation
client and closes when that client detaches (RFC-0016 §8.1); no checkpoint
contains it, so layer is never "popup" (such a record is corrupt).
1 to 2 migration. There is no offline rewrite: the format-2 reader reads
a format-1 file directly, as every pane tiled (no layer/float) with no
tags, and rejects layer, float or tags in it as unknown fields. Restore
then gives each such pane the restoring principal’s tag namespace as its
owner (section 5). The next checkpoint after that restore is written in
the current format (format 3, section 4.2). A daemon that only reads format 1 refuses a format-2 file as
incompatible rather than misreading it.
Complete format-2 example payload (two tiled panes split by columns, two floats, tags), byte for byte what venomx 32d5d3d writes for this state:
{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab","generation":2,"socket":"/run/user/1000/venomx.sock","uid":1000,"captured_at":"2026-11-07T21:00:00.000Z","shutdown":"open","sessions":[{"session":"s0","name":"work"}],"windows":[{"window":"w0","session":"s0","name":"agents","revision":2,"nodes":[{"kind":"split","axis":"cols","first":1,"second":2},{"kind":"pane","pane":"p0"},{"kind":"pane","pane":"p1"}]}],"panes":[{"pane":"p0","window":"w0","session":"s0","cols":40,"rows":24,"state":"running","tags":[{"key":"group","value":"thread-7"},{"key":"owner","value":"venom"},{"key":"role","value":"agent"}],"text":{"availability":"omitted"}},{"pane":"p1","window":"w0","session":"s0","cols":39,"rows":24,"state":"exited","exit_code":0,"tags":[{"key":"owner","value":"venomx"}],"text":{"availability":"omitted"}},{"pane":"p2","window":"w0","session":"s0","cols":64,"rows":19,"state":"running","layer":"float","float":{"preset":"venom.settings","z":2},"tags":[{"key":"owner","value":"venom"},{"key":"role","value":"control"}],"text":{"availability":"omitted"}},{"pane":"p3","window":"w0","session":"s0","cols":48,"rows":12,"state":"running","layer":"float","float":{"width":"60%","height":"12","anchor":"bottom_right","x":-1,"y":-1,"border":"single","title":"build log","z":5},"tags":[{"key":"owner","value":"venomx"}],"text":{"availability":"omitted"}}]}
p2 is a venom control view opened with --preset venom.settings and no
explicit geometry; p3 is a float the human opened with explicit geometry
and no preset. Wrapped with FORMAT = 2 by the script above, it decodes;
wrapped with FORMAT = 1, it is corrupt.
4.2 Format 3: split ratios
Format 3 is format 2 plus one optional split-node field. Everything else in sections 4 and 4.1 (envelope, checksum, payload fields, pane records, floats, tags, topology, text, bounds, strictness) is identical.
| Split node field | Rule |
|---|---|
ratio |
Integer 1–9999, RFC-0007 §4’s first child’s share in 1/10000; omitted when the split has none. A reader accepts every value in 1–9999, including one that draws an equal split (such as 5000) |
A format-3 split node has exactly kind, axis, first, second and
optionally ratio, the same closed shape as RFC-0007 §4’s wire node. The
writer copies each split’s live ratio and omits it when the split has none,
so a format-3 file of a layout nobody resized has the same payload as format
2 with only the envelope’s format changed. A ratio that is not an integer
in 1–9999 (0, 10000, negative, fractional, a string) is corrupt; ratio on a
pane node is an unknown field and corrupt. The reader does not check a saved
ratio against pane minimums or any client’s area: like a live ratio, it is
applied by RFC-0007 §4’s formula wherever the window is drawn.
2 to 3 migration. There is no offline rewrite: the format-3 reader reads
a format-1 or format-2 file directly under that file’s own rules, and rejects
ratio in it as an unknown field. Every split in such a file restores as an
equal split (no ratio). The next checkpoint after that restore is written as
format 3. A daemon that only reads formats 1 and 2 refuses a format-3 file as
incompatible rather than misreading it.
Complete format-3 example payload (three tiled panes: p0 on the left at 62.5%
of the columns, p1 over p2 on the right with an equal split, so only the
root split has a ratio):
{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab","generation":3,"socket":"/run/user/1000/venomx.sock","uid":1000,"captured_at":"2026-11-09T12:00:00.000Z","shutdown":"open","sessions":[{"session":"s0","name":"work"}],"windows":[{"window":"w0","session":"s0","name":"agents","revision":4,"nodes":[{"kind":"split","axis":"cols","first":1,"second":2,"ratio":6250},{"kind":"pane","pane":"p0"},{"kind":"split","axis":"rows","first":3,"second":4},{"kind":"pane","pane":"p1"},{"kind":"pane","pane":"p2"}]}],"panes":[{"pane":"p0","window":"w0","session":"s0","cols":50,"rows":24,"state":"running","tags":[{"key":"owner","value":"venomx"}],"text":{"availability":"omitted"}},{"pane":"p1","window":"w0","session":"s0","cols":29,"rows":12,"state":"running","tags":[{"key":"owner","value":"venomx"}],"text":{"availability":"omitted"}},{"pane":"p2","window":"w0","session":"s0","cols":29,"rows":11,"state":"running","tags":[{"key":"owner","value":"venomx"}],"text":{"availability":"omitted"}}]}
Wrapped with FORMAT = 3 by the script in section 4, it decodes; wrapped with
FORMAT = 2 or FORMAT = 1, it is corrupt.
5. Explicit atomic restore
recovery.restore requires current admin and an empty daemon resource tree,
including no retained panes or empty containers. It validates the named file
completely, prepares all resources and events, then commits them atomically in
one reactor turn. All sessions/windows/panes get fresh runtime IDs from the
current monotonically increasing counters; window revisions restart at 1.
Results give an old-to-new mapping. IDs consumed on failed preparation remain
consumed. Snapshot consumers MUST never see a partial restored graph.
Every resource is owned by the current resolved restoring principal; no
ownership string or self scope is imported. Archives have no process-backed
principal. A relaunch issues a fresh RFC-0005 pane principal/token only when a
new child is actually prepared. Owner and unknown remain the new daemon’s
special identities; registry entries from the old daemon MUST NOT be recreated.
The current unknown policy remains read-only at most, including its existing
configured read scope. Reauthorization of agents requires explicit new
grant.issue or new spawn grants; restore does not infer grants from names,
labels, old resource IDs or archived content.
For each archive, recovery metadata is:
{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab","previous_pane":"p0","process_state":"interrupted","text_state":"omitted","cut_rows":0}
cut_rows is a nonnegative safe integer counting saved screen and history
rows that venom-term’s literal hydration kept cut at the last column because
no single layout reproduced the saved row (for example, mode 2027 changed
mid-row). It is zero for exact hydration or blank/omitted text. A positive
count leaves text_state="plain" but MUST be shown with archive status outside
terminal cells; doctor MUST report the sum across retained archives and mark
the archive check as warning when nonzero. This is a report of this restore,
not a durable pane field: checkpointing the resulting literal cells does not
carry the count into another restore. The count remains available for the
archive’s lifetime, including after its text expires.
process_state is exited when an exit was recorded, otherwise interrupted.
It says nothing about whether an escaped process survives. The metadata is an
optional closed recovery object on pane.list entries, pane.capture results
and RFC-0003 pane snapshot content. Ordinary live panes omit it. Retained known
exit code/signal is exposed by existing capture/EXIT fields. Interrupted
archives have no exit code/signal. Recheckpointing an archive MUST preserve
its historical process_state, not convert it into an observed clean exit.
For this purpose durable pane records MAY additionally contain string
previous_state (exited or interrupted) only with state="exited";
when present it overrides the archive’s process_state on subsequent restore.
Floats (format 2). A float record restores as an archive in its
window’s float layer, never into the split tree. Its saved spec (preset
name plus explicit fields) is resolved against the restoring daemon’s
presets and config, exactly like a new pane.float: the saved geometry is
never reused, and an unknown preset falls back to floats.default and is
reported by doctor (RFC-0016 §8.2). Restored floats get fresh z-orders
above the daemon’s current z counter, in their saved stacking order (saved
z ascending, ties by numeric source pane ID): with counter n and k
floats, they get n+1…n+k and the counter becomes n+k. An archive keeps
its layer: pane.place on it fails conflict.layer until it is relaunched.
pane.relaunch of a float archive puts the new pane in the same float layer
with the same spec and z; the tree is unchanged, so there is no
window.layout_changed and no revision increment.
Split ratios (format 3). Each restored split node keeps its saved
ratio, so the restored window draws the same shares; the restored tree,
ratio included, is what window.created and snapshots carry. Every split
read from a format-1 or format-2 file restores as an equal split. Restore does
not recompute or drop a saved ratio; RFC-0007 §4’s rules for removing a
ratio apply only to later pane.split and pane.place.
Tags (format 2). Saved tags are restored as saved, owner included, so
a product still finds its archives by owner/role/group. A record with
no owner tag, which includes every format-1 record, gets
owner = <restoring principal's namespace> (venomx for the human owner,
RFC-0016 §5.1). Tags carry no authority: restore never derives grants,
resource ownership, principals or a namespace from them, and a restored
owner value does not let that namespace act on the pane. A relaunched
pane does not inherit the archive’s tags; it gets a fresh owner from its
creating principal like any new pane.
Restore initializes a fresh reset terminal at saved dimensions. When valid plain
text exists, hydrate cells through venom-term’s literal-text interface, never
feed checkpoint bytes to an escape-sequence parser. Use default styles, cursor
at home and primary screen; no terminal queries, OSC or side effects. Otherwise
the screen/history is blank. Display the archive status outside terminal cells.
Plain text is deliberately lossy: alternate screen, palette, cursor, keyboard
modes, partial UTF-8 and applications are not restored. pane.capture reads
this screen under its normal read grant; no bulk durable-history verb is added.
RFC-0006 emulating attaches can backfill the hydrated history as normal.
A daemon has a fresh venomx/<uuid> source before any connection is accepted,
whether or not it later restores. Restore does not import or change source.
Old tokens fail denied.token; invalid auth MUST NOT fall back to unknown.
A valid new client using an old RFC-0003 cursor receives a fresh scoped snapshot
with reason="source_changed". With an old RFC-0006 source/offset, ATTACH to a
new mapped pane gets resumed=false, a newly encoded negotiated snapshot and
READY, then EXIT (archives are exited). The fresh ring starts at offset 0,
and row addresses/stream epochs are newly allocated. Requesting an unmapped
old ID is an ordinary authorized not_found.pane; callers must rediscover,
not assume numeric equality. No raw ring or serialized ghostty blob is loaded.
6. New verbs, schema and errors
Declare these in the same RFC-0004 format-1 product manifest as other verbs.
All are nonstreaming, have closed typed results and standard exit policy.
The closed archive recovery object on pane results and snapshots includes
required integer cut_rows as specified in §5; it does not change the durable
format or the mapping-only recovery.restore result.
Dotted names map to spaced CLI paths; no ad hoc dispatch or new grant name.
| Verb | Parameters | Result | Dispatch grant / handler checks |
|---|---|---|---|
recovery.checkpoint |
None | {checkpoint,generation} |
admin; enabled |
recovery.list |
None | {checkpoints:[{checkpoint,status,generation?}]} |
admin |
recovery.inspect |
Required string checkpoint |
{checkpoint,generation,shutdown,sessions,windows,panes,text_panes,expired_text_panes} (counts are integers) |
admin; no terminal content returned |
recovery.restore |
Required string checkpoint |
{checkpoint,sessions,windows,panes} (mapping arrays) |
admin; enabled and empty tree |
recovery.forget |
Required string checkpoint |
{} |
admin; explicit one-file deletion and directory sync |
pane.relaunch |
Required pane, argv, boolean ack_orphans; optional grants |
{pane,window,session,principal,pid} |
spawn on pane plus write on pane; archive only; ack_orphans must be true |
Mappings are arrays of closed {previous,current} string objects for each
kind; counts and generations are nonnegative safe integers. List status is
valid, corrupt, incompatible, expired, unsafe or future; generation
is omitted when not safely decoded. Listing reveals no payload from invalid
files and sorts decodable generations ascending, then unknown ones by ID.
Inspect and restore accept only valid files, including valid topology whose
text expired. A nonexistent or malformed checkpoint identifier never becomes
a filesystem path. Authorization precedes file lookup, state inspection and
existence disclosure. pane.relaunch’s dispatch grant is spawn; its additional
scoped write check MUST occur before archive lookup or mutation.
Relaunch allocates a fresh pane ID from the current counter and atomically
replaces the archive leaf with that pane, incrementing the window revision.
It discards the old screen, history and recovery metadata and creates a reset
live pane owned by the current relaunching principal. It uses RFC-0007’s
PTY/principal/exec barrier and rollback contract, including explicit argv and
RFC-0005 optional grants. Until successful commit the archive remains readable.
On failure leave it unchanged, revoke staged principals and reap staged children.
On success emit the normal principal issuance event, archive pane.removed,
new pane.created, then window.layout_changed in the same atomic commit
(a float archive has no leaf: section 5, no window.layout_changed).
Existing streams of the removed archive close with CLOSE.reason="pane_removed";
clients rediscover the new leaf. No input is carried into the new process.
The old pane ID is never reused within the source, so an archive’s old offset
cannot resume a replacement process, even if both have offset 0.
Concurrent relaunch requests serialize: at most one commits; the others get
not_found.pane after the winning replacement removes the old ID.
Retry after a lost success reply still names the removed archive ID and
cannot launch again, including after the new process exits. An ordinary
exited pane is not an archive and remains ineligible for this verb. The ack flag
acknowledges that external jobs/escaped descendants may remain and that this
new execution could duplicate external work; it does not certify cleanup.
There is no automatic retry or launch during restore, startup or reconnect.
Example wire exchange after current authentication, on an empty fresh daemon:
{"id":2,"verb":"recovery.restore","params":{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab"}}
{"id":2,"ok":{"checkpoint":"c01234567-89ab-4cde-8fab-0123456789ab","sessions":[{"previous":"s0","current":"s0"}],"windows":[{"previous":"w0","current":"w0"}],"panes":[{"previous":"p0","current":"p0"}]}}
{"id":3,"verb":"pane.relaunch","params":{"pane":"p0","argv":["/missing/program"],"ack_orphans":true}}
{"id":3,"error":{"code":"unavailable.spawn","message":"executable unavailable","data":{}}}
CLI --json unwraps ok; errors retain RFC-0001’s {"error":...} shape.
Vanished executables affect only explicit relaunch, never topology restore.
A second restore (including retry after a lost reply) fails on the nonempty
tree, without duplicate resources. A successful restore is automatically
eligible for the next periodic checkpoint but does not delete its input file.
Cancellation before publication/restore commit discards staged work; after
commit the result stands. Clients that lose a reply inspect current state.
| Error | Exit | Condition / data |
|---|---|---|
invalid_params.checkpoint |
2 | Invalid ID syntax; param="checkpoint" |
invalid_params.ack_orphans |
2 | False acknowledgement; param="ack_orphans" |
not_found.checkpoint |
3 | Authorized ID absent; empty data |
denied.admin, denied.spawn, denied.write |
4 | RFC-0005 right/resource/principal data |
conflict.recovery_busy |
5 | Explicit write already in flight; empty data |
conflict.not_empty |
5 | Restore target not empty; empty data |
conflict.not_archive |
5 | Relaunch target is not an archive; empty data |
conflict.checkpoint_corrupt |
5 | Checksum/schema/topology/duplicate-generation failure; empty data |
conflict.checkpoint_expired |
5 | Whole checkpoint expired or future-dated; empty data |
unavailable.recovery_disabled |
6 | Checkpoint/restore disabled; empty data |
unavailable.recovery_io |
6 | Store unsafe, I/O or durability failure; empty data |
unavailable.spawn |
6 | New execution failed; empty data |
unsupported.checkpoint_format |
10 | Unrecognized durable version; empty data |
unsupported.checkpoint_size |
10 | Whole-file/count/depth bounds exceeded; empty data |
Shared invalid argv, denied delegation, not-found pane, shutdown, cancellation,
frame-size and allocation-exhaustion errors retain RFC-0007/0002 semantics.
Do not include payload, paths, terminal content, raw argv or credentials in
errors, events or doctor, except RFC-0007 §5’s derived argv0 in the
pane entity and pane.created. Error messages are stable descriptions, not parser
dumps. A failed restore emits no resource/recovery-success events.
7. Orphan processes and supervision
Clean restart terminates only the live process groups owned by that daemon, using its in-memory supervision handles and RFC-0007’s existing rules. It cannot preserve a PTY master across daemon death. Forced death may leave children, escaped descendants or remote work alive; PTY hangup is not proof that all work stopped. Subreaper ownership does not transfer to a new daemon.
A new daemon MUST NOT adopt, signal, wait on, attach credentials to or infer
ownership of an old process from a saved PID, PGID, start time, executable name,
terminal device or matching command line. Those fields are not stored at all.
Orphan cleanup requiring verifiable OS containment (for example an external
supervisor’s retained handle) is deferred; v1 reports cleanup_unverified
after any restore, even for a clean checkpoint, and requires human inspection
outside venomx. It MUST NOT claim a process sandbox or promise no external
duplicate work after explicit relaunch. An unrelated process reusing a former
PID MUST remain completely untouched. Old descendants connecting to the new
daemon use fresh RFC-0005 peer resolution and at most unknown rights without
new authorization; old tokens and process bindings confer no rights.
8. Events and conformance
Restore commits ordinary session.created, window.created and pane.created
events for new resources in numeric session/window/pane order (containers before
children), followed by pane.exited for each archive. A restored float’s
pane.created carries layer = "float", its window and its resolved float
geometry and new z, as RFC-0016 §8.3 specifies for pane.float. Known exit codes are
included; interrupted archives omit them. No program.status_changed or
command.started/finished is reconstructed from old terminal text. All events
and restored topology become visible together at the commit boundary.
Extend the product event descriptor/catalog with these fields and entries; existing field types MUST NOT change:
| Type | Resource | Data | Snapshot entity |
|---|---|---|---|
recovery.restored |
server:root |
checkpoint string, cleanup_unverified=true boolean |
None (historical completion) |
recovery.restored requires read on server:root for event delivery; the
mutation still requires admin. Per-pane snapshot recovery metadata is
available only to readers of that pane. Relaunch uses the existing pane/window
catalog and snapshot mappings. The shared descriptor’s recovery is the optional closed object of section 5. Success/failure of disk
writes is exposed through doctor, not new event spam. Events are not replayed
from checkpoint files.
COM-244 MUST reuse the shared modules. COM-255 (venom-term) supplies bounded literal text export/hydration; COM-256 (venom-std) supplies fallible config pre-activation effects for retention. Both explicitly block COM-244 and are governed by this accepted specification; product-local substitutes are not allowed. Neither depends on resolving COM-230 or COM-235.
COM-244 MUST run the shared agent-contract suite and real process/filesystem fault tests. The acceptance matrix is normative; “fresh” means different from the previous daemon’s authority/source, not merely an empty in-memory cache.
| Case | Required observation |
|---|---|
| Defaults / disabled | No checkpoint writes or restore, explicit persistent zero-session behavior unchanged |
| On-demand interaction | Restore never launches daemon; nonempty restore wins active transition only before grace/stopping commit; disk checkpoints do not count as sessions; shutdown timing follows enabled/disabled budgets |
| Clean restart | Children terminated/reaped within foundation deadline; final durable clean marker only after success; explicit restore reproduces topology and known exits, no processes |
| Forced SIGKILL | Last durable generation restores; later mutations may be lost; running records become interrupted archives, cleanup unverified |
| Interrupted writes | Inject before write, short write, fsync, rename and directory fsync; prior generation survives; no temporary file selected; no false durable acknowledgement |
| Corrupt/partial state | Truncation, bad checksum, duplicate keys/IDs, invalid tree, duplicate generation fail atomically; explicit selection required for older valid file |
| Version/limits | Unknown version (0, 4+), oversized/count/depth-bound input and future timestamp produce documented errors without allocations beyond caps |
| Format 2 | Format-2 files read and restore; format-1 files still restore (all tiled, owner = restoring namespace); format-2 fields in a format-1 file, a float in a tree, layer other than "float", z < 1, invalid spec fields, unsorted/empty/duplicate tags fail; tiled + floats + popup checkpoint restores the floats in the float layer with fresh z in saved order and no popup |
| Format 3 | Writer emits format 3 and each split’s ratio, omitting it for equal splits; a checkpointed resized split restores with the same ratio; format-1 and format-2 files still restore as equal splits; ratio in a format-1 or format-2 file, on a pane node, or outside 1–9999 fails; a format-2-only reader refuses format 3 as incompatible |
| Topology | Nested rows/cols, empty containers, exited panes and removed leaves retain membership invariants; fresh IDs/mapping and revision 1; allocation failure publishes nothing |
| Text / expiry | Off stores no terminal bytes; plain preserves literal Unicode rows and clipped history; expiry/backward clock/current off drops text but restores topology; rechkpt never renews expiry |
| Secret exclusion | Seed tokens, token hashes, env, argv, cwd, OSC/credentials in runtime structs; none appear in checkpoint allowlist, errors, events or doctor; opted-in terminal text remains explicitly sensitive user content. The one exception is RFC-0007 §5’s derived argv0 (basename of argv[0], at most 255 bytes), allowed only in pane.list results, pane.created data and the pane entity; a canary in the directory part of argv[0] (anything up to its last /), in the bytes past 255 or in another argv element still appears nowhere, and checkpoints and restored archives have no argv0 |
| Credentials | New owner token, empty old registry, new child principal/token on relaunch; old owner/bearer tokens denied; no old resource ownership or self-grants restored |
| Resume | New authenticated old event cursor gets source_changed snapshot; mapped-pane old stream resume gets resumed=false and fresh snapshot/READY/EXIT; unmapped IDs fail normally |
| Grants | Unknown/scoped non-admin cannot list/inspect/checkpoint/restore/forget; relaunch checks spawn AND write; excessive child grants denied; reauthorization explicit |
| Vanished executable | Restore still succeeds; explicit relaunch returns unavailable.spawn, archive/tree intact, no child/principal leak |
| Duplicate launch | Concurrent/retried restore rejected once tree nonempty; concurrent/retried relaunch launches at most once, including rapid exit and lost response |
| Orphans / PID reuse | Crash with surviving descendants and a reused-PID unrelated process; no startup/restore signal, adoption or implicit launch; diagnostics never assert cleanup |
| Filesystem security | Other UID, symlink, hard link, permissive store, replaced file, disk full and prune failure tested; no traversal, unsafe reads or silent success |
| Retention/config | Count/age pruning synced; valid last generation survives failed write; off transition removes text checkpoints; failed purge/reload reported and active policy unchanged |
| Events/clients | Restore snapshot race exposes all or none; no OSC replay; relaunch closes old streams, old pane ID cannot resume new process; per-resource grants filter snapshots/events |
Agent-contract impact
- Verbs/schema: section 6 adds generated CLI/MCP/docs/completions from one manifest; recovery handlers require current authority, not serialized intent.
- JSON/exits: standard results/errors/exits remain; new errors are section 6. No startup banner or persistence diagnostic may pollute JSON stdout.
- Events: section 8; sources/cursors remain RFC-0003, binary resumes RFC-0006.
- Skill: describe archives, explicit fresh argv/grants, lost-reply inspection, no automatic restore/relaunch, opt-in text privacy and process limitations; continue recommending verbs/events for agents rather than attaching.
- Doctor: add
recovery.storesafety/writability, enabled/effective bounds, last attempt and durable-success time, last error code, retained/invalid/ expired counts and bytes, archive count andcleanup_unverifiedstatus. Unsafe/unwritable enabled stores or failed writes are errors; disabled stores are informational; restored orphan uncertainty is a warning. Follow the existing doctor result-status contract and redact all user content. - Config: shared get/set/check/reload/schema exposes section 2 with defaults, enums/ranges and sensitive-text explanation. Config never initiates recovery.
- Grants: no new rights; section 5 requires fresh identities/reauthorization. Default unknown remains read-only; opaque data never grants authority.
- Headless/OSC: all operations run without a TTY; restored text cannot execute escapes or revive observations. Only new live authoritative PTYs parse OSC.
Alternatives
- Do nothing: loses topology and historical evidence on restart, leaving COM-244’s behavior unspecified.
- Implicit command replay: may duplicate external side effects and cannot restore program memory or authority. Explicit relaunch requires new intent.
- Adopt a surviving PID/PGID: unsafe under PID reuse and missing ownership; retained OS supervision is a separate future design.
- Persist raw PTY or ghostty snapshots: risks terminal side effects, secrets, parser compatibility and stale offsets. Plain text is lossy but format-independent.
- Persist principals/tokens: contradicts RFC-0005’s v1 restart boundary.
- Restore into nonempty sessions: requires merge and conflict policy; an empty target makes atomicity and ambiguous retries concrete without merging owners.
- Always retain terminal text: unnecessarily saves sensitive output. Off is the default; plain content requires explicit config and bounded retention.
Open questions
- Terminal content policy: resolved by the maintainer: optional plain text with private permissions and retention limits. It does not guarantee removal of application secrets printed as terminal text.
- Binary terminal fidelity and stable snapshots: deferred to COM-230. Formats 1, 2 and 3 neither read nor require ghostty disk snapshots; any durable binary extension needs its own accepted format/migration/security contract.
- Persistent principals or changing unknown policy: deferred to COM-235. Fresh issuance and existing read-only defaults apply independently here.
- OS-backed adoption/orphan cleanup and merging into a live daemon: deferred beyond COM-244; no such behavior ships under this RFC. COM-244 must open a separately scoped spec follow-up before implementing either feature.
- On-demand daemon lifetime: coordinated with COM-241’s now-accepted RFC-0009. Recovery commands are never auto-start triggers. Prefer explicit persistent serve for inspection/restore. In an already running on-demand daemon, nonempty restore transitions awaiting_first_session to active at its atomic commit; empty restore does not cancel or extend the 5000 ms grace. Expiry/stopping and restore serialize exactly like session creation: once stopping wins, authorized restore fails unavailable.shutdown without side effects. Existing sessions/archives count normally; disk checkpoints alone do not keep the daemon alive. Last-session and grace-expiry shutdown use section 3’s final checkpoint rule, including the untouched-empty-start safeguard and opt-in durability deadline extension.
The maintainer accepted this RFC and its resolved/deferred questions on 2026-11-06, including opt-in terminal text and the final persistence deadline extension.