# Architecture

## Shape

A shared directory. One record and one inbox per conversation.

```
~/.pi/agent/peers/            0700
  1ce0cbe5fe96.json           record: who, where, present or not
  1ce0cbe5fe96.inbox/         anyone may write, only the owner reads
    01786137505631-a4c187c6.json
  e8f14204d058.json
  e8f14204d058.inbox/
```

No daemon, no socket, no connection. Sending is `writeFile`. Receiving is
`fs.watch` on your own inbox.

## A message, end to end

1. The sender resolves a name to a record, and refuses rather than guesses if
   the name is ambiguous.
2. It writes the letter to `<inbox>/<name>.tmp` and renames it into place, so a
   draining reader never observes a partial write.
3. If the target is live, the sender waits up to 1.5s for the file to vanish.
4. The receiver's watch fires, drains the inbox oldest first, and unlinks each
   letter as it reads it.
5. Each letter goes through the inbound guard, which decides deliver, ask,
   refuse, or drop.
6. What survives is handed to pi as a custom message with the boundary preamble
   attached, delivered as `steer` so it lands between tool calls.
7. The sender reports **delivered** if the file went away and **queued** if it
   did not.

## Invariants

These are the promises. Each is pinned by a test.

**An address belongs to a conversation, not a process.** It is a hash of the
working directory and pi's session id, so the same session resumed tomorrow
answers to the same address, and two sessions open on one directory never share
an inbox.

**A record outlives the process that wrote it.** Shutting down marks a session
offline; it does not remove it. That is what makes a session addressable while
it is down.

**A sweep never destroys undelivered mail.** A mailbox holding anything is kept
until it has been abandoned for thirty days. Only an empty mailbox belonging to
a session that cannot be resumed is discarded.

**Reading the directory has no side effects.** A listing cannot delete a peer's
mailbox, however dead that peer looks.

**A reader never sees half a letter.** Writers rename into place, and readers
only ever look at `.json`.

**Nothing is delivered twice.** A letter is unlinked as it is read, before the
caller handles it.

**A loop between two agents terminates.** Repeats, rate, and backlog are capped
independently of what either model decides to do.

**Consumption is the receipt.** The sender is told *queued* unless the letter
actually disappeared.

## Decisions

### The mailbox is a directory, not a socket

A connection would give instant delivery and a synchronous acknowledgement. It
would also need liveness handshakes, framing, size caps, stale-socket cleanup,
and tests that spawn processes.

Files give three things a connection cannot. Mail for a session that is not
running waits instead of bouncing, which is the common case when you are
opening and closing terminals all day. The queue is inspectable with `ls`, so
diagnosing a delivery problem does not mean instrumenting a transport. And the
receiver unlinking the file is a stronger receipt than any acknowledgement a
transport can offer, because it means the message reached the agent rather than
the socket.

The cost is that presence is polled rather than pushed — accurate to within a
heartbeat instead of instantaneous. For a handful of sessions read on demand,
that is not a difference anyone can observe.

### Presence is a pid plus a heartbeat

A record carries the owner's pid and the time it last wrote itself. `live`
means the process exists and beat within 45s. `stalled` means it exists but has
stopped beating, so it is wedged or suspended and mail should wait rather than
bounce. `offline` means no pid, either because the session shut down cleanly or
because it died.

Both signals are needed. The pid alone cannot tell a wedged session from a
healthy one, and it can be reused. The heartbeat alone cannot tell a crash from
a pause.

### Sweeping is narrow, and learned the hard way

The first version treated a missing session file as proof that a conversation
had been deleted. Pi writes that file lazily, so a session that registered and
exited before its first message has a path that does not exist yet. The sweep
deleted its mailbox with a letter still in it — after the sender had been told
the letter was queued.

Mail now outranks tidiness. A running session is never touched. A mailbox
holding anything is kept for thirty days. Only an empty mailbox belonging to a
session that cannot be resumed is discarded promptly, and discarding that costs
nobody anything.

### Delivery is `steer`, not `followUp`

The point of a peer message is usually that something the receiver is doing
right now is affected. Waiting for the whole run to finish would deliver the
warning after the damage. `steer` lands between tool calls, so nothing in
flight is interrupted, and `triggerTurn` wakes a session that is idle.

### The boundary is repeated, not stated once

A peer message carries no authority: it cannot approve anything, cannot change
configuration, and any slash command in it is inert text. None of that can be
enforced the way a permission check can, so it is attached to every delivery
rather than mentioned once in a system prompt where it would scroll out of
reach.

The sending side has a matching rule in its tool guidelines: never ask a peer
to do something your own permissions would refuse. That closes the obvious
laundering path.

### The backlog counter is read through `isIdle()`

The count of letters handed to pi resets when the agent settles. If a
configuration never produced a settle, a raw counter would climb to the cap and
silently wall the mailbox off. An idle agent has by definition worked through
what it was given, so the count is reported as zero whenever pi is idle.

## Failure modes

| Failure | Behaviour |
| --- | --- |
| Target not running | Letter waits in its inbox; sender is told *queued* |
| Target wedged | Same, and the listing shows `not responding` |
| Target's session deleted | Mailbox kept while it holds mail, then swept after 30 days |
| Ambiguous name | Refused, with the candidates listed |
| Unknown name | Refused, with the reachable sessions listed |
| Message over 32 KB | Refused at the sender, with the room actually available |
| Corrupt letter | Discarded on read, so it cannot fail every future drain |
| `fs.watch` misses an event | A 3s poll finds the mail anyway |
| Two agents answering each other | Dedupe, rate, and backlog caps end it |
| Registry unwritable | Heartbeat failure is swallowed; the session keeps working |

## Testing

`test/registry.test.ts` covers addressing, presence, and the sweep rules.
`test/mailbox.test.ts` covers the transport guarantees. `test/policy.test.ts`
covers loop breaking. `test/format.test.ts` pins the strings the model reads,
because they are the interface. `test/extension.test.ts` drives the pi wiring
through a stand-in for the extension API. `test/exchange.test.ts` composes them
into whole exchanges, including a session going offline and collecting its mail
on resume.

`bun run check` is the gate: typecheck, lint, and the full suite.
