# pi-peer-messages design (v0)

A pi extension that lets agent sessions in different pi processes (and machines) message each other peer-to-peer over iroh, in the spirit of `send_message` in `pi-actor-subagents`.

## Trust model

- Every pi session has one stable **peer address**: an iroh EndpointId (ed25519 public key, base32).
- Knowing an address is the permission to send to it (capability). Sharing your address = granting the receiver permission to message your session. There is no pairing or allowlist.
- Replies are allowed: the receiver learns the sender's address from the authenticated QUIC/TLS identity (`connection.remoteId()`), never from the payload.
- Known limits: n0 discovery/relay servers and LLM providers (via transcripts) see addresses; capabilities are transferable; v0 has no revocation except a new session; two agents replying to each other can loop (each reply is a paid turn), as in `pi-actor-subagents`.

## Requirements

1. Stable identity per session: the same session (also after resume and pi restarts) → same address; new/fork/clone session → new address. Distinct sessions get distinct addresses even if they share a session id.
2. Sender identity correct by construction: from the transport, never from the payload, and not forgeable through message text.
3. Honest delivery status with exactly defined outcomes.
4. Bounded attack surface: size limit, deadlines, concurrency cap; incoming text is a peer message only, no commands.
5. Survives `/reload`, session switches, and same-process child sessions without two endpoints holding the same key.
6. Orthogonal to `pi-actor-subagents`: no shared runtime, no tool name collision (`send_message` is taken there).
7. Minimal: one tool, one command, one secret file. A session costs nothing (no socket, relay, discovery) until it becomes reachable.
8. Best-effort delivery, no mailbox: offline receiver → sender gets an error.

## Decisions

### Session identity: one master key, derived session keys
`sessionSecretKey = HKDF-SHA256(ikm = masterKey, info = "pi-peer-messages/v0" || header.id || header.timestamp)` → 32 bytes → `SecretKey.fromBytes`, with `header = ctx.sessionManager.getHeader()`.

- The session id alone is not unique: `pi --session-id X` creates a new session with id X in any project where none exists. The header timestamp is fixed at creation (new, fork, clone) and kept on resume, so id + timestamp identifies one session. Only a literal copy of the session file collides, which is the same session.
- Fork, clone, ephemeral and child sessions get a fresh uuidv7 id → new address; a restored child reopens its JSONL → same address.
- Master key: `<pi agentDir>/pi-peer-messages/master.key`, exactly 32 random bytes, mode 0600. Creation is race-free across processes: write a temp file, then `fs.linkSync(tmp, final)`; on `EEXIST` read the existing file and require exactly 32 bytes.
- Rejected: key in session JSONL (forks copy it → duplicate identity; secret in transcript); one key file per session (orphans, more files).

### Address format
EndpointId only (no ticket). Discovery via the n0 preset resolves id → relay/direct addresses (spike: connect by id only in ~466 ms). Stable across relay/IP changes.

### Reachability: listen lazily, remember it in the session
A session binds its endpoint only once its address can be known to anyone: on the first `/peer-address` or `send_peer_message`. At that moment it appends a marker entry `{address}` via `pi.appendEntry`. On `session_start` it binds again only if a marker in `ctx.sessionManager.getEntries()` (the whole session file, not the current branch, so `/tree` navigation cannot silence it) matches this session's derived address; a fork inherits the marker but derives a different address, so it stays silent. The marker is written only after a successful bind and only if no matching marker exists.

Rationale: binding in every session (interactive, `pi -p`, every subagent child) costs a relay connection, discovery publishing, and a 1.1–2.6 s `close()` on every quit, reload and session switch.

### Lifecycle
- The endpoint lives in the extension closure of its session, not in a `globalThis` slot: `pi-actor-subagents` children share the process and `globalThis`, so a process-wide slot would let one session close another's endpoint. Pi awaits `session_shutdown` before invalidating a runtime and before the next `session_start` (reload, new, resume, fork), so the closure is sufficient.
- Binding is single-flight: the closure holds one `Promise<Endpoint> | undefined` plus a `closed` flag. pi runs tools in parallel, so concurrent sends and `/peer-address` share the one pending bind.
- `session_shutdown`: sets `closed`, awaits a pending bind, then an idempotent `close()` capped at ~2 s. Bind and send after `closed` fail with `not delivered`.
- Bind is fast (~5–20 ms); `online()` (~0.5 s) is never awaited on `session_start`.
- Known edge case: the same session opened in two pi processes → two endpoints with the same id (documented, not handled in v0).

