zirvDocs

DocsSupervision (zirv ctx)

Sending mail between sessions

README.md, lines 1054–1217View source

Agent sessions running on the same machine can leave each other short notes, scoped to the current repository, with zirv ctx send and zirv ctx inbox:

bash
zirv ctx send --message "the webhook route moved to /v2/webhook"
zirv ctx inbox

zirv ctx status reports how many are waiting (mail: N unread). A mail message is free-form text written by whichever agent session sent it, not an operator instruction — see Trust boundary for how it's capped and labeled the same way the other untrusted surfaces are.

Add --to-session <prefix> to address one specific live session instead of every session an agent has: zirv ctx send --to-session abcd1234 --message "..." resolves abcd1234 (a short id, or a unique prefix of one — see Session registry and nudging below) against the live registry and stores the full resolved id, so the message keeps finding its target even if the registry record itself is gone by the time it is read. A session-addressed message is only ever delivered into a headless session's own launch prompt (exec/loop, when [mail] enabled = true); an interactive session (chat/wrap) only ever gets a one-line unread-count advisory on its status bar or event channel, split as broadcast+direct once something is addressed to it specifically — never the message body itself, the same "advisory, not authority" rule zirv ctx nudge follows below. There is no environment variable for --to-session: unlike the config knobs elsewhere in this document, session addressing is a per-invocation argument, not something an operator or a repo would want to pin as a default.

Mid-turn delivery (Claude only, off by default). With [mail] mid_turn = true (ZIRV_CTX_MAIL_MID_TURN, operator-only), Claude's PostToolUse hook returns mail addressed to this exact session (--to-session, including root-level mail filed in the mailbox it registered from) as additionalContext, merged beside any compaction or checkpoint note. Each message is framed as agent-authored (sender named, no user authority, information not instruction), rendered with the same envelope zirv ctx inbox prints, and moved to read/ as it is delivered, so the idle-boundary advisory and later hooks never repeat it. At most 4 KiB is injected per call; a message that does not fit stays unread and is named once by a N more: zirv ctx inbox pointer. With the key off the hook does no mail work; with it on and no ZIRV_CTX_SESSION the hook does none either, and with an empty inbox the output is byte-identical to the key-off output. Codex is unchanged.

A body over [mail] max_message_bytes/max_delivered_bytes is still stored, truncated, rather than failing the send — but the cut text is no longer just lost. The full original body is written to a sidecar file under the mailbox's own full/ subdirectory (capped at 1 MiB of its own, with a trailing [truncated] marker if even that is exceeded), and the stored message's own [truncated] marker becomes [truncated; full body: <path>], naming that sidecar. full/ is a dedicated directory nothing else ever moves a message into or out of — unlike the message itself, a sidecar is never relocated into read/ on consume or into a dead-letter directory once its TTL expires, so a marker's path always stays valid for as long as the sidecar exists. It is pruned to the same [mail] keep as the mailbox itself, in its own pass, so it does not accumulate forever either.

A delegated worker's own final report is persisted before any report-back mail is sent, at <state>/logs/delegation-results/<session>.json (capped at 1 MiB, with a report_truncated flag when cut) — whether or not the delegation declared a --result-schema/--result-kind contract. The report-back mail for a contract-declared delegation names that file as a trailing full report: <path> line, and zirv ctx agent --json's own receipt carries it as result_path (see Delegation receipt above).

Session registry and nudging#

Every supervised session (wrap, exec, loop, chat) registers itself under the state dir at <state>/sessions/<short8>.json for as long as it is alive — best effort, released when the supervisor exits, and swept automatically the moment zirv ctx status (or anything else that reads the registry) notices its process is gone. zirv ctx status reports it under sessions:, one line per record:

text
sessions:
  abcdef12  claude  exec  pid 48213  3m  live         -work-my-repo
  9a8b7c6d  claude  wrap  pid 19042  40m  stale        -work-my-repo
  1f2e3d4c  claude  wrap  pid 51120  5m  unreachable  -work-other-repo

