feat(peers): linked Dispatch instances — pairing, shadow agents, and remote activity - #978
Draft
lukebrevoort wants to merge 15 commits into
Draft
feat(peers): linked Dispatch instances — pairing, shadow agents, and remote activity#978lukebrevoort wants to merge 15 commits into
lukebrevoort wants to merge 15 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.0.0.0.0bind never starts the secondary listener./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.allowEventscapability: 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.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.usePeerNamevia 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
PEER_PROTOCOL_VERSIONbumped 1 → 2 for theallowEventsaddition; mismatched peers fail pairing with a clear error.socket.remoteAddressonly whenlocalAddressis absent (injected test requests); real sockets always carrylocalAddress, 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:childcontract — a peer-launched shadow nests under the launcher's Sub Agents section by default;child: falsekeeps it a top-level card.launched_by_agent_idrecords provenance either way.Out of scope
Testing
pnpm run check,pnpm run finalize:web— clean.peers-event-mirror.test.ts. Fixed an 18-test regression inapi-validation.test.tscaused by the loopback gate (injected requests have nolocalAddress).dispatch-dev.test.tstimed out only under parallel-suite contention; passes alone.🤖 Generated with Claude Code