Venom
RFC-0015venom-spec

Trusted run/wait command execution results

Field Value
Status Accepted
Accepted 2026-11-07, explicitly approved by the maintainer
Authors commixon (commixon)
Created 2026-11-07
Linear COM-251; implementation COM-252
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): §9’s “events carry no argv” rule and conformance row 28 exempt RFC-0007 §5’s argv0 in the mirrored pane.created. argv0 is the basename of the executed argv[0], at most 255 bytes. It is never the directory part of argv[0] (anything up to its last /), the bytes past 255, or another argv element. A shell run’s argv0 is sh, from /bin/sh, never from the shell text. Key, output, environment and cwd stay out of events.

Summary

Add trusted command execution to venomx: a run is a command that the daemon starts as the root process of a new pane, under its own daemon- allocated ID (r0, r1, …), and whose result comes only from the daemon’s own wait status for that process. Five verbs: run starts one and returns as soon as the program has been exec’d; run.wait blocks until it completes and returns its outcome, exit code or signal and a bounded tail of its output; run.get, run.list and run.cancel read and stop runs. Every run carries a caller-chosen idempotency key, so a retry after a lost response returns the same run instead of starting the command twice. Cancelling or losing a wait never stops the command; stopping it is an explicit write. OSC 133 and OSC 7501 output from the program is never an input to any of this. The verbs need spawn to start and read to read results, under RFC-0005. COM-252 implements this RFC in venomx after COM-238.

Motivation

Agents drive builds, tests and tools. Today they have two bad options in venomx:

  • Send text to a shell with pane.send and watch the screen. Whether the command started, which command it was, and whether it succeeded all come from PTY output, which the program controls. RFC-0003 §8 makes OSC 133 an untrusted observation and says an observed D;0 “MUST NOT complete a trusted run --wait”. Nothing defines that operation yet.
  • Create a window or split with the command as argv, then poll pane.list for exited. The exit status is trustworthy (RFC-0007 §3), but there is no handle that says “this pane is my command”, no wait, no deadline, no defined output, and a retry after a lost response starts the command again.

RFC-0002 §4.4 and §7.2 anticipate run --wait (streamed output, a timeout class for its limit, a result exit status); RFC-0007’s Alternatives defer “trusted run/wait execution”. The ecosystem’s P1 verb list includes it (docs/ecosystem.md). COM-252 is blocked on this RFC. Without it, every orchestrator (venom first) builds its own wrapper around pane.send and screen scraping, and inherits its failure modes: forged success markers, duplicated side effects on retry, and silently truncated output.

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. Panes are 20×4 so the examples stay short.