unreachable means the process is running but bound no turn-signal socket (--no-supervise, or the socket failed to bind), so it never checks for wake-ups: zirv ctx nudge refuses such a target and says so, while zirv ctx send still leaves a message for its next run.

<short> is the same eight-character id --to-session and zirv ctx nudge resolve a prefix against. A socket left behind by an older zirv binary that predates the registry (s/*.sock with no matching JSON record) still shows up, labeled (no record), so a mixed-version machine never silently drops a live session from the listing.

Addressing a parked seat (see "the displaced harness is parked, not closed" below) whose own supervisor process has since exited -- a "ghost park" -- is recognized instead of reported as a plain unknown id. If its park window has already elapsed, send/nudge resume it in place before delivering, clearing the park without ever opening a handover onto a different harness -- that stays wrap's/dash's own supervision loop, never a short-lived send/nudge invocation's; if the window has not yet elapsed, the mail is queued as usual and the reply names the park and its reason instead of a bare not-found. Either way mail never resumes a park early on its own -- only a window that has already elapsed does. A ghost-parked seat's own repository cannot be recovered from its seat record alone, so its mail files under the sender's own repo mailbox, mirroring the undirected --claim-once fallback.

zirv ctx nudge <prefix> --message <text> wakes a live session early instead of waiting for it to notice on its own:

bash
zirv ctx nudge abcd --message "please check the new failing test"

A nudge prefix must be at least four characters (or a session's whole short id) — unlike --to-session, which only addresses a message, a nudge wakes and can restart what it resolves to, and on a machine running one session a single mistyped character is still "unique". A shorter prefix is refused and the live sessions are named back to you.

The message itself is ordinary, durable mail (visible in zirv ctx inbox even if the wake-up is missed), so the two pieces are decoupled on purpose: a nudge is a wake-up signal plus a payload, stored separately, and losing the wake-up never loses the message. For a headless session (exec), a nudge costs the in-flight turn — the session is stopped and relaunched with a handoff distilled from the transcript so far, the same recovery path a rot restart uses, just triggered by an operator instead of the rot engine. That restart is bounded by [supervise] max_nudges (default 3, ZIRV_CTX_MAX_NUDGES) so a session cannot be interrupted indefinitely; past the cap a nudge's message is still queued as mail but the session runs on untouched. The cap counts consecutive nudges — it resets as soon as the session reports a turn of its own, so a long-running session that keeps making progress can keep being steered. For an interactive session (wrap or chat), a nudge is advisory only: it never restarts or types anything into the agent, and it never receives the message body — it just surfaces on the status bar and event channel that a nudge arrived, pointing at zirv ctx inbox. zirv ctx nudge says so at send time too when the target it resolved is interactive, so an advisory delivery never looks like a nudge that silently did nothing. Either way, latency is bounded by [supervise] poll_ms (default 2000ms) — the interval a supervisor's own tick already runs on, since a nudge just claims a marker file that same tick checks for.

A zirv ctx chat session (bare zirv included) with a real, large-enough terminal attached also gets a bit of chrome:

  • a one-time launch banner naming the resolved harness, the rule that chose it, and the session id;
  • a reserved one-row status bar pinned to the bottom of the terminal;
  • an event channel on stderr, one line per notable event, in the shape [HH:MM:SS] zirv ▸ <message>.

All three degrade together and only in one direction: --simple, --no-supervise, a terminal narrower than 40 columns or shorter than 8 rows, or a non-terminal stdout turns every piece off, and nothing here ever upgrades a session mid-run. Turn just the event channel off with --quiet, the ZIRV_CTX_QUIET environment variable, or [chrome] events = false in ctx.toml; [chrome] banner and [chrome] bar switch the other two off the same way. See .settings.toml below for enabling or disabling the harnesses themselves (claude, codex) — a separate file from [chrome].