Skip to content

feat(peers): linked Dispatch instances — pairing, shadow agents, and remote activity - #978

Draft
lukebrevoort wants to merge 15 commits into
mainfrom
feat/peer-shadow-visibility-and-terminal
Draft

feat(peers): linked Dispatch instances — pairing, shadow agents, and remote activity#978
lukebrevoort wants to merge 15 commits into
mainfrom
feat/peer-shadow-visibility-and-terminal

Conversation

@lukebrevoort

@lukebrevoortlukebrevoort commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

AGENT

Everything below was written by the agent.

What this adds

Links two Dispatch instances as peers so one machine can see and drive agents on another:

  • Pairing & auth (apps/server/src/peers/): short-lived pairing codes, hashed bearer credentials per peer, and a capability set per pairing — allowLaunch, allowMessage, allowFullAccess, and (new in protocol v2) allowEvents. Each route gates on its own capability column.
  • First-run open mode hardening: passwordless instances only answer loopback callers — gated on the arrival interface, not the tailnet listener's bound address, since a 0.0.0.0 bind never starts the secondary listener.
  • Event mirroring: a scoped SSE feed (/api/v1/peers/events) streams local agent upserts to peers. Snapshots buffer live events until written so reconnects can't regress a shadow. Mirrored events keep their originating timestamps, deduped on (agent, created_at) since snapshots replay the full agent list on every reconnect.
  • allowEvents capability: identity/name/type/status always cross (a shadow row can't exist without them); the latest-event text is its own grant, omitted (not nulled) when ungranted so the mirror can distinguish "not shared" from "no event yet". Default on.
  • Per-agent event history: GET /api/v1/agents/:id/events (paged, newest first) — the live SSE feed only carries the latest event, so timeline panes need this for anything before they opened.
  • Web UI: shadow agents render a peer badge in the collapsed card header (usePeerName via a shared peers query), and the terminal pane is replaced by a Remote Activity timeline pane — the tmux session lives on the other machine, and events are already structured data. The center tab relabels Terminal → Activity; the mobile terminal toolbar is suppressed for shadows.

Constraints & decisions

  • Capabilities are columns, not a version/flag — a launch-only CI box or message-only observer is a policy row, not a protocol fork.
  • PEER_PROTOCOL_VERSION bumped 1 → 2 for the allowEvents addition; mismatched peers fail pairing with a clear error.
  • History inserts stay fire-and-forget off the status-indicator critical path.
  • The first-run loopback gate falls back to socket.remoteAddress only when localAddress is absent (injected test requests); real sockets always carry localAddress, so the invariant is unchanged.

v0.35 sub-agent integration (merged from main)

main (v0.35) is merged in, and cloud sessions adopt its sub-agent standard:

  • Remote launches honor the new child contract — a peer-launched shadow nests under the launcher's Sub Agents section by default; child: false keeps it a top-level card. launched_by_agent_id records provenance either way.
  • The sub agent row is shadow-aware: peer badge naming the machine, View activity (remote timeline) instead of terminal attach, and only Archive in the overflow menu — pause/resume/settings act on a tmux session this machine doesn't have.
  • Validated in Playwright against a seeded linked peer: shadow renders as a sub-agent row with the peer badge, clicking it opens the Activity pane with the mirrored timeline (including the link-down break).

Out of scope

  • Relaying a live terminal for remote agents (deliberately replaced by the activity pane).
  • Peer-to-peer file/media transfer; review flows across instances.

Testing

  • pnpm run check, pnpm run finalize:web — clean.
  • Unit suite: peers pairing/auth/mirror tests pass, including new peers-event-mirror.test.ts. Fixed an 18-test regression in api-validation.test.ts caused by the loopback gate (injected requests have no localAddress). dispatch-dev.test.ts timed out only under parallel-suite contention; passes alone.

🤖 Generated with Claude Code

lukebrevoort-mytraand others added 15 commits August 17, 2026 10:29
…ow rows, cross-instance messaging
Adds the peer service: tailnet identity & binding (whois-pinned StableID),
TV-model pairing with per-direction credentials and pair-time launch policy,
dispatch_launch_agent location param with local shadow rows, SSE status
mirroring per peer, and durable outbox + idempotency-keyed message delivery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconciler skips shadow rows; peer auth fails closed without a password;
tailnet listener destroys upgraded sockets and skips wildcard-host binds;
peer SSE is scoped (agent.upsert only, slim fields) with snapshot-first
buffering; pairing handshake carries a protocol version; shadows are marked
stale when their peer drops; UI: role-split pairing cards, password hints,
peer display name on the Location badge; remote-launch context limits
documented in tool description and child preamble.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Buffered delivery without a snapshot would hold events forever; destroying
the connection makes the subscriber reconnect and re-snapshot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the review of the linked-instances branch.
Capability model — pairing granted three separable powers (launch, message,
full-access) behind one allow_launch boolean, so the messages route ended up
with no gate at all and the launch route honored any cwd off the wire:
- peer_credentials carries allow_launch/allow_message/allow_full_access;
each route gates on its own member. full-access is off by default.
- incoming launches must land inside a repo root this instance advertises,
resolved through realpath and compared segment-wise.
- a pairing claim can no longer take over a live peer slot pinned to a
different tailnet node.
Races:
- re-pairing rotates the outbound token, but the SSE loop was keyed on peer id
alone and kept presenting the revoked one forever. Subscriptions now carry a
url+token fingerprint and restart when it changes.
- shadow rows seeded from the launch response status instead of a hardcoded
"creating"; the peer's own upsert lands before the shadow exists.
- qualified peer addresses are recognised by shape, not by "contains a colon".
Agents named "fix: sse race" were being routed to a nonexistent instance.
Efficiency and lifecycle:
- whois memoized per remote address (30s, negatives uncached); auth folded to
one query; last-seen writes throttled to once a minute.
- outbox drains per peer, concurrently, abandoning a peer on first failure,
with a dead-letter cap. One closed laptop no longer holds every peer's mail.
- shadows are reaped when a snapshot stops mentioning them and when a peer is
unlinked, instead of freezing at their last status.
Also drops the unrelated pnpm-workspace shamefullyHoist change.
The location an agent targets is a name someone has to choose, so make it
choosable and make the tool descriptions say what the choices are.
- Peers carry a LOCAL label (what this machine calls them) alongside
reported_name (what they call themselves). Rename inline from Linked
Instances; the remote is never told, because "Cloud" describes where a
peer sits relative to here. Labels are unique, so `location` is never
ambiguous, and they survive a re-pair.
- Name this instance from the same panel — it seeds the label peers adopt.
- dispatch_launch_agent's `location` description now lists the actual linked
instances, with "not responding recently" / "launching not permitted"
noted inline. Computed per MCP request, since a peer can be added or
renamed between two calls; tool descriptions are the only place a model
learns what exists.
- The pairing cards expose the three capabilities as switches instead of
hardcoding allowLaunch: true, and peer rows show what was granted.
- The wildcard-host bind state finally renders, as a neutral note rather
than a blocker — the server already serves the tailnet in that case.
Adds coverage for the newly gated paths: capability independence, slot
takeover, label uniqueness and rename, cwd containment (including traversal),
and the full-access gate.
Badge renders a div, so wrapping the peer name row in a <p> tripped
validateDOMNesting. The rename form also swallowed the server's 409 on a
duplicate label — the user pressed Save and nothing happened.
pnpm 11 no longer reads shamefully-hoist from .npmrc or the pnpm field in
package.json — it warns about the latter on every command and silently
ignores the former. Same values, new home.
The hoist is load-bearing: e2e/ sits at the repo root outside any workspace
package, so its `import pg` only resolves against a flattened node_modules.
Without it every spec fails to collect with "Cannot find package 'pg'".
…dress
With DISPATCH_HOST=0.0.0.0 the secondary tailnet listener never starts — the
wildcard bind already serves the tailnet — so isBoundAddress() returned false
for tailnet callers and passed them into first-run open mode. A passwordless
instance in that configuration served its entire API to every node on the
tailnet, which is exactly what the comment above the check said it prevented.
Gate on "did this arrive on loopback" instead, which is the actual invariant
and also covers LAN and any other interface a wildcard bind picks up.
Found by trying to stand this up on a tailnet VM.
listPeerLocations decides 'reachable' from last_seen_at, but only
requirePeerAuth wrote it — i.e. only when a peer calls US. A peer we only
ever dial (the normal shape for a cloud box that just answers) therefore went
stale and got advertised to the model as "not responding recently" while it
was perfectly healthy.
A connected outbound event stream is proof of life, so stamp there too.
Caught on a live tailnet pairing: the launch tool description read
`Linked instances: "Cloud" (not responding recently)` for a VM that was
answering every request.
Adds an allow_events capability to pairing (protocol v2), mirrors remote
latest events with their original timestamps, exposes per-agent event
history at /api/v1/agents/:id/events, and renders shadow agents with a
peer badge and a remote activity pane in place of the terminal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Injected test requests have no socket.localAddress, so the loopback gate
401'd every passwordless inject() suite. The fallback still requires a
loopback address, so the first-run invariant is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bility-and-terminal
# Conflicts:
#	apps/server/src/routes/mcp.ts
#	apps/server/src/server.ts
#	apps/server/src/server/mcp-handlers.ts
#	apps/server/src/shared/mcp/agent-launch-tools.ts
Remote launches now honor the child contract: a peer-launched shadow
nests under the launcher's Sub Agents section (child: false stays
top-level), and launched_by_agent_id records provenance either way.
The sub agent row is shadow-aware — peer badge, View activity instead
of terminal attach, and only Archive in the overflow menu since
pause/resume/settings act on a tmux session this machine doesn't have.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0041_agent-launched-by.sql shipped in v0.35; the unshipped peers
migration moves to 0042. Every statement is IF NOT EXISTS-guarded, so
instances that already applied it under the old name no-op through the
re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@lukebrevoort@lukebrevoort-mytra