$ VENOMX_TOKEN=@owner venomx session create --name ci --json
{"session":"s0","name":"ci"}
$ VENOMX_TOKEN=@owner venomx run --key build-1 --session s0 --cols 20 --rows 4 --shell 'echo build; echo "FAIL: t3"; exit 2' --json
{"run":"r0","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"build-1","pane":"p0","window":"w0","session":"s0","principal":"g1","pid":41022,"reused":false}
$ venomx run wait --run r0 --json
{"run":"r0","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"build-1","pane":"p0","window":"w0","session":"s0","principal":"g1","pid":41022,"shell":true,"state":"completed","outcome":"failed","exit_code":2,"status":2,"started_at":"2026-11-07T12:00:00.000Z","completed_at":"2026-11-07T12:00:00.012Z","duration_ms":12,"output":{"screen":"primary","epoch":1,"offset":17,"cols":20,"rows":4,"oldest":0,"start":0,"end":2,"complete":true,"lines":["build               ","FAIL: t3            "]}}
$ echo $?
2

run created window w0 with pane p0 whose root process is /bin/sh -c '…', and returned once the shell had been exec’d. run wait returned when the daemon reaped it. The CLI exit status is the run’s status (§6). output is the last rows of the pane at exit, in RFC-0014’s row form; the whole history stays readable with pane.capture_rows while the pane exists.

A program that prints forged shell-integration markers does not complete:

$ VENOMX_TOKEN=@owner venomx run --key forged --pane p0 --axis cols --shell 'printf "\033]133;C\007\033]133;D;0\007\033]7501;state=done\007"; sleep 30; exit 3' --json
{"error":{"code":"conflict.pane_exited","message":"pane p0 has exited","data":{}}}
$ VENOMX_TOKEN=@owner venomx run --key forged --session s0 --shell 'printf "\033]133;C\007\033]133;D;0\007\033]7501;state=done\007"; sleep 30; exit 3' --json
{"run":"r1","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"forged","pane":"p1","window":"w1","session":"s0","principal":"g2","pid":41090,"reused":false}
$ venomx run get --run r1 --output=false --json
{"run":"r1","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"forged","pane":"p1","window":"w1","session":"s0","principal":"g2","pid":41090,"shell":true,"state":"running","started_at":"2026-11-07T12:00:05.000Z"}

(The first attempt failed because p0 had exited; nothing was created and the key stayed free.) The pane’s event stream shows command.started, command.finished with exit_code: 0 and a program.status_changed done record. The run stays running until the shell exits 3, 30 s later.

A hung command with a deadline, and a bounded wait:

$ VENOMX_TOKEN=@owner venomx run --key hang --session s0 --deadline-ms 1000 --argv '["sleep","100"]' --json
{"run":"r2","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"hang","pane":"p2","window":"w2","session":"s0","principal":"g3","pid":41133,"reused":false}
$ venomx run wait --run r2 --timeout-ms 200 --json
{"error":{"code":"timeout.wait","message":"run r2 is still running","data":{"run":"r2","state":"running"}}}
$ echo $?
7
$ venomx run wait --run r2 --output=false --json
{"run":"r2","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"hang","pane":"p2","window":"w2","session":"s0","principal":"g3","pid":41133,"shell":false,"state":"completed","outcome":"terminated","cause":"deadline","signal":"SIGHUP","status":124,"started_at":"2026-11-07T12:00:06.000Z","completed_at":"2026-11-07T12:00:07.001Z","duration_ms":1001}
$ echo $?
124

The wait timeout did not touch the command; the run’s own deadline did.

A retry after a lost response, on the wire (RFC-0002 NDJSON, after hello). The first connection drops before the response arrives:

{"id":4,"verb":"run","params":{"key":"lint-7","session":"s0","argv":["make","lint"]}}

The client reconnects and sends the identical request:

{"id":1,"verb":"run","params":{"key":"lint-7","session":"s0","argv":["make","lint"]}}
{"id":1,"ok":{"run":"r3","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","key":"lint-7","pane":"p3","window":"w3","session":"s0","principal":"g4","pid":41201,"reused":true}}

make lint ran once. The same key with different parameters fails:

$ VENOMX_TOKEN=@owner venomx run --key lint-7 --session s0 --argv '["make","lint","-k"]' --json
{"error":{"code":"conflict.run_key","message":"key lint-7 is bound to run r3 with different parameters","data":{"run":"r3"}}}
$ echo $?
5

2. Runs, panes and ownership

A run is a daemon-owned record bound to exactly one pane: the pane created by the run request, whose root process is the command. The run’s process is that pane’s leader, so the run’s result is the pane’s RFC-0007 §3 wait status, observed by the daemon’s own supervision. There is no other source of completion.

  • A run never adopts an existing pane, shell or process. Sending a command line to a shell (pane.send) is not a run and never becomes one.
  • The pane is an ordinary venomx pane: it appears in window.get and pane.list, can be attached, watched and captured, receives pane.send input (for programs that prompt), and is retained after exit until removed (RFC-0007 §5, RFC-0012). Humans can watch an agent’s build.
  • The target is a session (the run gets a new window, like window.create) or a live pane to split (like pane.split). The topology, principal, PTY, environment and executable rules are exactly those of the verb it mirrors (RFC-0007 §5, RFC-0013 when implemented).
  • Run IDs are r0, r1, … from their own increasing counter, never reused within a source (RFC-0003), including after failed creation or expiry. They are allocated by the daemon; nothing a program prints or a caller sends can name or forge one.
  • The run’s resource is run:<id>. Containment (RFC-0005 §5) extends RFC-0007’s tree: parentOf(run:<id>) = pane:<pane>. Its owner is the resolved principal of the run request. The pane principal created for the command is distinct, as for any spawn (RFC-0007 §3); its environment additionally gets the informational VENOMX_RUN=<id>, which is never trusted (like VENOMX_PANE, RFC-0005 §3.6).

A run is running, then terminating (only after a termination request, §7), then completed. completed is final. There is no visible starting state: a run exists only once its program has been exec’d.

3. Schema

Five verbs in the venomx RFC-0004 format-1 manifest. None streams. CLI and MCP names follow RFC-0004 §4–5.

Verb CLI MCP tool Dispatch grant Resource check Exit policy
run venomx run run spawn spawn on the target standard
run.wait venomx run wait run_dwait read read on run:<id> .result, field status
run.get venomx run get run_dget read read on run:<id> standard
run.list venomx run list run_dlist read read on each run standard
run.cancel venomx run cancel run_dcancel write write on run:<id> standard

run parameters:

.{
    .{ .name = "key", .summary = "Idempotency key: a retry with the same key and parameters returns the same run instead of starting the command again", .type = .{ .kind = .string }, .required = true },
    .{ .name = "session", .summary = "Run in a new window of this session; give exactly one of session or pane", .type = .{ .kind = .string } },
    .{ .name = "pane", .summary = "Run in a new pane split from this live pane; give exactly one of session or pane", .type = .{ .kind = .string } },
    .{ .name = "axis", .summary = "Split axis; required with pane, not allowed with session", .type = .{ .kind = .string, .choices = .{ "cols", "rows" } } },
    .{ .name = "argv", .summary = "Program and arguments, executed directly with no shell; give exactly one of argv or shell", .type = .{ .kind = .array, .items = .{ .kind = .string } } },
    .{ .name = "shell", .summary = "Command line run explicitly as /bin/sh -c; give exactly one of argv or shell", .type = .{ .kind = .string } },
    .{ .name = "name", .summary = "Window name; only with session", .type = .{ .kind = .string } },
    .{ .name = "cols", .summary = "Pane columns", .type = .{ .kind = .integer, .min = 1, .max = 65535 }, .default = 80 },
    .{ .name = "rows", .summary = "Pane rows", .type = .{ .kind = .integer, .min = 1, .max = 65535 }, .default = 24 },
    .{ .name = "deadline_ms", .summary = "Terminate the command this many milliseconds after it starts; default: no deadline", .type = .{ .kind = .integer, .min = 1, .max = 86400000 } },
    .{ .name = "output_rows", .summary = "Most rows of output kept in the result", .type = .{ .kind = .integer, .min = 0, .max = 1000 }, .default = 100 },
    .{ .name = "output_bytes", .summary = "Most UTF-8 bytes of output lines kept in the result", .type = .{ .kind = .integer, .min = 0, .max = 262144 }, .default = 65536 },
    .{ .name = "source", .summary = "Fail unless the daemon's event source is this one (detects a daemon restart between retries)", .type = .{ .kind = .string } },
}

plus grants exactly as on window.create and pane.split (RFC-0005 §6.2, RFC-0007 §5), and, once venomx implements RFC-0013 (COM-248), its cwd, env and env_unset with identical semantics, validation, errors and redaction. Until then those three are absent from run too; run needs nothing beyond the RFC-0007 foundation defaults (daemon working directory, sanitized daemon environment).

The run result is a closed object: strings run, source, key, pane, window, session, principal; integer pid; boolean reused (true when an existing run was returned for the key, §8).

run.wait parameters: required string run; optional integer timeout_ms (0–86400000, absent: no limit); boolean output (default true). run.get: required run; output (default true). run.cancel: required run; RFC-0012 §2’s signal and grace_ms records unchanged (default SIGHUP, 2000 ms). run.list: optional strings pane, key and after (a run ID; list runs after it), and integer limit (1–1024, default 256); result {source, runs, next} with runs the records ordered by run ID, each without output, and next (present only when more matching runs follow) the after for the next page. An explicit pane is authorized (read on it) before lookup, as RFC-0007’s pane.list.session.

The run record, returned by run.wait, run.get and run.cancel:

Field Type Presence / meaning
run, source, key, pane, window, session, principal string Always. window and session are those the pane was created in
pid integer Always. The leader’s PID at creation; informational only
shell boolean Always. True when started from shell
state "running" | "terminating" | "completed" Always
outcome "succeeded" | "failed" | "signaled" | "terminated" Only when completed (§5)
exit_code integer 0–255 Only when completed by a normal exit
signal string Only when completed by death from a signal: its symbolic name
cause "cancel" | "deadline" | "pane_kill" | "removal" | "shutdown" Only when a termination was requested (§7)
status integer 0–255 Only when completed; required in run.wait’s result (§6)
started_at, completed_at string RFC 3339 UTC with milliseconds, as RFC-0003 ts; completed_at only when completed
duration_ms integer Only when completed: monotonic time from commit to reap
output object Only when completed and the request’s output is true (§4)

run.wait’s result descriptor is this record with state, outcome, status, completed_at and duration_ms required, so the RFC-0004 result-exit policy has its required status field. The other verbs use the descriptor with those fields optional. Format 1 has no unions or mutual exclusion, so the handler enforces “exactly one of” rules with the errors in §10. No shared generator work is required.

Summaries of run MUST say that it starts a process, that key makes retries safe, and that argv uses no shell. Summaries of run.cancel MUST say it is destructive. run.wait’s MCP description MUST say that a wait blocks the tool call and RECOMMEND timeout_ms of at most 60000 with a loop on timeout.wait.

4. Command, output and limits

Direct argv or explicit shell. Exactly one of argv and shell. argv follows RFC-0007 §5 (nonempty, nonempty first element, no NUL), executed directly with no shell and no shell fallback (RFC-0013 §6 once implemented). shell is 1–65536 bytes with no NUL and is executed as argv = ["/bin/sh", "-c", <shell>], with /bin/sh by absolute path, never $SHELL or a PATH search. A shell is never implied: the daemon MUST NOT wrap argv, add exec, set -e, redirections or 2>&1, or rewrite either form. With shell, the result is the shell’s exit status; a missing command inside it is the shell’s 127, a failed run, not an exec failure.

Output tail. When the run completes (§5), in the same reactor turn, the daemon captures the tail of the pane’s active screen from the authoritative Vt, at the final pane offset that RFC-0006 EXIT carries. Rows are RFC-0014 §5’s text form (one physical row per string, padded to cols, no controls). Let [oldest, end) be RFC-0014’s bounds at that point, and e be one past the last row that contains a non-space cell (oldest if none): trailing blank screen rows are not output. The tail is rows [s, e) for the smallest s ≥ oldest such that it has at most output_rows rows and at most output_bytes bytes of line text. A row is kept whole or not at all; output_rows=0 or output_bytes=0 keeps no lines.

The output object has screen, epoch, offset, cols, rows, oldest, start (= s), end (= e), lines, and boolean complete. complete is true exactly when the tail holds every row the pane ever had: the pane’s row epoch never changed since creation (RFC-0014 §3), oldest is 0 and start is 0. When complete is false, the result says which rows it holds and the caller can read earlier ones with pane.capture_rows --epoch <epoch> --end <start> while the pane exists and the epoch is unchanged; rows before oldest are gone. The daemon never shortens a line, merges rows or invents text, and output is never the source of the outcome.

The tail reflects the PTY as rendered: stdout and stderr are merged, a progress bar rewritten with CR is one row, and a full-screen program that is still on the alternate screen at exit yields that screen. Separate streams and raw bytes are deferred (Open question 1, COM-271).

No item stream. None of the verbs streams. Live output is available through RFC-0006 attach (humans) and pane.capture_rows polling (agents), and lifecycle through events (§9). This keeps MCP results bounded and avoids streaming rows that can still change on screen (RFC-0014 §4). RFC-0002 §4.4’s mention of streamed run --wait output was illustrative.

Bounds. A record’s retained output is at most 256 KiB of line text by schema; with JSON escaping of " and \ that is at most 512 KiB, inside the default 1 MiB frame. A run.wait or run.get reply that still exceeds the connection’s negotiated max_frame fails unsupported.result_size with data.bytes; the caller retries with output=false and reads rows with pane.capture_rows. The daemon MUST NOT truncate a reply to fit.

5. Completion and outcome

A run completes in the reactor turn in which the daemon reaps its leader after RFC-0012 §4’s pinning and group sweep (the same turn that commits pane.exited). The outcome is computed from that wait status and from whether the run was terminating when the exit was observed:

Run state at exit Wait status outcome Fields
running exited 0 succeeded exit_code: 0
running exited 1–255 failed exit_code
running killed by signal signaled signal
terminating any terminated cause, plus exit_code or signal

succeeded therefore means exactly: the daemon reaped the leader, it exited with status 0, and nobody asked the daemon to stop it first. A program that catches a termination signal and exits 0 is terminated, never succeeded. Nothing else completes a run or affects its outcome:

  • OSC 133 markers (D;0 or otherwise), OSC 7501 reports, command.* and program.status_changed events, screen text, titles and silence MUST NOT complete a run, change its state or outcome, or create one. The daemon’s OSC observation pipeline (RFC-0003 §7–8) runs unchanged for a run’s pane.
  • Descendants that left the leader’s process group (RFC-0012 §4, “Not covered”) do not delay completion and are not part of the result. succeeded is a statement about the leader only; doctor counts escaped descendants (RFC-0005 §12).
  • Exec failure is not an outcome: no run exists (§8).

duration_ms is measured on the monotonic clock. started_at and completed_at are wall-clock and may disagree with it.

6. Waiting and CLI exit status

run.wait returns the record when the run is completed, immediately if it already is. Otherwise it waits on reactor notifications (never by blocking the reactor) until completion or timeout_ms. When timeout_ms passes first, it fails timeout.wait with {"run","state"}. timeout_ms: 0 is a non-blocking check that fails timeout.wait unless completed. Any number of requests may wait on one run; all complete from the same reap with identical records.

A wait never acts on the run. Its timeout, RFC-0002 cancel of the wait request, Ctrl-C in venomx run wait (which sends that cancel), and closing its connection end only the wait (cancelled, exit 8, or timeout.wait, exit 7). The command keeps running. To stop it, call run.cancel or set deadline_ms.

status. run.wait declares RFC-0004’s result exit policy with field status, so venomx run wait exits with it and MCP returns it as data. It is a convenience for scripts, derived from the outcome:

outcome status
succeeded 0
failed exit_code (1–255)
signaled 128 + the platform’s signal number, at most 255
terminated, cause = deadline 124
terminated, any other cause 125

status is 0 only for succeeded. A program can itself exit 124, 125 or 128+n, so a script that must distinguish these reads outcome. Errors from run.wait itself (timeout.wait, denied.read, …) use RFC-0002’s classes as always.

7. Termination: cancel, deadline and other causes

Every daemon-initiated stop of a run is an RFC-0012 §4 termination of the run’s pane: the same group target, pinning, SIGCONT, monotonic grace deadline, SIGKILL escalation, input freeze, joining and 1000 ms kill-wait. On entering it, the run becomes terminating with a cause:

Cause Trigger
cancel run.cancel with its signal and grace_ms
deadline deadline_ms elapsed since commit, measured on the monotonic clock; SIGHUP with 2000 ms grace
pane_kill pane.kill of the run’s pane (RFC-0012)
removal Recursive window.remove or session.remove covering the pane (RFC-0012)
shutdown Daemon shutdown (RFC-0007 §2)

The first cause recorded wins; later requests join the termination (RFC-0012 §4, “Joining”) without changing cause. A deadline that fires during a termination does nothing.

run.cancel responds with the completed record once the leader has been reaped. On a run already completed it returns the record unchanged, sends nothing and does not change the outcome; on a terminating run it joins. If the kill-wait deadline passes, it fails RFC-0012’s timeout.terminate with {"panes":[<pane>]}; the run stays terminating and completes, with cause kept, when the leader finally exits. Cancelling the run.cancel request itself (RFC-0002 cancel, disconnect) ends only that request; signals already sent are not undone and the termination continues, exactly as in RFC-0012 §5.

At shutdown, waiters receive the completed record if the run completes before transport teardown, otherwise cancelled; delivery is best effort (RFC-0012 §5). Runs are memory-only: they are not checkpointed by RFC-0010, a restored archive pane has no run, and pane.relaunch of a run’s former pane does not create one.

8. Idempotency, retries and duplicate execution

Keys. key is 1–128 bytes of [A-Za-z0-9._:-], otherwise invalid_params.key (syntax). It is required: every start can be retried safely, and there is no keyless path that silently duplicates. Keys are scoped to the resolved principal of the request (RFC-0005 §3): the same key from two principals names two runs, and no principal can discover or join another’s key. The generated CLI and MCP adapter in one pane resolve to that pane’s principal, so they share its namespace.

The daemon binds (principal, key) to the run and to a parameter digest: HMAC-SHA-256, under a random per-daemon key that is never exposed, of the canonical JSON of every run parameter after default insertion, except source. The digest is the only thing retained about env values (RFC-0013 §4’s retention rule otherwise holds); it lives in memory only and appears in no output.

Lookup. After authorization (§11), a run request looks up its (principal, key):

Found Result
Nothing Start a new run (below)
A run with the same digest, any state Return its run result with reused: true. Nothing is started, re-checked against target state or re-signalled.
A run with a different digest conflict.run_key with {"run"}
A run request with the same key still preparing Join it: respond with its result (reused: true) or its error

A binding lasts as long as its run’s record (§12); after the record expires, the key is free and a new request starts a new run. A failed start (any error, including exec failure and rollback) binds nothing.

Starting. Preparation, exec-status and rollback are RFC-0007 §5’s (with RFC-0013 §6’s exec-status phases once implemented). On exec failure the request fails unavailable.spawn and nothing is visible: no run, pane, window, principal, PTY, child, event or key binding. The run ID may be consumed. On exec success, the run, its pane and its key binding commit together in one reactor turn with their events (§9).

Cancel and disconnect of run. Before the daemon releases the child- start barrier, RFC-0002 cancel or closing the connection rolls the start back as above, and the request ends cancelled. After the barrier is released the program may already be running, so the start is no longer cancellable: the daemon completes it normally (commit on exec success, rollback on failure) and then ends the request with cancelled, or drops the response if the connection is gone. A client that did not see a response repeats the identical request, or calls run.list --key <key>; either finds the run if it committed.

Restart. Keys, records and the HMAC key die with the daemon. A retry reaching a new daemon would start the command again. A client that passed source (from an earlier run.list, or from its event cursor) gets conflict.source with the current {"source"} instead, and learns that the original’s fate is unknown; RFC-0010 §7 applies to any surviving process. The daemon MUST NOT start, retry or re-run a command on its own: not on reconnect, restore, relaunch, config reload or startup.

9. Events

The venomx event catalog (RFC-0003 §6, RFC-0007 §7, RFC-0012 §6) gains:

Type Resource Data Snapshot entity
run.started run:<run> run, pane run: {run, pane, state} plus, once completed, outcome and optional exit_code, signal, cause
run.terminating run:<run> run, cause run
run.completed run:<run> run, pane, outcome, optional exit_code, signal, cause run
run.expired run:<run> run run disappears

New data fields run, outcome, cause and state are strings, as is signal (death signal, never confused with RFC-0012’s requested_signal). exit_code keeps its existing integer type. The snapshot includes every retained record, running or completed. Events carry no argv, shell text, key, output, environment or cwd. The one exception is RFC-0007 §5’s argv0 in the mirrored pane.created: the basename of the executed argv[0], at most 255 bytes. It is never the directory part of argv[0] (anything up to its last /), the bytes past 255, or another argv element. A shell run executes /bin/sh, so its argv0 is sh. It is never derived from the shell text.

Ordering, each in one reactor turn:

  • Start: the mirrored verb’s events (window.created then pane.created, or pane.created then window.layout_changed), then run.started.
  • Termination: pane.terminating (RFC-0012), then run.terminating. A deadline emits both.
  • Completion: RFC-0003’s pane-exit sequence (command.finished with reason pane_exit if a command was observed, program-status cleanup, pane.exited), then run.completed.
  • Expiry: run.expired when the record is evicted (§12).

run.completed is the only event that reports a run’s result, and it is emitted only from the reap. command.finished may precede it with any exit_code; consumers MUST NOT treat it as the run’s result.

Events are authorized against run:<run> through containment. A run record outlives its pane: when the pane is removed, the record freezes its authorization data exactly as RFC-0012 §6 does for removed resources (its owner chain and containment ancestors as of the removal commit), and read/write on the run, and on its retained events, are evaluated with live grants against the frozen data. The frozen data is freed with the record or with the last retained event about it, whichever is later.

10. Errors

New product errors:

Code Exit Verb When Data
invalid_params.command 2 run Neither or both of argv and shell; bad shell {"param","reason":"missing"|"exclusive"|"size"|"nul"}
invalid_params.target 2 run Neither or both of session and pane; pane without axis; axis without pane; name without session {"param","reason":"missing"|"exclusive"|"misplaced"}
invalid_params.key 2 run §8 syntax {"param":"key","reason":"syntax"}
not_found.run 3 all but run Authorized run absent {"reason":"expired"|"unknown"}: expired for IDs below the counter
conflict.run_key 5 run Key bound to different parameters {"run"}
conflict.source 5 run source differs from the daemon’s {"source"}, the current one
unavailable.run_limit 6 run run.max_active runs not yet completed {"max_active"}
timeout.wait 7 run.wait timeout_ms passed first {"run","state"}
unsupported.result_size 10 run.wait, run.get, run.list Reply exceeds max_frame {"bytes"}

Also declared, with their existing meanings: on run, invalid_params.argv, .name, .size (RFC-0007), RFC-0013’s errors once implemented, not_found.session, not_found.pane, denied.spawn, denied.delegation, conflict.pane_exited, conflict.pane_terminating, conflict.removing (RFC-0012), unavailable.spawn, unavailable.shutdown; on run.cancel, denied.write, timeout.terminate, unavailable.shutdown; on the read verbs, denied.read. cancelled and schema validation errors are RFC-0002/0004.

11. Authorization and validation order

run checks, in order, all in the request’s first reactor turn and before any effect:

  1. RFC-0004 schema validation; RFC-0005 dispatch gate (spawn).
  2. Target form: exactly one of session and pane (invalid_params.target), because it names what to authorize.
  3. spawn on session:<id> or pane:<id>, before lookup, so an unauthorized caller gets denied.spawn whether or not it exists.
  4. source (conflict.source), then unavailable.shutdown.
  5. grants coverage (denied.delegation, RFC-0005 §6.2).
  6. Remaining parameter rules: invalid_params.target (misplaced), .command, .key, .argv, .name, .size, RFC-0013 syntax.
  7. Key lookup (§8). A reused run returns here.
  8. Target lookup and state: not_found.*, conflict.pane_exited, .pane_terminating, .removing.
  9. unavailable.run_limit; RFC-0013 cwd resolution; preparation and exec (§8).

Result-read authority. Reading a run (run.wait, run.get, run.list, run.* events) needs read on run:<id>: covered by *, run:*, run:<id>, a concrete pane, window or session scope containing it, or self for its owner and the owner’s ancestors’ grants as intersected by RFC-0005 §6.1. The command’s own pane principal, with the default self grants, can read and cancel its own run. A kind wildcard such as pane:* does not cover runs. run.list filters each record by read and, with key, matches only the caller’s own namespace. An absent run is covered only by *, run:* or run:<id> (RFC-0005 §7): with any other scope an expired run gives denied.read, never not_found.run.

Unknown callers get RFC-0005’s configured default (read on * unless changed), so they can read run results and output tails, as they can already capture panes (RFC-0005 §12, T14). They can never start or cancel a run. Stopping a run needs write on run:<id>, covered by write on its pane, window, session or self; it also stops the pane, so holders of write on only the run can terminate that pane and nothing else.

12. Retention, limits and config

A completed record is kept until evicted. Eviction is oldest-completed first, emits run.expired, frees its key binding, and happens when any limit below is exceeded; config reload applies new limits at once. Records that are not completed are never evicted and do not count toward the retention limits. Removing a run’s pane does not evict its record.

.{
    .run = .{
        .max_active = 64,
        .retain_count = 256,
        .retain_ms = 3600000,
        .retain_bytes = 16777216,
    },
}
Field Range Meaning
max_active 1–4096 Runs not yet completed; more fails unavailable.run_limit
retain_count 0–65536 Completed records kept
retain_ms 0–604800000 Longest a completed record is kept, monotonic from completion
retain_bytes 0–1073741824 Total retained output line bytes

Changes go through the shared config verbs (admin, RFC-0005 §11). Lowering max_active below the current count stops no run; it only refuses new ones. Per-request output limits have fixed schema ceilings (§3) and are not configurable. Pane scrollback depth is the existing Vt setting.

13. Conformance matrix

COM-252 MUST pass each case against a real headless daemon with real PTYs and processes, through both the CLI and MCP, comparing the two results for parity (identical records and error objects, CLI exit equal to status for run.wait). A marker file is written by the command each time it starts (echo >> "$F") so the test counts executions independently of the daemon. “No side effects” means no new run, session, window, pane, principal, PTY, child, key binding or event.

# Setup Action Expected
1 — Run ["true"]; wait succeeded, exit_code 0, status 0; CLI exit 0
2 — Run --shell 'exit 3'; ["false"]; --shell 'nosuchcmd' failed with 3, 1, 127; CLI exits 3, 1, 127
3 — Run a missing program; a non-executable file; a script without #! unavailable.spawn (exit 6); no side effects; key reusable; marker never written
4 — Run --shell 'kill -SEGV $$' signaled, signal SIGSEGV, no exit_code, status 139
5 — Run ["sleep","100"] with --deadline-ms 300; one that traps HUP and exits 0; one that ignores HUP terminated, cause deadline, status 124 for all; signal SIGHUP; exit_code 0 for the second but still not succeeded; SIGKILL for the third after 2000 ms
6 Hung run run wait --timeout-ms 100; --timeout-ms 0; cancel a wait; close a waiting connection timeout.wait (exit 7) twice; cancelled; run still running after each; marker written once
7 Hung run run.cancel with SIGTERM; again after completion terminated, cause cancel; second call returns the identical record and sends no signal
8 Run trapping every signal in uninterruptible-sleep seam run.cancel --grace-ms 0 timeout.terminate; run stays terminating; completes with cause cancel when the seam releases
9 Hung run pane.kill its pane; recursive window.remove; daemon shutdown cause pane_kill, removal, shutdown; waiters get the record (shutdown best effort)
10 — Command prints OSC 133 C, D;0, OSC 7501 state=done, the text of a success record, then sleeps and exits 3 command.finished with 0 and program.status_changed observed; run running until exit; failed, 3; no run.completed before the reap
11 — Command prints OSC 133 D;1 then exits 0 succeeded
12 — seq 1 100000 with 1000-row scrollback; default limits output holds the last 100 non-blank rows, complete false, oldest > 0; pane.capture_rows --end <start> returns the rows before; reply within limits
13 Rows of 3-byte characters --output-bytes equal to 2.5 rows; --output-rows 0; trailing blank screen Exactly 2 lines; no lines; blank rows excluded from end
14 Connection max_frame 4096 run.get of case 12’s run unsupported.result_size with bytes; --output=false succeeds
15 — Drop the connection after sending run (before and after the barrier seam); repeat the identical request Before: nothing ran, the repeat starts it; after: repeat returns reused: true, same run; marker written exactly once in both
16 Run with key K Same K and params from a second connection while preparing and after completion; K with different argv; K from another principal Joined / reused: true; conflict.run_key; a new independent run
17 Run with key K Restart the daemon; repeat with --source <old> conflict.source with the new source; marker unchanged
18 Key bad key, 129-byte key run invalid_params.key; no side effects
19 — Neither/both of argv and shell; neither/both of session and pane; axis with session; shell with NUL invalid_params.command / .target with the reasons in §10; no side effects
20 Caller without spawn; with spawn self on another’s pane run at an existing and a missing target, also with invalid params denied.spawn, identical for all; no side effects; audit record per RFC-0005 §10
21 Caller with spawn grants beyond its own denied.delegation; no side effects
22 Owner’s run r0; caller with read pane:*; with read session:s0; the run’s own pane principal; caller with only read run:r0 run.get, run.list, run.cancel, events denied.read / filtered; allowed; allowed incl. cancel of itself; read allowed, cancel denied.write
23 max_active 4 50 concurrent run requests with distinct keys 4 succeed until one completes; others unavailable.run_limit with no side effects; each run’s record, pane and output independent
24 retain_count 2, then retain_ms 100, then retain_bytes small Complete several runs Oldest evicted first with run.expired; not_found.run expired for * holders, denied.read for pane-scoped callers; key reusable after expiry; running records never evicted; run.list --limit 2 pages through all retained records via next
25 Subscriber from before the run, and a fresh snapshot Start, cancel, complete, remove the pane, expire §9 order in one turn each; snapshot has the run entity until expiry; pane removal leaves run.* events readable under frozen grants
26 Command double-forks a writer that outlives the leader Wait Completes at leader reap; descendant output after EXIT not in output; doctor counts the escapee
27 Run in a split pane; human attaches; pane.send to it Interactive prompt answered by pane.send Run completes from the leader’s exit; attach shows the same bytes
28 Canary in argv, shell, key, output, and (with RFC-0013) env/cwd All cases, plus doctor, events, audit and daemon logs Canaries only in results that return them (key, output); never in events, doctor, logs or audit. A canary in argv elements other than argv[0], or in argv[0]’s directory part, never appears in events. A canary as argv[0]’s basename appears only as argv0 (RFC-0007 §5) in pane.list, pane.created and the pane entity. A shell canary never appears, and that run’s argv0 is sh
29 Existing RFC-0007, RFC-0012, RFC-0013, RFC-0014 suites Rerun Unchanged

Agent-contract impact

  • Verbs/schema: five verbs (§3) in RFC-0004 format 1, no generator change. run.wait uses the existing result exit policy. CLI, MCP, docs and completions are generated. No local dispatch, no streaming.
  • JSON/exits: standard, plus run.wait’s status passthrough (§6). New errors in §10. Exec failure stays unavailable.spawn (exit 6), as for window.create; RFC-0002 §7.2’s failed example for a launch failure was illustrative.
  • Events: run.started, run.terminating, run.completed, run.expired and the run snapshot entity (§9). Existing events are unchanged.
  • Skill: the generated skill MUST say: start with run --key <unique> and wait with run wait, retrying either on a lost response with the identical request; use argv and add --shell only when shell syntax is needed; a wait timeout or Ctrl-C does not stop the command, use run cancel or --deadline-ms; trust outcome, never screen text or command.* events; status 124/125 means terminated; read more output with pane capture-rows; remove finished panes with pane remove; pass --source to detect a daemon restart. It SHOULD tell agents to prefer run over pane send for commands whose result matters.
  • Doctor: add run.active (count and max_active, warn at 90%), run.retained (count, bytes, evictions by limit), run.stuck (warn for runs terminating past their kill-wait), outcome and cause counters, key bindings, and joined/reused starts. No argv, shell text, keys, output, env or cwd.
  • Config: the run section in §12, through shared ZON verbs; reload never stops a run.
  • Grants: spawn to start, read to read results, write to cancel, on the new run:<id> resource contained in its pane (§11). No new right.
  • Headless/OSC: fully headless. OSC 133/7501 from a run’s program are observed as for any pane and never establish a run’s identity, success or completion (§5).

Alternatives

  • pane.send plus OSC 133 completion: the program controls the markers, a shell without integration emits none, and RFC-0003 §8 forbids trusting them. Rejected.
  • Run in an existing pane or shell: the daemon would have no handle on the command’s process, only on the shell’s. Wait status would be the shell’s. Rejected.
  • A pipe-based child with no PTY (separate stdout/stderr, raw bytes): attractive for agents, but it adds a second process class outside the pane tree that humans cannot watch, with its own grants and retention. Deferred (Open question 1).
  • One run --wait request: a lost response loses the run ID, and a wait timeout would have to mean either “kill” or “you no longer know the ID”. Separate start and wait, joined by the key, make every step retryable.
  • Optional key: easier for humans, but a keyless retry silently runs the command twice. A required short key costs one flag.
  • Streamed output items: MCP buffers streams to 1024 items and 1 MiB (RFC-0004 §5), screen rows still change, and long builds exceed both. Attach and pane.capture_rows already serve live output.
  • Report nonzero exit as a failed error: scripts get exit 1 for every failure, and MCP loses the structured result (RFC-0004 §5 omits structuredContent on error). A successful result with an exit policy keeps both.
  • status = 128 + signal for terminated runs: a program that traps the signal and exits 0 would need a special case anyway; fixed 124/125 follow timeout(1) and never yield 0.
  • Automatic pane removal on completion: keeps the tree tidy but destroys the history that complete: false points at. Deferred (Open question 2).
  • Do nothing: every orchestrator reimplements run/wait around screen scraping, with forged success and duplicate side effects.

Open questions

  1. Pipe mode and raw output. Deferred to COM-271. Separate stdout and stderr, raw bytes and a non-PTY child. Until then a run’s output is its pane’s rendered rows (§4).
  2. Removing a run’s pane on completion. Deferred to COM-272. Until then callers remove finished panes with pane.remove, or run in a dedicated window and remove it recursively.
  3. Key required. Resolved: required (§8), for the reasons under Alternatives.
  4. status mapping. Resolved: §6’s table (0 only for succeeded; 124 deadline, 125 other terminations, 128+n signals).
  5. Default grants of a run’s pane. Resolved: unchanged RFC-0005 §6.2 rules, as for window.create; callers that want a narrower child pass grants (for example []).
  6. Limits. Resolved: §3’s ceilings (1000 rows, 256 KiB of output, 24 h deadline and wait) and §12’s defaults.

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 trusted run/wait execution that RFC-0007’s Alternatives deferred.