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 .git can enter a commit, and it is not a ref namespace, so push --mirror cannot 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 OFF file.

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:

  1. Resolve first. Take the path to an absolute one, then to its worktree root (git rev-parse --show-toplevel). A relative -To ..\peer and a session launched in a subdirectory are both routine, and neither yields the same string as the recipient’s own cwd.
  2. Normalize trailing separator and slash direction, and fold case only on a case-insensitive filesystem (Canonicalise before comparing).
  3. Hash the normalized string for injectivity: two different paths can never land in one box.
  4. 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

Mail stays queued after display until its session reaches Stop.
The shipped drain emits mail before writing its display marker. A failed marker can cause repeat display; a closed parent cannot run hooks. Editable diagram.

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. A ParameterBindingException is 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:

  1. The exclusion primitive did not exclude. Step 4: an exclusive open, not a move-and-catch.
  2. Showing is not consuming. Step 5: render at every event, consume only at Stop.
  3. The repair reintroduced the defect it was fixing. Step 5: mint the marker after the emit.
  4. 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 Stop event 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.


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