interlink-mcp 0.8.0

Cryptographically-authenticated, cross-machine agent-to-agent chat for Claude Code, over MCP
Documentation
# Sessions: multiple live sessions per node (design)

> Status: **implemented in 0.5.0.** Makes N concurrent interlink sessions per
> machine first-class and addressable. The session-scoped announcement changes the
> roster shape (a coordinated, pre-1.0 change), so all nodes must be ≥0.5.0 to see
> each other in `discover`; already-paired peers still chat across the bump.

## The problem

interlink installs as a **user-scope plugin**, so *every* Claude Code session on a
machine spawns its own `interlink-mcp` — and today they're undifferentiated: all
point at one fixed `agent.redb` and all poll the one bare-key inbox. The moment
there's a second session (or an orphan), that gives:

1. **DB collision** — redb is single-writer, so the second server crashes on
   startup with *no tools*.
2. **Inbox fan-out** — every session polls the same inbox, so a message "to fedora"
   is grabbed by a random session (including plain chats that shouldn't intercept
   anything).
3. **Roster spam** — every session announces as the same identity.

We want the opposite: **run several real interlink sessions on one machine, each
its own addressable endpoint**, keeping the node's cryptographic identity and
pair-once semantics.

## Prior art

`louislva/claude-peers-mcp` is a good **UX** reference and a poor **mechanism** one
— it's single-machine (localhost broker, PID-probe liveness). We adopt its UX (a
per-session id; tell sessions apart by **cwd + git repo + a free-text summary**;
pick from a list) and replace the single-machine machinery: identity is our
Ed25519 key, liveness is a **heartbeat TTL** (can't PID-probe a remote box), the
"broker" is our untrusted signed-message bus, and we keep a **node identity +
human-gated pairing** (pair with *A* once, then pick one of *A*'s live sessions).

## The model

Identity is unchanged (a node = an Ed25519 key, paired once). A **live session** is
a new sub-unit under it:

- **`session_id`**, resolved at startup in priority order: an explicit
  `INTERLINK_SESSION` pin → the id Claude Code injects into the MCP subprocess env
  (`CLAUDE_CODE_SESSION_ID`, the normal case) → a random id (fallback off-Claude). It
  keys everything: the session polls `key#session_id` (its own inbox) and registers
  under it. Being Claude's own id, it is **stable across the server restarts Claude
  performs within a session** — so a peer's reply always finds the same inbox — and
  unique per session, so two sessions in the same directory never clash.
- The id **is** surfaced: `discover` shows it, and you pass it to `send_message`
  (a unique prefix works). Humans still recognize a session by **cwd + git repo +
  summary**; `key#session_id` is the routing address.

## Local store: in-memory

Each session uses an **in-memory** outbox + log — **no db file**. This:
- **fixes the crash** (nothing shared to contend for),
- **needs zero cleanup** (no `sessions/*.redb` files to orphan or sweep),
- **survives sleep** (suspend freezes the process with RAM intact; the long-poll
  reconnects on wake).

The trade: the outbox/log are **ephemeral** — lost on a full restart or kill (but
not on sleep). That's fine, because the **bus is the durable layer**: a message
that reached the bus is still keep-until-ack durable for an offline recipient. Only
messages queued *while the bus was unreachable* would be lost on a hard restart —
an edge case that survives sleep.

## Register node + session on start

There is **no `INTERLINK_NODE` flag** and no alias change. A session **announces on
startup** — node and session together — and heartbeats to stay live. **Node
registration is idempotent:** every session under one identity announces the same
`pubkey`, the bus keys the roster by `pubkey#session_id` and groups by `pubkey`, and
a re-announce is an upsert, so N sessions never produce a duplicate node. This is the
plain, intuitive model: if a session is up, it's discoverable; when it closes it
unregisters.

(An earlier design registered lazily — only on the first `send_message` / `set_summary`
— to keep plain, non-interlink chats out of the roster, since the MCP is a user-scope
plugin that every session spawns. It was dropped: it made a fresh session invisible to
itself and created a standoff where two fresh sessions never saw each other. The
trade-off is that every plugin-loaded session now appears; gating that on a real
"is this an interlink session" signal — if one ever becomes detectable — is a separate
call.)

## Polling & presence

Each live session:
- **long-polls only its own inbox** `key#session_id`. There is **no node inbox**  every message, *including a pairing knock*, is delivered to a specific live
  session's inbox, so each inbox is polled by exactly one session and there is **no
  shared mailbox and no fan-out anywhere**. (A node has no persistent daemon;
  nothing handles a message unless a session is live, so a node-level inbox would
  only be a store-and-forward queue — which the keep-until-ack bus already provides
  per inbox. It bought nothing over "reach a live session," so it's gone.)
- **heartbeats** a signed announcement ~every 30s (from startup).

## Bus registry & cleanup

The roster is session-scoped. A live session heartbeats a **signed** announcement
`{ pubkey, session_id, cwd, git_root, summary, ts, sig }`; the bus groups by
`pubkey`, so `/roster` returns each identity → its live sessions.

Presence is **three states on two clocks** — see [`PRESENCE.md`](./PRESENCE.md) for
the full design and rationale:
- **live** — heartbeat age `< 90s`. Fresh, actively heartbeating.
- **away** — silent past 90s but retained up to `AWAY_RETAIN_MS` (~3 days). A slept
  laptop stays **away**, not evicted: `route_session` keeps routing its mail to it
  (it drains on wake) instead of misrouting to an awake sibling. `discover` shows it
  as `away (last seen …)`.
- **gone** — an explicit unregister **or** age past the ~3-day retention. Removed from
  the roster.
- **Queue — long retention, independent of presence.** The inbox `key#session_id` is
  **NOT** dropped when presence goes away/gone — that's the keep-until-ack point, and
  it's what lets a sleeping session get its messages on wake. It's bounded (drop-oldest
  cap + a long sweep), so a truly-abandoned inbox is a small, self-limiting leak.
- **Graceful unregister** on `SIGTERM` (clean Claude-session close) makes the session
  **gone** *immediately* — the authoritative "really closed, not just sleeping" signal —
  and optionally sends a **goodbye** to sticky peers so an idle peer is told rather than
  discovering it on its next send.

The through-line: **silence ≠ death; unregister = death.**

## Discovery & addressing

1. **`discover`** lists each paired online peer → its live sessions:
   `A → [ (a3f2) ~/eden-protocol · git:eden · "installing Hunyuan3D deps" ]`.
2. **`send_message(to:"A", session:"<id>")`** routes to `A_key#<id>`.
   - **Exactly one live session → `session` optional, auto-routes** (zero friction
     for the common case).
   - Several → omit it and the tool returns the list to pick from.
3. **`set_summary("…")`** sets this session's descriptor (and announces it); cwd +
   git_root are filled automatically.
4. **Reply-stickiness:** every message carries `reply_to = my key#session_id`
   (unsigned routing hint), so a reply returns to the exact sending session and,
   within a `task_id`, both sides pin to the session pair. Address once; the
   conversation stays on that desk.

**Pairing folds into this.** To connect to a peer you're not yet paired with,
`discover` still shows it (roster presence is public), and you **knock one of its
live sessions** — the knock is delivered to that session's inbox, its operator
accepts, and the accept is **identity-level** (their key enters `peers.json`, and
yours theirs, for *all* their sessions). Which session you knocked is just the
delivery path. You can only knock a peer that has a **live session** — with no node
daemon, nothing handles a knock otherwise.

## Recovery (the sleep/restart scenarios)

- **Send never hard-errors.** A message to a session that's currently offline is
  **queued** (keep-until-ack); the sender surfaces an informational "session
  offline — queued," not a failure.
- **Sleep → wake: self-heals.** The frozen process wakes as the **same** in-memory
  `session_id`, resumes polling its inbox, **drains the messages queued during the
  sleep**, and re-heartbeats. Seamless; no retry needed. During the sleep it stays
  **away** (not evicted), so peers keep routing to it and see it as "asleep" rather
  than gone.
- **Clean close: immediate.** Graceful unregister tells peers the session is gone,
  so they re-pick right away.
- **Server restart within a session: same id.** Claude re-spawns the stdio server
  (config changes, reconnects) but injects the **same** `CLAUDE_CODE_SESSION_ID`, so
  the new process reattaches to the same bus inbox and drains what queued. Only the
  in-memory store is cold — the durable queue lives on the bus.
- **New Claude session: re-pick.** A genuinely new session (you closed and reopened)
  comes up under a new id, so peers see the old id gone and re-pick the new session.
  (The old inbox's queued messages are the bounded remnant, swept later.)

## What changes (code)

- **`identity`** — add `session_id`, `cwd`, `git_root`, `summary` to the signed
  announcement (extend the announce encoding).
- **`interlink-mcp`** — resolve `session_id` (INTERLINK_SESSION → CLAUDE_CODE_SESSION_ID
  → random fallback); **in-memory store always** (drop
  the `--db`/`INTERLINK_AGENT_DB` durable path for the agent); poll `key#session_id`
  only (no node inbox); knocks are sent to a target session's inbox; announce on
  startup + heartbeat;
  `SIGTERM` graceful-unregister; new `session` arg on `send_message` (auto-route if
  one); new `set_summary` tool; `discover` renders identity→sessions; set/consume
  `reply_to`.
- **`bus`** — roster groups by `pubkey`; `/roster` returns sessions; presence TTL vs.
  queue retention split; `/unregister` for graceful removal.
- **plugin** — no `INTERLINK_NODE`, no alias change; `.mcp.json` drops the agent
  `--db`; README/skill document the model.

## Build order

1. **Stable `session_id` (CLAUDE_CODE_SESSION_ID, random fallback) + in-memory store +
   poll `key#session_id` only.** *Fixes the crash and the fan-out on its own* — ship first.
2. Session-scoped signed announcement + roster grouping; `discover` renders
   sessions; `set_summary`; register-on-start; graceful unregister.
3. `session` arg + auto-route-if-one; `reply_to` stickiness; the recovery behavior
   (queue-not-error; sleep-heal; re-pick). Two-tier presence (live/away/gone) and
   goodbye-on-close land here — see [`PRESENCE.md`]./PRESENCE.md.