### Wire protocol
- ALPN `pi-peer-message/0`; one connection carrying exactly one bidirectional stream per message.
- Sender writes UTF-8 JSON `{"text": string}`, `finish()`es, reads the reply, then closes the connection.
- Receiver handles each connection concurrently, off the accept loop: `readToEnd(64 KiB)`, parse, deliver, write `{"ok":true}` or `{"ok":false,"error":string}`, `finish()`, then await `conn.closed()` with a timeout so the reply is not dropped by an early QUIC close.
- Every connection has a ~10 s deadline on both sides; on expiry `conn.close()` (the binding has no read timeout; a stalled peer otherwise keeps `readToEnd` pending indefinitely).
- At most 16 inbound connections in flight; beyond that `incoming.refuse()`.
- No connection reuse (KISS); iroh caches discovery.

### Delivery semantics
`send_peer_message` has three outcomes:
- `delivered`: the receiving pi session accepted the message via `pi.sendMessage` without a synchronous throw. It may still sit in the receiver's in-memory steer queue, or its triggered turn may fail (e.g. no model or API key); `pi.sendMessage` returns `void` and reports later errors only to the receiver.
- `not delivered`: connect failed, rejected (`ok:false`), or too large.
- `unknown`: timeout or connection lost after the message was written; the receiver may have delivered it.

The tool description says: you may retry after `unknown`; duplicates are possible. The receiver catches the throw of a stale pi runtime and replies `ok:false`.

### Delivery into the session
`pi.sendMessage` with custom type `peer-message`, `triggerTurn: true`, `deliverAs: "steer"` (same pattern as `pi-actor-subagents/extensions/actor-subagents/index.ts`). Model-facing content: `[peer message from <full address>]: <JSON.stringify(text)>`. The JSON string encoding keeps message text on one line, so it cannot fake a second provenance header. Structured `details` keep `{from, text}`; a renderer parses them at the display boundary. Every peer-controlled string drawn in the TUI (message text, reply errors, echoed tool arguments) passes `terminalSafe`, so escape sequences cannot drive the terminal or fake pi's UI; reply errors are also capped at 200 characters.

### Agent and human interface
- Tool `send_peer_message({ address, text })` → `delivered` | `not delivered: …` | `unknown: …`.
- Command `/peer-address` makes the session reachable and shows its address to the human.
- The agent gets no tool to read its own address (it replies via the sender address; removes a leak path).

## Files

```
extensions/peer-messages/
  index.ts                 shell: hooks, tool, command, lazy single-flight endpoint lifecycle
  session-secret-key.ts    HKDF(master, id, timestamp) → 32 bytes (pure)
  master-key.ts            race-free load-or-create master.key (0600)
  reachability-marker.ts   which sessions listen on start (pure)
  wire-protocol.ts         ALPN, limits, deadline, request/reply codec (pure)
  peer-endpoint.ts         bind, accept loop, per-connection receive, send (iroh I/O)
  send-outcome.ts          delivered / not-delivered / unknown
  peer-message.ts          session custom message + details parser (pure)
  peer-message-renderer.ts
  terminal-safe.ts         control-character sanitizer for peer-controlled display text (pure)
```

Tests: `session-secret-key` (deterministic; distinct per id and per timestamp), `wire-protocol` (parse), `peer-message` (header forgery), `reachability-marker`, `master-key` (concurrent creation), e2e with two endpoints in one process using the minimal preset and direct `127.0.0.1` addresses (no n0 dependency in CI), including a stalled sender that must not block other senders.

## Reused from pi-actor-subagents
`flake.nix`, `.envrc`, `tsconfig.json`, `.gitignore`, `LICENSE`, `package.json` scripts (tsgo, oxlint, `node --test`, `ci`), `.github/workflows/ci.yml`, `scripts/publish.ts` + tests, `docs/publishing.md`, `AGENTS.md`/`README.md` skeleton; patterns from `agent-message.ts` and `agent-message-renderer.ts`. Not reused: demo SVG pipeline.

New runtime dependency: `@number0/iroh` (napi-rs bindings of iroh 1.x, prebuilt binaries; verified on NixOS with nix node 24).
