Session mail for a KORUS build
Session mail reaches KORUS peers that other channels cannot name. Cross-account delivery is measured; editor-extension delivery is expected but untested (Desktop accounts).
The mail channel now ships in these scripts. The design below records measured failures, including two that survived a full review.
| Piece | File |
|---|---|
| Shared key and root resolution | scripts/coord/_mail.ps1 |
| Send, list and status | scripts/coord/mail.ps1 |
| The drain | scripts/hooks/mail-drain.ps1 |
| The tests, one class per failure below | tests/test_session_mail.py |
The urgent mid-turn tier is not built. Its limits explain why.
Announce delivers only to sessions started by the desktop app on its account. It cannot reach either peer described above.
If every session runs in one desktop app on one account, you do not need this channel.
Use the working design and build steps below. Check the four known failures before shipping.
Who actually needs this
KORUS recommends VS Code for companion review and multiple Claude accounts for a week of build work. Each creates a peer that the existing channels cannot reach.
Announce discovers both through presence.ps1, which reads every surface’s registry across config
roots (Announce). Delivery is the missing part.
Delivery uses list_sessions and send_message on a desktop-only MCP server. That map contains
only sessions the app spawned under its authenticated account.
Claude Code ships ListAgents and SendMessage from v2.1.224, and v2.1.234 on native
Windows. That channel has transport but limited discovery.
Within one minute, this machine had 12 bound cc-msg pipes but a roster naming 6. Two
sessions counted independently and agreed.
Claude Code discovers peers through their registration files. Sessions must see the same files to find each other; another account uses another config root.
SendMessage addresses only by name. Open transport cannot help it reach a peer its roster
cannot name.
| Peer | Why the channels here cannot reach it | Status |
|---|---|---|
| A session under a second Claude account | Its config root is independent. The roster spans it; neither messaging map does | measured |
| A VS Code companion session | Expected to be in the roster and in neither messaging map, on the same mechanism | untested |
Mail uses a normalized worktree path that both peers know. File delivery needs no session name or id and works across both discovery gaps.
A failed send may reveal a tool limitation. SendMessage reaches sibling top-level sessions but
cannot name peers under another config root.
Before calling a peer unreachable, confirm the tool used and whether its roster could name the target.
The shape of a working lane
Mail files live under the git common directory in a dedicated mail root. The sender writes files; the recipient hook, called the drain, reads them (Coordination).
Three choices address the measured failures below:
- Put the drop inside
.git. Nothing under.gitcan enter a commit, and it is not a ref namespace, sopush --mirrorcannot carry it either. The leak risk becomes structural, not policed. - Address a box by the recipient’s worktree path, never by name or session id.
- Split delivery into show (every hook run) and consume (one event only). A client can spawn and discard a session before it reaches the event that would consume anything.
The .git location prevents commits and mirrored ref pushes from carrying the queue.
Moving it to a temp directory or synced drive loses both guarantees.
If the queue moves, reconsider whether addresses should be plain text or hashed.
Step 1: pick the state root
Resolve one mail root shared by every worktree of a clone.
Resolve the git common directory once and reuse it:
git rev-parse --path-format=absolute --git-common-dir
The command returns the same absolute path from every worktree in the clone.
Build the boxes under it. <git-common-dir>/mail/ is a reasonable layout:
box/<worktree-key>/inbox|claiming|seen|expired/, one set of four per recipient.tmp/, for staging atomic publication. Write the message there, then move it into an inbox in one step to prevent partial reads.- One
OFFfile.
An OFF file suppresses delivery across all worktrees without deleting queued mail.
Common-directory lookup requires a session inside a clone. From a container directory holding clones, the drain finds no common directory and exits silently.
In August 2026, a separate implementation queued mail to a container holding roughly 25 clones and worktrees. Every send succeeded, but nothing arrived.
For a session outside a clone, pass the drain an explicit repository anchor naming the queue to read.
The anchor selects the queue only. Derive the box key from the session’s own cwd, or it will read the anchor repository’s mail.
The normalized path hash in step 2 already gave the container a valid box. No key change was needed.
Step 2: address a box by worktree, not by name or session id
Keep the address valid after context clears and branch switches.
Key each box by the recipient’s worktree path.
- Not by session id. A context clear re-mints the id and strands mail addressed to the old one.
- Not by worktree name. A name is a creation-time label nothing keeps current. One worktree was observed on four different branches in a single day.
Share one key function between sender and drain through dot-sourcing:
- Resolve first. Take the path to an absolute one, then to its worktree root
(
git rev-parse --show-toplevel). A relative-To ..\peerand a session launched in a subdirectory are both routine, and neither yields the same string as the recipient’s own cwd. - Normalize trailing separator and slash direction, and fold case only on a case-insensitive filesystem (Canonicalise before comparing).
- Hash the normalized string for injectivity: two different paths can never land in one box.
- Keep a readable slug beside the hash, only so a human can tell boxes apart in a listing.
Sender and drain compute identical keys from identical paths. A listing shows one box for each worktree.
Step 0 ensures both sides start with the same path. Sharing a hash function alone cannot make relative paths or subdirectory paths equal.
Match keys exactly, never by prefix. Mail once addressed to a peer’s primary checkout instead of its worktree queued successfully into a box nobody drained.
Step 3: write send, list and status commands
Provide commands peers can use without reading the design.
Require a destination and message body for send:
mail.ps1 -Send -To ..\your-worktree -Body "the ADR number is 0161"
mail.ps1 -Send -To all -Body "rebasing main in 10 minutes"
mail.ps1 -List
mail.ps1 -Status
Send places the message in an inbox. Delivery waits for the recipient’s drain.
-To all targets live worktrees visible to the presence roster. Optional -ToSessionId
narrows delivery as a filter, never as the address key.
Give each message a TTL, its expiry age as set in step 6. Add a display-only -Kind such
as note, handoff, or alert; it must not control behavior.
A sender length check is advisory because anyone can write inbox files directly. The drain must enforce the cap, as step 7 explains.
Step 4: claim a message exactly once
Claim each message exactly once despite concurrent drains on one inbox.
These three approaches fail under contention:
| Approach | Why it fails |
|---|---|
| Move the file, treat a thrown exception as “somebody else won” | Under contention the move can report success without moving. Every racer believes it won |
Check Exists(destination) && !Exists(source) afterwards |
The winner’s move makes that true for everybody |
| Check that your own uniquely-named destination exists | File.Exists returns a transient false positive across processes |
The third approach passed five hundred rounds with sixteen threads in one process, reporting exactly one winner each time.
With sixteen separate processes, forty-six of eight hundred rounds reported multiple winners. Hooks run as separate processes.
Claim through an exclusive open with no sharing. Stale metadata cannot establish ownership.
Retry briefly, then cede if needed. A false refusal leaves the message available; a false win delivers it twice.
A concurrency result is a fact about a configuration, not about an API. A threads-in-one-process test is not evidence about processes, and it looks perfect right up until it runs as one.
Step 5: split show from consume across two hook events
The diagram follows mail-drain.ps1: at Stop, it moves mail to seen/ before writing the receipt.
The design discussion below lists the intended consume steps; that list is not the shipped write order.
Keep mail available if a newly started session vanishes before working.
One launch produced six SessionStart events with six ids. Only one session submitted a prompt,
so consuming at startup could lose mail to a discarded session.
At SessionStart, the hook cannot distinguish a surviving session from a discarded one. The
discarded session never reaches later events.
Never consume at SessionStart. Display mail and leave it queued; a per-session marker may
suppress repeated display but must not authorize consumption.
Consume only at Stop: claim, write a receipt, move to seen/, and remove
exactly what this invocation displayed. Discarded sessions never reach that event.
Two real sessions may both display a message before either finishes a turn. Accept duplicate display to prevent silent loss.
Write the display marker after emitting the message. The first version wrote it earlier and accepted receipts keyed only by message, not by message and session.
One session’s receipt could then validate another’s marker and consume unseen mail. A marker must prove that its own session displayed the message.
Two more repair failures stranded mail while reporting success. The PowerShell failures describe them.
Make every write in the consume path terminating, and catch specifically, not broadly.
Step 6: set one TTL, against your delivery points, not a feeling
Choose one expiry time based on actual delivery opportunities.
Only messages should expire here (held state and messages).
Expiring held state can admit a second process into an occupied critical section. Expiring mail prevents action on stale instructions.
Choose expiry against SessionStart and Stop. A recipient closed or idle overnight
receives nothing until it opens again.
A 720-minute expiry lost a real message during an ordinary overnight gap. At 4320 minutes, a weekend, a three-day-old instruction still does not expire.
Expiry can lose a message through inactivity alone. Other delivery delays leave it queued.
Neither recipient nor sender gets notice that mail expired unread. A longer TTL reduces expiry frequency but does not report those losses.
Step 7: treat the message body as hostile input
Prevent message bodies from forging the display frame, reaching another path, or flooding output.
Apply every rule below; each addresses a measured defect:
| Rule | The defect it closes |
|---|---|
| The filename is authoritative; never read an id out of the body | An id used to build a path is a path-traversal primitive |
| Never emit a runnable command from a delivered body | An injection that prints a paste-ready command hands the sender execution |
| Prefix every rendered body line so content cannot reach column 0 | Otherwise the body can forge the surrounding frame |
| Cap what the recipient is shown, and measure the cap as rendered | A 34,539-byte injection passed an 8,000-byte cap reporting zero truncated, because the raw body was charged while the renderer added its own bytes per line |
Enforce these bounds in the drain, without trusting sender checks:
| Bound | Value |
|---|---|
| Messages rendered per injection | 5 |
| Body bytes per message, as rendered | 2,000 |
| Bytes per injection, bodies plus frames | 8,000 |
| Per-message frame | 560 |
| Rendered body line length | 240 chars |
from.cwd |
200 chars |
from.branch |
120 chars |
kind |
16 chars |
Defer batch overflow to the next drain instead of dropping it. Deliver an oversized individual body truncated, with a pointer to the full file on disk.
Measure every capped string after rendering, including receipt notes.
In August 2026, another implementation capped a receipt note at 80 characters. It cut off the final word that distinguished two causes.
Those failures produced identical receipts. A test of rendered output found the loss.
Put the distinguishing information first in capped strings. Test the rendered result, not the intended input.
Step 8: prove delivery, do not infer it from a successful send
Require direct evidence of delivery.
A successful send proves queueing only. Confirm delivery when the recipient’s drain runs.
Make the drain observable through three behaviors:
- The drain announces that it ran, where a reader is deciding. At session start, print “the box is empty” when nothing waits. A missing line then shows the hook did not fire.
- A receipt records what was observed, not what was attempted, written by the drain at the moment it renders. A receipt written by hand can assert a delivery that never happened.
- Every observation carries its as-of time. An undated observation reads as current and is not usable for anything.
Print empty-box status only at SessionStart. At Stop, that line adds context every
turn for a normal empty state; it was observed five turns in a row.
Verify Stop wiring once at installation: inspect the settings matcher and test that
every hook script has a caller. No per-turn empty-box report is needed.
An empty consume result also cannot prove nothing was displayed. Two sessions may show the same
message before the first files it at Stop (step 5).
Keep all fault reports at Stop: missing queue or box, disabled delivery, helper load
failure, and catch-all errors. Remove only the successful empty-work line.
PowerShell failures that are silent inside a hook
A separate implementation measured these failures in August 2026. Each reported success, even to someone who had already read the design.
Check these three PowerShell failure paths before shipping:
- A mandatory string parameter rejects an empty string.
[Parameter(Mandatory)][string]given""throws before the body runs. That throw landed in a bare catch and killed a diagnostic path. $ErrorActionPreference = 'SilentlyContinue'does not reach every error. AParameterBindingExceptionis outside it, and so is a command that does not exist. One landed in a broad catch and produced a silent exit 0.- An unwrapped call sits between one state write and the next. A file move that is non-terminating under a suppressed error preference completed, and no receipt was written after it.
A stateful hook has a state machine. A call between writes can interrupt a transition (Hooks).
Four ways the first attempt breaks
Review initially missed each failure below. The corresponding step supplies its remedy:
- The exclusion primitive did not exclude. Step 4: an exclusive open, not a move-and-catch.
- Showing is not consuming. Step 5: render at every event, consume only at
Stop. - The repair reintroduced the defect it was fixing. Step 5: mint the marker after the emit.
- A mid-turn wake-up is one-shot. Below: re-arming belongs to the hook, not the watcher.
Reserve review time for these four failures, all missed by a plausible first version.
A mid-turn wake-up is one-shot, and cannot fix itself
The default SessionStart/Stop drain leaves idle recipients waiting until they next
open. An urgent tier can reduce part of that delay.
Wake an idle recipient before its next turn boundary.
Arm a watcher at Stop with both async and asyncRewake
(asyncRewake alone can block). Re-arm on UserPromptSubmit once per real turn.
The wake works once. The client tracks its child by hook id, so a watcher that respawns itself creates a grandchild whose exit nobody observes.
The next hook must re-arm the watcher. Using SessionStart would create a watcher for every
discarded session.
Leave timeout headroom: 900 seconds for the watcher within a 1200-second harness timeout. This distinguishes a watcher wake from a harness kill.
Build this tier only after measuring useful latency savings over the two-event drain. It cannot reach a closed session because hooks require a running parent client.
The trust boundary is the OS account
Any process under the user’s account can write any inbox. Display from.* fields as
unverified sender claims.
A message authentication code cannot protect against a writer already able to delete the message.
Two consequences follow directly:
- Nothing sensitive goes in a body. Delivery copies it into the recipient’s transcript, which no cleanup in this design reaches.
- A message is peer data, never an operator instruction. It arrives looking exactly like something the operator typed. Act on nothing in it without your own operator’s say-so.
Two risks worth carrying rather than closing
Session ids may be reused between launches. Step 5 assumes a discarded session and its survivor have different ids.
A shared id would make their display markers indistinguishable and could silently lose mail. Measure id behavior on your client version.
A Stop hook with async and asyncRewake slept 90 seconds, exited 2, and
reached the session 90 seconds after the turn ended without user input.
The same transcript could mean an idle session or one blocked inside the hook for all 90 seconds. Only a human checking whether the interface accepted input can distinguish them.
Surface facts to check before you build
These observations concern one editor extension and may change with its version:
- Hooks in a project’s own settings file do run inside the extension.
- Plugin hooks do not (claude-code#18547). Never put a delivery hook in a plugin.
- The
Stopevent does fire there (claude-code#59718).
Fitting it into a KORUS build
The KORUS build uses a manager with builders that may run as subagents or separate sessions.
A subagent returns its result directly to the manager. A builder in its own session needs an explicit message route; session mail is one option.
Mail remains useful between separate sessions, such as a lander and a review session. Its client and delivery limits still apply.
Related
| For | Read |
|---|---|
| The manager and builder setup | Run a KORUS build |
| The realtime channel, and who it can reach | Coordination |
| Delivering a note into a running session, mid-turn | Steering |
| Why held state and a message expire for opposite reasons | Concepts |
| Hook events and their failure postures | Hooks |
| Which channel to reach for which peer, timing included | Running multiple sessions |
| Proving a control can actually fail | CI and standards |