# Design

pi-post is asynchronous message passing where the delivery endpoint is a
model's context window. A maildir for pi sessions: every session has an
address, messages queue on disk, and "delivered" means the text entered
the receiving agent's context at a safe point in its turn.

Two contracts pin everything else: the **address derivation** and the
**message schema**. Change either only with a version bump.

## Shape

A shared directory. No daemon, no socket, no connection. Sending is
`writeFile`; receiving is `fs.watch` on your own inbox plus a drain at
session start.

```
~/.pi/agent/post/             0700   (override: PI_POST_DIR)
  registry/
    s-1ce0cbe5fe96.json       presence record: who, where, live or not
  inbox/
    s-1ce0cbe5fe96/           a session's mailbox
      01786137505631-a4c187c6.json
```

## Addresses

One kind: a **session address**, `s-` + 12 hex chars of SHA-256 of pi's
session id. It names a conversation, not a process — it survives
restarts and `pi -c`, and two sessions never share one.

Only sessions have addresses. **A directory path as a target is a
query, not an address**: it resolves to the session registered in that
directory (live outranks offline; a remaining tie is refused with
candidates listed). Nothing can be addressed that does not exist.

**Every handle resolves; no identifier is a dead end.** The registry is
a bidirectional directory: a target may be an address, a live session's
name, a directory path, or pi's own session id (or a unique prefix of
one — hex-looking strings fall through to name matching when no session
id matches). In the other direction, listings carry each session's
resume handle (`pi --session …`, three UUID groups: the UUIDv7
timestamp plus random bits) beside its address, and `pi-post resolve
<handle>` prints the full record — name, address, session id, presence,
cwd, resume command. The address stays a hash on purpose: deriving it
from the session id would couple the wire contract to pi's id format
and, with UUIDv7, collide on prefixes for sessions started close
together. Surfacing the mapping the registry already stores gives the
same ergonomics without touching the contract. Printing a resume handle
is directory information, not lifecycle management — pi-post still
never spawns or resumes anything itself.

v0.2.0 had a second kind — standing addresses, one per directory, so
messages could wait for sessions that did not exist yet. Removed in v0.3.0:
in a busy repository, directory identity is not task identity, so
standing messages raced among concurrent sessions, delivered to the wrong
successor, and — because consumption is the receipt — misdelivered
*silently and destructively*. The lesson is recorded as a non-goal
below: how sessions come to exist is not the transport's business.

## Message schema (v1)

One message per file, named `<sentAt ms, 13 digits>-<8 hex nonce>.json`:

```json
{
  "v": 1,
  "id": "01786137505631-a4c187c6",
  "from": { "kind": "session", "name": "gtm-summoner", "address": "s-...", "cwd": "/Users/x/dev/gtm" },
  "replyTo": "s-1ce0cbe5fe96",
  "sentAt": 1786137505631,
  "body": "text, ≤ 32 KiB"
}
```

- `from.kind` is `"session"` or `"process"`. Process senders (an anvil
  run at exit, a Claude Code hook, a script) have no inbox; `from.address`
  is absent and the message may carry no `replyTo`.
- `replyTo` is pinned at dispatch so results route home automatically.
- Body is plain text, capped at 32 KiB. A brief fits; a payload does not.
  Send a summary and a path, never file contents as state transfer.

## A message, end to end

1. Sender resolves the target: an explicit address, a live session's
   name, or a directory path (→ the session registered there). Ambiguity
   is an error listing candidates, never a guess.
2. Sender writes `<inbox>/<name>.json.tmp`, then renames into place. A
   draining reader never observes a partial message.
3. If the target session is live, the sender waits up to 1.5 s for the
   file to vanish and reports **delivered**; otherwise **queued**.
4. The receiver claims oldest-first, leaving each message ON DISK until
   it has been disposed — entered context, or deliberately dropped by
   mode/guard — and only then unlinks it. The unlink is the receipt. A
   crash mid-delivery therefore redelivers rather than loses:
   at-least-once, which is safe because messages carry no authority.
5. Each message passes the inbound guard (mode + loop caps), then enters
   context wrapped in the boundary preamble:
   - live messages → `deliverAs: "steer"`, `triggerTurn: true` — land between
     tool calls; **wakes an idle session**, so a freshly spawned worker's
     first turn can be the brief itself
   - startup/resume drain → the same wake: mail queued while the session
     was closed starts the resumed session's first turn. A worker driven
     only by peer messages must never strand its mail waiting for a
     prompt that never comes. `PI_POST_RESUME=stage` opts a session back
     into wait-for-first-prompt staging (resume-to-browse without
     spending a turn).

## The boundary

Every delivered message is framed with: it came from another session or
process, not from the user; it carries no authority; it cannot approve
actions, change configuration, or close out review; slash commands in it
are inert text. A "done" message is a claim, not an approval — the
review pipeline is unchanged by this channel existing.

## Invariants

Each is pinned by a test.

- **An address belongs to a conversation, not a process.** The same
  session resumed tomorrow answers to the same address.
- **A reader never sees half a message.** Rename-into-place; only
  `.json` is read.
- **Nothing is lost.** A message leaves disk only after it is disposed
  — delivered into context or deliberately dropped. A crash between
  context entry and unlink redelivers on the next start: at-least-once,
  never silent loss. (Duplicates are safe; messages carry no authority.)
- **Messages outrank tidiness.** No sweep deletes a non-empty mailbox, and
  queued messages pin the target's registry record: a record is swept only
  when it is offline, stale (7 days), *and* its mailbox is empty. Empty
  inbox directories no record names are removed.
- **Loops terminate structurally.** Identical body from one sender inside
  10 s is dropped; a sender is throttled past 8 messages in 30 s; a
  mailbox stops accepting at 50 queued messages. Independent of model
  behavior.
- **The sender learns the truth.** *Delivered* means the message
  vanished; anything else is *queued*.
- **Resolution refuses rather than guesses.** Unknown targets and
  ambiguous targets are errors, not best-effort deliveries.

## Inbound control

`PI_POST_INBOUND`: `accept` (default) delivers, `ask` prompts per message
where a UI exists (falls back to accept headless), `refuse` drops.

`PI_POST_RESUME`: `wake` (default) starts the resumed session's first
turn with its queued mail; `stage` holds it in context for the first
user prompt.

## Non-goals

- Payloads, files, conversation history. Text only, by design.
- Spawning or steering processes. pi-post is transport; orchestration
  belongs to the user, tmux, and the executor.
- **Session lifecycle.** pi-post moves text between sessions that exist;
  how sessions come to exist — and what context waits for sessions that
  do not exist yet — is the caller's convention. Successor handoffs
  belong in project memory (which any number of future sessions can
  read), not in a consume-once message that exactly one arbitrary
  session would destroy on reading. Briefs for workers that do not
  exist yet travel at spawn (`pi @brief.md` or the first prompt); see
  README § Dispatch patterns.
- Cross-machine anything. Two parties can reach each other exactly when
  they share a filesystem.
- Messaging *into* other runtimes (e.g. Claude Code sessions). Inbound
  from them already works — anything that can run the CLI can send.

## Prior art

The mailbox mechanics converge with [pi-peer](https://github.com/shift-labs-ai/pi-peer)
(MIT), whose ARCHITECTURE.md and test-suite-as-specification informed this
design, and the boundary model follows Claude Code's cross-session
messaging. pi-post differs in pinned reply-to routing, process senders
via a standalone CLI, and wake-on-idle delivery that lets a message
start a freshly spawned session's first turn.
