sot-comm protocol — v1
Session-to-session messaging for Ship of Tools: a durable inbox per handle, so sessions can address each other across capsule rows and across machines.
This file is the contract. Every client, the Claude and Codex adapters that ship and any other, implements this, so all clients are mutually addressable through the same registry and inboxes.
Layout (runtime, under $SOT_COMM_HOME, default ~/.sot-comm)
~/.sot-comm/
bin/ # installed scripts (the reference client)
VERSION # the installer's commit stamp
registry.json # who is reachable + liveness (source of truth for discovery)
registry.json.tmp # a registry write's temp file, renamed over registry.json
registry.json.new.<pid>.<n> # the skeleton ensure_home links into place when there is no registry
.registry.lock # the registry-write lock: a file naming its holder (below)
.registry.lock.reclaim.<id> # one marker per holder a waiter proved dead; kept, except a daemon's own
# (it removes that one when its reclaim finishes)
.registry.lock.tmp.<id> # a take's temp file, removed by the take
inbox-lock-manager # the hub daemon's record of the inbox lock's mount, written at boot
.inbox-lock-manager.<id>.<pid>.<n> # that record's temp file
gh-device-auth.json # sot-gh-auth.sh's device-flow state
inbox/<name>.jsonl # durable per-recipient inbox (append-only)
inbox/<name>.lock # that inbox's lock file
read/<name>.cursor # per-recipient read cursor (`<count> <crc>-<len>`: lines shown, and a hash of the last)
self/<host>__<pane>.txt # a row's identity pin; <pane> is the row id, or `nopane` outside a row
state/ # per-session scratch; the end-of-turn check keeps mail-<key>.tick,
# lock-fault-<handle>.<key>.tick and stop-feedback-<key>.jsonl here,
# and comm-status.sh keeps turn-<self slot> while a turn runs.
# Nothing removes a mail tick, so every session ever held on mail
# leaves one. Bounded per-session litter, and nothing sweeps it.
probe/<handle>/ # a probe row's project root (comm-probe.sh)Every entry above except bin/ and VERSION (the installer's) is 0700 (a folder) or 0600 (a file), created so by every writer; ensure_home closes an older folder's entries at the next join. Anything else in the folder keeps its mode behind the 0700 folder. On Windows the folder inherits the profile's access list.
The registry and inboxes are data at rest — discovery and catch-up need a shared place to publish, not a live broker. In an optional shared-home deployment, one ~/.sot-comm serves every host sharing that home.
registry.json
{
"protocol_version": 1,
"agents": {
"<name>": {
"host": "<host>", // the daemon's declared host name
"workspace_id": "ws-…", // the row this session runs in (SOT_WORKSPACE_ID); "" in a bare shell
"repo": "Ship of Tools",
"root": "/path/to/project", // the project root comm-join.sh joined from; handle collisions compare it
"expertise": ["files", "rust-backend"],
"status": "idle", // lifecycle: idle | spawning
"joined": "2026-05-29T18:00:00Z",
"last_seen": "2026-05-29T18:04:00Z", // heartbeat, bumped on send/poll/join
"state": "working", // WORK state, derived from the facts below: working | idle | blocked | waiting | done
"summary": "rebuilding backend + BE suite",// one line derived from the facts ("" when none)
"status_at": "2026-06-15T19:32:13Z", // when the facts were last written; nav ages a stale "working"
"floor": "user", // present while a turn runs: who started it, user | machine
"dialog": "Allow Bash?", // present while the agent shows a dialog that waits on a person
"question": "which branch?", // present while a question to the user is open
"waiting": "for CI", // present while the session waits on something
"done": true, // present when the last user-started turn finished with nothing pending
"note": "rebuilding backend + BE suite",// the last working/idle/done line
"stop_at": "2026-06-15T19:32:13Z" // present while the Stop hook runs (see Waking)
}
}
}Registry host facts and unpinned self-slot keys use the daemon's declared host. Raw host text is a derived-handle component and validated legacy-slot input only; explicit identity pins retain their text.
Liveness is one fact, last_seen: a handle is live when its last_seen is a YYYY-MM-DDTHH:MM:SSZ stamp under 600 seconds old (COMM_LIVE_SECS, LIVE_SECS). The session stamps it (join, send, poll, status, and each turn's heartbeat), and the daemon that runs a Starting or Ready row stamps that row's handle every minute (liveness.rs), so an idle row stays live while it runs, through a daemon restart, and until ten minutes after its last stamp. A daemon that cannot take the registry lock, or read or write the registry, stamps nothing. Its warning says so each minute, and ten minutes after a row's last stamp a send to that row fails as no live session holds @h; the lock rules are below, and comm-registry-lock-clear.sh clears a lock whose holder this box proves dead. An age is read against the reader's clock, so skew between two boxes moves the ten minutes by the skew. host + workspace_id are the address a same-host daemon resolves.
Work-state powers the ADE state-nav at-a-glance view and is distinct from the lifecycle status above. A row keeps facts: floor, dialog, question, waiting, done and note. state and summary are recomputed from them on every script write, by priority (comm/work_state/comm-status.sh):
| Facts | state | summary |
|---|---|---|
dialog set | blocked | dialog |
question set, no floor | blocked | question |
floor set | working | note |
waiting set | waiting | waiting |
done set | done | note |
| none | idle | note |
comm-status.sh <verb> ["text"] writes the facts, merge-only, and stamps status_at and last_seen. Five verbs are events: prompt (a turn starts; sets floor, and a user prompt clears question and done), stop (a turn ends; sets done when the user started the turn and nothing is pending, then clears floor and stop_at), dialog "T" (the agent shows a dialog that waits on a person; sets dialog, which reads red over the running turn), resumed (the dialog is gone; clears dialog, as prompt and stop also do) and join (comm-join.sh sends it). A Codex session's PermissionRequest hook sends dialog and the heartbeat sends resumed. A Claude Code session sends no event when a dialog shows, so the daemon reads the screen of each working Claude row every 5 seconds (not on Windows) and sends both, through SOT_COMM_ROW_HANDLE, which takes only these two verbs and only from a process whose parent is sotd. Five are declarations, the session's own word: working, idle, blocked "q", waiting "w" and done. The hooks Claude Code runs are UserPromptSubmit (prompt), PreToolUse on AskUserQuestion (blocked), Stop (stop) and a PostToolUse heartbeat that re-stamps status_at. A client that writes state or summary directly is overwritten at the next script write. In a session that is not a joined comm agent, an event is a silent no-op; a declaration fails with rc 1 and comm-status.sh: no registry row for ... stamp discarded. The daemon's one work-state write of its own is clearing done when a person views the row (clear_comm_unread); a Claude row's dialog and resumed it sends through comm-status.sh. The daemon joins state, summary and status_at onto workspace.list as agent_state / agent_summary / agent_status_at, keyed by the row's handle (the one its session declared, else the one in its pinned self file, else the row's stored agent_name; comm_handle_for_workspace), so the frontend, which cannot read this registry directly, renders summary as the per-session glance, colored by state and aged off status_at.
One writer, one reader. Every script write goes through registry_replace (comm-lib-registry.sh), under the registry lock (below). jq writes a tmp, and the tmp is renamed over registry.json only if it is one JSON document with an object .agents and its data has been flushed to the server; otherwise nothing is written and the writer prints FAILED: the registry could not be read or updated, so nothing was written (or FAILED: the registry update could not be flushed (…), so nothing was written, or … could not be renamed into place (…) …). The heartbeat hook calls the same registry_replace, under the same lock with one try, and stays silent; the daemon flushes its tmp before its rename too. perl is required for registry writes: the flush is perl's sync, and a host without perl fails closed with the "could not be flushed" line. ensure_home creates the registry only when there is no file: it flushes a skeleton tmp and publishes it by a hard link, which fails on an existing name, so it never replaces, truncates or repairs a registry.
Every read goes through sot_registry_read (the hooks source the library in a subshell for it; only the Stop hook's joined-agent test on a host without jq greps sot_registry_bytes instead), which has three answers: present, absent (it parsed; no such row) and unreadable (missing, empty, not JSON, not exactly one document, or no object .agents; or jq is missing, which it says on stderr).
Unreadable is never absent. Every read, and every writer's read under the lock, takes its bytes from sot_registry_bytes (every daemon read from its Rust twin, read_registry_fresh, but its 1.5 s change poll, where a failed read skips one tick), because an NFSv4 client can get ESTALE (stale file handle) on a read after its open succeeded, when another host renames a new registry over the file. So a read that failed (any error but a missing file, even after some bytes, which are discarded) or that returned zero bytes revalidates the folder and opens the file by path again, up to 3 times in about 200 ms; no good read by then is unreadable. A missing file is told at the open, never by a stat. Non-empty bytes that do not parse are never retried.
A send on an unreadable registry prints FAILED -> @<to>: the registry could not be read, so identity @<me> is unverified; nothing was sent. leave, list, spawn and despawn print FAILED: the registry could not be read; nothing was <removed|listed|spawned|despawned>, status and worktree-sync end the same line with stamp discarded and nobody was reminded, and a join prints the writer's FAILED line or the registry could not be read, so no handle was derived; nothing was written. Each exits 1.
A registry that stays unreadable is not repaired automatically, because rewriting an unreadable file is the wipe. The fix is by hand: move registry.json aside; the next script creates an empty one, and each session's next send says reclaim it with comm-join.sh --name <handle>.
The registry lock
Every write to registry.json (the scripts' with_lock, the daemon's with_comm_registry_lock) holds .registry.lock, a regular file whose one line names its holder: name:machine:boot:pidns:pid:start — the host name, Linux /etc/machine-id, the kernel's boot_id, the pid namespace, the pid, and that pid's start tick (field 22 of /proc/<pid>/stat); on macOS the host UUID, the boot session UUID, darwin, the pid and its start in whole seconds since the epoch. A field that cannot be read is -, which never equals anything; on other systems, and where the comm home's mount proves no fresh read (Proof of death), the last four proof fields are -. In file names ':' is written '.', because Windows reads a ':' in a name as a stream; the record keeps its colons.
Take. Write the record to a temp file and
link(2)it to the lock (the commandlink, neverln, which links INTO an older peer's lock directory and "succeeds"). The lock never exists without its holder, and a link never replaces anything. A failed link after which the caller's fresh temp file is the lock was taken (a retransmitted NFSv3 LINK answers "exists" for the caller's own link). Release removes the file. The comm home must support hard links: every filesystem listed under Reclaim does, and so does NTFS; on FAT every write fails (the best-effort touches silently), and a ruled one FAILs naming "cannot hard-link in <dir>: <errno>".Proof of death (Linux and macOS, and only from the holder's own machine): the same boot and pid namespace, with the pid gone or its start tick changed; or the same machine-id AND the same host name with a different boot. This assumes a machine-id is unique to one machine: a cloned image that shares one would read a live clone as rebooted. A box proves a death only where its comm home's mount, the one on top where mounts are stacked, makes a read fresh: ext2, ext3, ext4, xfs, btrfs, zfs, f2fs or tmpfs, or nfs or nfs4 without
nocto, or on macOS the Mac's own disk (MNT_LOCAL); on any other (overlayfs, a Mac's network home) its records have no proof fields, so its dead holders are removed by hand. No timeout proves anything, so a frozen holder, one on another machine, or any unprovable one, is never forced.Reclaim. It runs on the FIRST failed take, before any sleep. A waiter that proves the holder D dead takes the marker
.registry.lock.reclaim.<D>by the same link step, settles 1 s, reads the lock again fresh (open its folder, then open the record), and removes it only if it still names D. Only the marker's creator acts; if the creator died too, the next waiter proves that and takesreclaim.<creator>, which carries the same authority. A lock naming the daemon itself, seen by the daemon thread with the turn (below), was left by one of its threads, and is reclaimed like a dead holder's, through a marker naming the daemon. Markers are kept forever, but for that one: the daemon removes it when that reclaim removes the lock or finds it gone after the settle, and every record its walk passed is its own. An ID is the process's own only with its proof fields:-never equals anything, so a daemon or script with none never adopts a marker or reclaims a lock as its own. A marker naming a record the walk has already passed, other than the waiter's own, ends it: no reclaim can pass that marker, and FAILED says to remove the lock by hand. A removed lock is retaken at once, as part of the try that removed it, even past the deadline below.Bounds are deadlines, polled every 50 ms (
SOT_LOCK_WAIT_SECSin the scripts): 10 s for join, leave, spawn, despawn, status and the daemon's workspace destroy; about 1 s for the best-effortlast_seentouches of send, poll and spawn, and for the daemon's unread clear; 0 for the heartbeat, which still makes its one try and reclaims a dead holder. There is always one try, and with a clock its reclaim step (in the daemon, once the thread has its turn); after the first failed take the deadline is checked after every failed take, every sleep and every marker a step walks past its first, so no reclaim chains past the deadline and no try starts after it. One daemon thread at a time is inside the lock, and its wait for that turn counts inside the same deadline. The scripts' clock is bash 5'sEPOCHREALTIME, or perl'sTime::HiResbefore bash 5 (the mail tools check it there); both are wall clocks, so a clock jump can stretch or shorten one wait. The daemon's is monotonic.FAILED names the holder and the recovery, in the scripts and in the daemon's log alike:
registry lock <path> is held by <host> pid <pid> start <tick> (<age> old): <why>. If it is dead, run any comm command on <host>.Where this box cannot prove it dead, on another machine or in another pid namespace, the last sentence is insteadIf it is dead: run any comm command on <host>, or, if <host> will not run again or a comm command there says it cannot prove a death, remove <path> by hand.(in a pid namespace:on <host> in its pid namespace, or, if nothing will run there again or ...): no reclaimer can be pending where nothing proves its death, and the clause stands under "If it is dead", as a live holder is never proved dead. A holder the step did not judge, one that took the lock during the reclaim, gets the first sentence. When the record that blocks has no proof fields, which no box can prove dead, its last sentence is insteadNo box can prove <host> pid <pid> dead (its record has no proof fields): if it is, remove <path> by hand.A lock found released since the last read says insteadregistry lock <path> was held by <host> pid <pid> start <tick> when last read (<why>); it may have been released since. Retry.With no clock the scripts sayregistry lock <path> is held, and there is no clock to wait by: <which>; a daemon thread that never got its turn saysregistry lock <path> was not tried: another thread of this daemon held its turn past the bound. A lock no reclaim can clear (a record read whole that does not parse, an older version's directory, or a marker naming a record its walk already passed) saysregistry lock <path> still held (<age> old): <why>. If its holder is dead, remove <path> by hand and retry.A wait that ends with no holder read, the lock released or taken again just then or its record not read, saysregistry lock <path> was not taken by the deadline: <why>. Retry.comm-registry-lock-clear.shtakes the reclaim path above at once, with no deadline, and clears only a lock whose holder this box proves dead: a holder it cannot prove dead may be alive, and clearing it would give the registry two writers. Its refusal names the recovery the FAILED line names. It is never a barerm, which would let a pending reclaimer remove the next holder's lock. With no lock it says the lock is free and exits 0. When no reclaim can clear the lock, its refusal says to remove the lock by hand.Older peers. An older version's lock is a holderless directory: it is never reclaimed, and FAILED says "held by an older version that records no holder". An older waiter's
mkdirfails on the file and waits as before.Residual. A dead holder on a server where nothing comm-related runs again wedges registry writes fleet-wide until someone removes the lock by hand: every other box FAILs each ruled lock after 10 s, and every send or poll waits out the ~1 s touch bound. The 1 s settle is the only guard against a dead holder's late operations, its orphaned release
rmand its in-flight calls; one that lands later than that is not covered. A daemon S killed after taking its own marker and before removing the lock (the settle and the re-read included), or whose re-read fails and that dies before another of its writes adopts the marker, leavesreclaim.<S>naming S while the lock names S; so does one whose removal of that marker fails and that later leaves its lock and dies. Every walk then stops at that marker, and the lock is removed by hand. A removal ofreclaim.<S>that failed for a live S but reaches the server later (NFS) can remove a later walker'sreclaim.<S>after S has died, so two walkers each act for one dead holder, and one can remove the lock the other retook; this is very improbable. A lock whose record stays unreadable (a permission or I/O error) is never reclaimed, and its FAILED line says only that its record could not be read; it is removed by hand. Where a box's two arms disagree about whether it proves a death (no findmnt or stacked mounts; on macOS, a shell whose PATH finds a date or ps that is not the system's, as Homebrew's coreutils gnubin puts first, so it writes no proof while the daemon does), the arm that proves a death can be mid-reclaim while a person, told by the other arm's line, removes the lock by hand; two writers need that removal and a new holder's link to land between the reclaimer's re-read and its unlink.
Message frame (inbox JSONL, one object per line)
{"from": "<name>", "to": "<name>|\"\"", "repo": "<repo>", "msg": "<text>", "ts": "2026-05-29T18:04:00Z"}ISO-8601 UTC timestamps sort lexically, but they are NOT the read cursor: the cursor is the NUMBER of inbox lines already shown, which cannot lose a frame filed in the same second as one already read. comm-poll.sh is the only writer; a legacy timestamp cursor is read as the number of lines before the first line whose ts is later than it; nothing is written until the next poll that shows a line. The cursor is <count> <crc>-<len>: the count, then the cksum of line <count> without its newline and NUL bytes — one hash, taken alike of the file's line and of the reader's copy. If that line no longer hashes so, a cut-back removed it, and the reader steps back one line and says so. A count-only cursor still works. The daemon's wake reads every form exactly as sot_cursor_offset does and never writes the cursor: a timestamp-form cursor means "read up to that time" until the next poll that shows a line. Only newline-terminated lines are ever counted.
to equal to the handle is directed mail and counts as unread; "" is a broadcast copy, filed and read at the next poll, never counted as unread. A line whose to is missing, not a string or another handle's, or whose from is this handle, is shown at the next poll and never counted. One rule counts: the wake's counts in Rust and its shell twin sot_unread, pinned by unread_agrees_with_the_shell. They agree on every line a product writer emits (jq -c, serde_json); a line that is not strict JSON (invalid UTF-8, a byte-order mark, NaN, a number out of range, nesting past 127) may be counted by the shell and not by the wake, so the hook holds one turn for mail comm-poll shows.
Delivery
This section is ADR 0049's design of record. The hub files for every handle its own comm folder lists; a box with its own comm folder (a Windows host) holds its own link to the hub, and its daemon files for that folder the requests the hub offers it over the link, the frontend playing no part.
Words. A row is a session running inside Ship of Tools. The daemon is the background program on each box that runs that box's rows; it keeps running when the window closes. The hub is the one daemon every box can reach.
Address. A handle is a session's repository or worktree folder name plus its box name; two boxes never share one. A row's session declares its handle to its daemon when it first starts; the daemon keeps it through restarts, compactions and clears, gives each handle to one row only, and a newer declaration moves it. Not built: nothing tells the row that lost the handle, whose self file still names it, so its scripts still act as that handle until it re-joins (ADR 0049 stage B6). A box whose daemon runs no rows holds no addresses.
Inbox. One file per handle, inbox/<handle>.jsonl, in the box's comm folder. The read cursor is a line count and a hash of its last line, kept in read/<handle>.cursor. Unread mail is any line past it addressed to this handle by someone else.
Sending picks one of two routes, by whether the sender's own comm folder lists the receiver:
It can reach the inbox (same box, or a box sharing the home): the line goes in under the inbox lock — the kernel's file lock (
flock) oninbox/<handle>.lock, taken by the daemon's filer and the scripts alike. The inbox is opened inside the lock and closed before it is released. "Filed" means kept: the line is flushed to disk before the answer, an unterminated last line (a dead writer's partial) is cut back to the last newline first, and logged, so the new one stays whole, and a failed append is cut back to the length before it (after that cut), taken by a seek to the end of the descriptor opened under the lock, never from a path's cached size. A lock excludes only writers that share one lock manager, so the folder's hub records its own ininbox-lock-manager, besideregistry.json, when it starts. Line 1 is the manager:nfs4 <server>:<export>(an NFS v4 mount withlocal_lock=none),local <machine-id>, or, on NFSv3 or any other mount whose lock is unknown,none@<machine-id>— that one machine's own lock, so the record binds the folder to the machine that wrote it (barenone, with no machine id, never matches). A non-Linux daemon isnone@<its machine id>on every mount. Line 2 is the writing machine's id (Linux/etc/machine-id, macOS its host UUID, WindowsMachineGuid; never a hostname, which two machines can share); a record without it has an unknown writer. Every route compares line 1 only. The hub is the daemon with no topology, the topology's hub (an unreadablehosts.tomlnames no hub, so that daemon is a guest unless its folder is on its own disk), or a daemon whose comm folder is on its own disk, asked of the folder on each OS (Linux:local <machine-id>; macOS: its mount isMNT_LOCAL; Windows: a fixed drive, not a UNC path; anything else, or an error: not own disk); every other daemon is a guest on the hub's folder and never writes or deletes the record. The hub makes it by an exclusive create when it is absent (of two daemons starting at once only one writes it; the other reads the winner's), leaves it alone when line 1 already names the hub's own manager, and replaces it only when line 2 is the hub's own machine id (the hub after a remount). A record another machine wrote names a different lock manager: the hub leaves it untouched and logs it as an error, and only an explicit reset with no other daemon running replaces it. A hub with no machine id (barenone) never creates or replaces the record and refuses every filing, saying so: give the machine a machine id (/etc/machine-idon Linux), then restart the daemon. A v3 hub writesnone@<its machine>and serves other hosts over the wire. A script adds the line itself only whenflock(1)andperlexist (perl makes the append, its fsync and its cut-back on one descriptor), the box is Linux, andfindmnt -Ton the inbox names that same manager — under anone@…record, only a script on the record's own machine. Anything else (another machine'snone@…, a mismatched export, another box mounting the daemon's local disk, no record, a barenone, noflock(1)orperl) hands the line to the daemon that owns the comm folder ascomm.file: this box's own daemon, else the relay endpoint (the hub). A daemon that does not answer isFAILEDand no second route is tried; one older than the record answersunknown op: comm.file. Every daemon rechecks its own manager against line 1 at each filing: a match appends (never on barenone); a guest forwards anything else to the hub, once, markedforwarded, and answers with the hub's own verdict; the hub refuses it asfile_failedwith the recovery named — restart the hub when its own machine wrote the record (a remount), else stop every daemon on the folder, delete the record and start the hub.It cannot:
comm-relay.sh send @h(whichcomm-send.shexecs on a registry miss) writes ONEcomm.filerequest to the relay endpoint, the hub, and reads ONE answer. The hub files for its own home: when its comm folder lists the handle and a session holds it, it adds the line under the same inbox lock and answersok. A handle its folder does not list (not_here) it offers, as the samecomm.filerequest markedforwarded, to every daemon linked to it (a hello of rolelink), all at once, and answers with theirs: the firstok; else the first refusal that is notnot_here, that box's own words; elsethe daemon on <host> did not answer within <n>s; @<h> may still be filedfor a link that gave no answer in time; elseno box knows that handle: <h>, or, when a box that should hold a link to the hub (a frontend box other than the hub, byhosts.toml) holds none,no linked box knows that handle: <h>; not linked: <boxes>. A linked daemon answersnot_hereunless its own folder lists the handle under its own box; only then does it file the offer by the rules of route 1, and it never offers or forwards it again. It files each offer as it comes, so one held lock delays no other answer. A request that arrivedforwardedis not offered.
The wait is chosen by lock kind, and bounded at 2 s (SOT_INBOX_LOCK_WAIT_SECS) on both paths. The Linux NFSv4 client retries a blocked lock with a backoff that doubles from 100 ms, so a local writer re-takes the lock before a remote waiter's next retry and a blocking waiter can sleep past a free lock. Under nfs4 … the daemon's filer and the scripts therefore try the lock without blocking every 15-25 ms (jittered) until the bound; under local … or none@… (NLM on v3, one machine's own kernel lock) a blocked waiter is woken on release, so they block, bounded. Past the bound nothing is appended and the answer is FAILED -> @<h>: the inbox lock for @<h> was held for 2s — nothing was appended. The reader's shared lock takes the same choice for its 3 s bound.
Readers have two guards against a line that a failed append then cuts back. Where a writer would append locally (flock(1) and perl, Linux, identity equal to line 1), the count-and-read runs under a shared lock on inbox/<handle>.lock, bounded by SOT_INBOX_READ_WAIT_SECS (default 3). Every inbox lock descriptor, a reader's or a writer's, is opened read-write: the Linux NFS client refuses a shared lock on one without read access. comm-poll.sh reads its batch once under the lock, lets go, shows the batch, and then writes the cursor from the bytes of the last line it read, never from the file again, so a slow display never holds off a writer. A timeout means try again, never a skip: comm-poll.sh says the inbox is being written, leaves the cursor and exits 75, the end-of-turn hook prints that the inbox was busy and does not block the turn, and a wake reader checks again on its next tick. The end-of-turn hook counts only with sot_unread, and only when jq is on PATH (without jq its tool block is its one word on it); a count that fails with jq present is named like a lock fault (WARNING: the unread count for @h failed (…) — run comm-poll.sh to read your mail), never read as 0. Only a held lock is "try again": any other lock fault, a lock file that cannot be opened or any flock error, is named (flock's code and its own error text) where the session sees it (comm-poll's output, every block of the end-of-turn hook) and the read runs unlocked, covered by the hashed cursor. On every host the hashed cursor steps back one line when a cut-back removed the last line read. The accepted residual: a reader on a mismatched host may deliver a line whose sender was told FAILED, so a retry can duplicate it; it can never lose one.
Every route checks liveness before it appends, by the one rule above: a script that reads the receiver's entry refuses a handle that is not live itself, and the daemon's filer refuses it the same way, so filed is never printed for a handle no live session holds. The shell's sot_heartbeat_fresh and the filer's heartbeat_fresh are one rule, pinned by heartbeat_agrees_with_the_shell.
The one result, nothing else:
filed -> @h(exit 0; a script's own append prints it indented).FAILED -> @h: <reason>(exit 1), the reason being the daemon's own sentence whenever there is one:no box knows that handle: <h>,no live session holds @h,not a handle: …, a failed append; the inbox lock held for 2 seconds (the inbox lock for @h was held for 2s — nothing was appended); a daemon older than the op (unknown op: comm.file); a daemon that refused this client's hello and answered nothing else (hello refused: <the daemon's own words>: this host has said hello as two OS accounts, or the two sides speak different protocols; a daemon older than this release refuses only for the protocol and still answers the request, and that answer decides); or no answer at all (the daemon did not answer at <endpoint>, followed by the transport's own stderr when it wrote any). A refusal means nothing was added; with no answer nothing is known to have been. Retrying or reporting is the sender's call.
The answer decides, whatever the transport's exit status or stderr say: the transport's stderr is read only when no answer came, and then only lengthens the reason. No answer within 5 seconds is FAILED (decision 0031): the read window is the lock wait plus 3 seconds, 5 at the default 2 s wait, and a daemon gives a guest's forward or an offer to a linked daemon the lock wait plus 1 second, so an appender that waited out the lock and then filed is not reported FAILED.
With no daemon found at all a wire send prints FAILED -> @h: no sotd daemon found; … and exits 1. agent.send is not mail: it carries the frontend's UI notifications (sot-nav.sh's nav envelope, the ROI echo) with an empty to, published to every connection as agent.message; one that names a handle is refused (mail_requires_comm_file) and nothing is published.
Nothing is queued anywhere, and a failed send is not retried by another route. To get an answer, send, end the turn, and be woken.
Waking. Every two seconds each daemon looks at every row it runs. If the row's handle has unread mail and the row sits at a free prompt — the cursor sitting at the start of the input line marked by the prompt glyph (❯, or on Windows ❯ or >, the latter being Claude Code's fallback when its unicode check fails), followed by a no-break space, between the input box's two rules (on every OS; on Windows a bare glyph too, for now), so a gray suggestion (drawn dim) or any other dim decoration does not count as a draft, while a draft is not free wherever its cursor sits, and the screen down to the input box's lower rule (the background-agent footer below it is not watched) must then hold still for a second and a half before anything is typed, so a working session is not typed into while its screen is still changing — the daemon types one fixed line, [sot-comm] you have mail: run comm-poll.sh, and Enter. The daemon does this, not the frontend or the sender — several frontends can show one row and each would type, and a closed window would leave the row deaf. It types a fixed notice, never the message itself: a pasted message is never marked read, so it would show again. One line per new batch of mail; one more if it is still unread ten minutes later at a free prompt. A line it typed that did not send is sent by a later tick, with Enter alone, once main's input box holds just that line, whether or not the mail has been read by then (the session then finds nothing new); a box that holds anything else is left as it is. Within one daemon run the line is never typed twice; a restart forgets it, so a restarted daemon looks once at each row it may type into and sends Enter alone if main's input box holds exactly the wake line, whether or not its mail is unread; any other box is left as it is. A row whose registry entry carries a stop_at under a minute old is running its Stop hook and is not typed into: the hook stamps it as it starts, its stop deletes it when the turn ends, and the hook reads the mail itself. A turn ended by Esc or an API error runs no Stop, so a mark over a minute old holds nothing. A busy session needs no typing: its end-of-turn check will not let a turn finish while unread mail waits. That check reads the inbox before anything else, so a turn that closes with a report marker is held too, and its row is stamped only at the turn end that passes, from the last marker anywhere in the turn: the check's own held-turn notices do not start a new turn. This is the only wake — no per-session watcher, listener, bridge or Monitor exists.
Cases.
Only a Claude Code row is typed into; the free-prompt test knows its prompt. A Codex row is never typed into, since no Codex screen has been captured to know its prompt (
rust/backend/src/comm/wake/screen.rs).A row that just restarted, compacted or cleared re-arms nothing: the handle stays with the row and the count is a file.
A session outside any row (a bare terminal, or one on a box with no daemon) has a handle and an inbox, and its end-of-turn check reads new mail before a turn ends. While idle it sees mail only at its next turn, and after ten idle minutes sends to it fail as "no live session".
A subagent that runs inside its parent's agent process uses no mail: it neither sends nor reads, and it reports to its parent, which sends. On Claude Code a
PreToolUsehook on Bash (comm-subagent-guard.sh) enforces it: the hook input carriesagent_idonly inside a subagent, and the hook denies a Bash command that names acomm-*.shscript there; a session's own input has none, so it is never refused. Another agent started inside a session (codex exec,claude -p), and anything it starts, has no comm identity: a process acts as a handle only if at most one agent (claude or codex) lies between it and its row's capsule, or the top of its process tree outside a row. Anodeprocess is an agent when any of its arguments names one or lies in its npm package, and it counts once with its native child only when that child is its direct child and its arguments after the binary equal the host's after its script (on Windows, as the Microsoft C runtime splits each command line, its backslash and""rules included). Agents are named, not launchers: the list is the agents comm ships an adapter for, and it grows in the commit that adds one. The check reads each ancestor's full command line, and an ancestry it cannot read in full (a walk past 64 processes, an unreadable /proc record, a parse that did not finish, asotd.exethat fails or is too old) is refused, not trusted. On Windows the walk reads Cygwin's own /proc (the MSYS runtime Git for Windows ships) from the script's own process up through its MSYS parents, andsotd.exe ancestors --from <pid>above the first one whose parent is native. An MSYS program that execs another is a new Windows process whose Windows parent has exited, so where the native walk stops at an MSYS process, the walk goes on from that process's MSYS parent. An ancestor whose parent has exited with no MSYS parent to go on from, whose record cannot be opened, or whose creation time is after its child's, ends the walk as the top; for a process that names no row, only the 64 cap, an unreadable /proc record and node.exe's unreadable command line are refused (the row rule below refuses that early top for one that names a row). On Windows, and on macOS wherepsprints(name)for arguments it cannot read, an unreadable command line is recorded as the program name with arguments unknown, which equals nothing: a native agent so recorded never counts once with its node host, though a standalone one is one layer, and anodeso recorded is refused. On Windows every comm script that reads mail, sends, joins or stamps refuses until the installedsotd.exehasancestors --from(an older one fails, "update sotd", or prints lines that carry no pid, which is an unreadable ancestry), so the scripts andsotd.exeship together. A refused process may neither spawn nor despawn rows (comm-probe.sh's included) nor runcomm-worktree-new.sh, which refuses it before any git write, and no hook of it writes anything under the comm home. An agent that needs its own handle is started as its own row.A process whose identity names a row acts as that row's handle only if that row's capsule is one of its ancestors. The identity names row
<id>when its pinned self file is named<host>__<id>.txtand<id>is notnopane. When no self file is pinned, it names the rowSOT_WORKSPACE_IDgives. A capsule is that row's when one of its arguments before--(with\read as/) ends in/workspaces/<id>or contains/workspaces/<id>/voyages/. The process is refused, and nothing is read, sent or stamped, when its walk ends in any of three ways: at the top, at another row's capsule, or at a capsule whose command line cannot be read. So for a process that names a row, two kinds of early stop no longer pass: being reparented away from the row, and, on Windows, a walk that stops at an ancestor it cannot open (a sandboxed agent's child may be unable to open its parent even under the same account), at a reused pid, or at one that has exited. So on Windows a comm script given alone tobash -cby a native program (cmd.exe, PowerShell) inside a row is refused: bash execs it, its#!/usr/bin/env bashexecs bash again, and the second image's Windows parent has exited and its Cygwin parent is pid 1, so no walk reaches the row. Given to Git for Windows' bash.exe, by its path, with the script as its file argument, or typed at a Git Bash prompt, it is not refused (rust/backend/tests/ancestors.rs shows both). Only a process that names no row may end its walk at the top, which is how a session outside any row passes, including one under another account's sshd. A private identity file for a process outside any row must not be named<host>__<id>.txt.A handle a row holds is read and taken only from inside that row. The row id a process carries is the one the agent-layer walk proves (
sot_capsule_workspace_id,comm-context.sh'sROW_ID):$SOT_WORKSPACE_IDwith no private self file, or the id a row-keyed self file<host>__<id>.txtnames; an id exported over any other private self file names no row. The registry entry'sworkspace_idsays which row holds the handle. Reading:sot_row_bindingis the one answer (owner,other,unbound,unreadable, by one jq over the registry's bytes).comm-context.shdiscards the handle, with@<h> belongs to row <id>; this process carries <its id or no row id>, so it is not this row's inbox, when the entry binds another row (or any row, to a process with no row id), and emitsNAME_HELD_BY=<id>; so a stray session in the row's folder, or a process of another row, reads, sends and stamps as nobody, and the Stop hook has no identity to block on.comm-poll.shaskssot_row_bindingitself and reads nothing, moving no cursor, for another row's handle or an unreadable registry. An entry with noworkspace_idis unchanged. Taking: a join by a process with no row id never takes a handle a daemon's row holds. Whatever the registry file says, when a daemon endpoint resolves the script asks it (agent.joinwith an emptyworkspace_id,sot_outside_join_clearance): it writes nothing and answersheld_by_rowfor any row it has not seen end (a row loaded from disk and not yet probed, Stopped, Unreachable, Foreign and Ending ones too; only a latched Terminal or EndedNoRespawn row, or none, frees a handle), else ok. An explicit--nameor$SOT_COMM_NAMEis refused,comm-join.sh: REFUSING to join: @<h> is held by a row (row <id>); a session outside any row cannot join as it. A name only the self file gave is not a pin: the join steps past a held one to the session's own derived handle (comm: @<h> (named by this session's self file) is held by a row; ...), and the self file then names that handle, so the session reads the held inbox no more. A derived join asks about its three tier names before the registry lock and steps past a held tier to the session's own, with one line,comm: @<tier 1> is held by a row; a session outside any row joins as @<tier 2>, no stranding warning and no MISMATCH. The writes under the lock are held to what was cleared (_sot_outside_cleared): a row that binds the handle between the answer and the write is refused, never overwritten. An entry bound to a row on another computer of the shared home (itshostis not this one) is held, since no local daemon can rule on it. Where the daemon gives no ruling (none answering, an older one, a failed call) the registry's binding refuses: a bound entry is held, and an unreadable registry refuses.comm-leave.sh --name Hof an entry bound to another row asks the same clearance and is refused where a join would be (REFUSING to remove @H, or FAILED for an unreadable registry): a destroyed row's orphan, which the daemon does not hold, stays removable, and with no daemon answering a bound entry stays. Named limit: with no daemon answering and the entry unbound or absent, a session outside any row takes the handle. A join carrying a row id is unchanged:comm-join.shalone passescomm-context.sh --rebind, honored only with a row id, so a respawned row, or a row a newer row's join displaced (M5,agent.joinmoves the handle), takes it back; the displaced row'scomm-session-start.shsays@<h> moved to row <id>and rejoins. Two rows in one folder that derive one handle keep taking it from each other (0.6.7). A child of a row (a subagent,claude -p,codex exec) is judged as before, by the agent-layer walk above. A forged id: with no private self file the walk must reach that row's capsule, so a stray that exports a row's id is refused; with a private self file that names no row, the exported id is no row id. Not closed: a process that writes the registry or the daemon's socket by hand (one account).comm-matrix.sh's runner is a session of its own,probe-matrix-<host>: it joins from outside any row through a private self file that names no row, reads only its own inbox, sends as itself and leaves at exit, so it needs no row's identity.What the check does not see: for a process that names no row, an agent above a reparenting, or on Windows above an ancestor that has exited with no MSYS parent to go on from (a process of another MSYS or Cygwin installation is not in this one's /proc), cannot be opened, or was created after its child; an agent whose name is not in the list; a macOS agent whose argv[0] contains a space; on macOS, an agent whose arguments
pscannot read and whose program file is not named after it; and a shim that startsnodeunder the agent's own name (a volta-style shim), which may be refused instead. On macOS a capsule binary installed under a path that holds a space is not recognized (pssplits its argv[0]), and a state path holding a standalone--word ends the argument scan early; either way a process naming its row is refused there. An argument whose state root itself lies inside another row's voyages folder (a path holding/workspaces/<id>/voyages/before its own) matches that row too. On macOS the walk readsps, which folds runs of white space, so a native agent started directly by its node host with arguments that differ only in white space counts as one layer with it. An argument that itself contains the record's marker bytes (\x1f, \x1e, \x1c, \x1b) reads as a separator, an unknown marker or an encoded newline or return. On Windows an unpaired UTF-16 surrogate in a command line reads as U+FFFD. On Linux a process whose whole command line is exactly!endends the walk as if it were the top. Nor does the check see a subagent: a subagent's shell has its session's ancestry, so the check takes it for the session. That a subagent neither sends nor reads is enforced for Claude Code by the hook above, which readsagent_idfrom the hook input; Codex has no such hook, so there the rule is an instruction only.
Dependencies
The shell client needs jq (every path that reads or writes a frame) and perl everywhere (registry writes flush through it; before bash 5 it supplies the clock, which is every Mac), and on Linux flock (the inbox lock; with perl, the whole-line append). A session whose tool is missing would never see its mail, so each path that reads mail checks the tools it runs before first use and says so, naming the tool: comm-poll.sh prints the line on stdout and exits 1; comm-session-start.sh prints it and exits 1; the Stop hook blocks once per episode with it as the reason, prefixes every later block with it, and clears it on the first turn end with the tools present. The client's request builders (sot_hello_frame, sot_pty_input, sot_pty_screen), the registry reader, comm-join.sh and comm-relay.sh check jq the same way and name it on stderr before any write or connection; comm-relay.sh send says it as FAILED -> @<to>: ....
Upgrading to 0.6.6
comm-relay.sh bridge is retired: a bridge loop started by an earlier version prints one line and sleeps until the next reboot, or ends with its session. No action is needed. An install removes the comm scripts a release no longer ships, by exact name: those it recorded in bin/.sot-comm-installed, and the fixed list of names retired before the record existed (bus.sh, comm-listen.sh and others). A retired script that a running loop still re-execs stays shipped as a stub, never deleted.
Mail and address-book verbs (reference client = bin/*.sh)
| Verb | Script | Notes |
|---|---|---|
| join | comm-join.sh | The handle is derived (see Naming); --name <n> is accepted and overrides it. --expertise "a, b"; writes registry + self file. Refuses (exit 3) when the self-file slot is already claimed for a DIFFERENT project — the slot is keyed by the workspace row in the environment while the identity comes from the shell's cwd, and a row that comes to name another project's session reads that session's mail. --repin is the deliberate override |
| audit slots | comm-self-audit.sh | compares each workspace-keyed slot's key against the repo= it carries; reports the ones naming a different project (exit 1), passes a suffixed or path-disambiguated name |
| send | comm-send.sh | @name 'msg', @name --file <path | -> (the body is the file's bytes, read whole, and never argv on a local send) or --broadcast 'msg'; recipient is only the first positional @arg, so the message may itself begin with @. Either verb routes: a directed target this box's registry names is filed by route 1 of Delivery (comm-relay.sh send execs here), and one it cannot name goes to the hub as comm.file (this execs comm-relay.sh send). The triggers are mutually exclusive, so a session never has to know which verb reaches a peer |
| poll | comm-poll.sh | shows the inbox lines past the read cursor, then advances it; a busy inbox exits 75 (try again) |
| list | comm-list.sh | all agents + live/stale + (me) marker |
| leave | comm-leave.sh | removes self from registry; --name <handle> removes an orphan row (registry only — comm-despawn.sh is full teardown), refused for a row's handle the daemon holds |
Naming
Names come from the repo, never from the task: a task-named anything is unfindable next to its repo-named siblings. comm-spawn.sh refuses a task-named workspace label.
A derived handle is <base>-<host>, with the case kept (never lowercased). <base> is the project folder's name reduced to [A-Za-z0-9._-] and cut to 20 characters; <host> is the box's name cut to 12, with -<hash6> of the raw name appended when that changed it. When another project's root already holds the handle, it becomes <base>-<parentdir>-<host> (the parent folder's name, reduced and cut the same way), then <base>-<hash6>-<host> (comm/lib/comm-lib-identity.sh, sot_derive_handle). A worktree is a folder named <repo>-wt-<shortname> (<shortname> names the worktree, never the task), so its handle is <repo>-wt-<shortname>-<host>; create it with the /worktree skill (comm-worktree-new.sh), at <repo-parent>/worktrees/<repo>-wt-<shortname> on branch wt/<shortname>. A Codex row started by ccx defaults to <repo>-cx-<host>. An explicit --name or $SOT_COMM_NAME overrides the derivation.
| Thing | Convention | Example |
|---|---|---|
| Session handle | <base>-<host> | MyRepo-myhost |
| Worktree session handle | <repo>-wt-<shortname>-<host> | MyAnalysis-wt-rotation-myhost |
| Frontend address | fe@<host> — the frontend PROCESS's declared hello name, the target sot-fe --fe <host> scopes a directed fe.command/open-url to (two frontends on one box differ by instance); the frontend is a client, never a comm peer, and no session derives or joins as this name | fe@laptop |
| Workspace label | repo basename (comm-spawn default; task-named labels are rejected) | MyPackage |
| Workspace slug (the row's name) | derived from the label by the daemon | mypackage |
| Second session on one repo | a git worktree (comm-worktree-new.sh); comm-spawn.sh refuses a root that already has a workspace | MyPackage-wt-rotation |
Task identity lives in --task / --expertise / the message body. Never reuse a handle that has a registry row, even a stale-looking one — the owner may be alive with a lagging heartbeat, and a collision makes two sessions execute the same briefs in parallel.
Message tags and the domain-expertise rule are in the sot-comm skill.
Versioning
protocol_version is stamped into the registry on creation and checked on join. A mismatch warns loudly (no quiet degradation) and means a machine needs ShipTools.update_comm(). Bump this integer on any breaking change to the schema, frame, or delivery semantics.