# Federation Device-Routing & Satellite-Parity Audit — 2026-05-28

Branch: `feat/activity-page-running-agents-restyle`
Author: Coder agent (audit pass for "make device dropdown + satellite parity actually work" task)
Scope: route-to-selected-node correctness + satellite UI parity across InfiniBot.

This doc enumerates what works, what's broken, and what was changed in this
session. Anything left for follow-up is called out explicitly with file:line.

---

## Section A — `renderFederationNodeSelect` callers

The shared dropdown lives at `ui/src/ui/components/federation-node-select.ts:86`.
Every call site is below.

| # | File:line | What it controls | State field | Downstream routing | Round-trips correctly? |
|---|-----------|------------------|-------------|--------------------|------------------------|
| 1 | `ui/src/ui/views/terminals.ts:1217` | Global page-level **filter** (which device's terminals to show in the list) | `TerminalsState.filterNodeId` (persisted in `localStorage` `infinibot.terminals.filterNodeId`) | Client-side filter only; `terminal.list` is not filtered server-side. Also seeds `createForm.nodeId` so the spawn dialog defaults to the filtered device. | ✅ (filter is local-only; spawn pre-fill works via `setTerminalFilterNode`, `controllers/terminals.ts:350-358`) |
| 2 | `ui/src/ui/views/terminals.ts:1875` | Create-dialog device picker (where to spawn the new terminal) | `TerminalsState.createForm.nodeId` | `createTerminalSession()` → `spawnSingleSession()` (`controllers/terminals.ts:824-857`) stores `nodeId` on the `TerminalSession`; subsequent lifecycle calls (`createPtyProcess`, `sendPtyInput`, `resizePtyProcess`, `destroyPtyProcess`, `terminal.isAlive`, `terminal.getBuffer`, `terminal.agent.invoke`) honor it via the `federation.node.invoke` proxy (`controllers/terminals.ts:1036-1053, 1135, 1146, 1178-1187`). | ✅ |
| 3 | `ui/src/ui/views/masonry-grid.ts:4984` | Add-panel device picker (which device the next terminal panel spawns on) | `MasonryGridView.addPanelNodeId` (persisted in `localStorage` `infinibot:grid:addPanelNodeId`) | `addTerminalPanel(state, title, cli, cwd, nodeId)` (`controllers/masonry-grid.ts:662-699`) stores `nodeId` on the `GridPanel`; panel's `onSpawn` (`views/masonry-grid.ts:13419-13470`) passes it to `createTerminalSession` which uses the same lifecycle pipeline as terminals page. Per-panel dropdown also exists at `views/masonry-grid.ts:13516-13525`. | ✅ |

Result: **all three current callers route correctly end-to-end** through the
existing `federation.node.invoke` gateway proxy + the `nodeId`-aware UI
lifecycle in `controllers/terminals.ts`. No UI fix is required for these
flows.

What was already wired (commits prior to this audit):
- 750181f, 2c4f846 introduced the multi-device picker + project/directory
  federation associations.
- 741b9f0, 43a90be added autonomy + activity infrastructure that surfaces the
  federation status correctly.

---

## Section B — `terminal.spawnAgent` callers (server-side)

`terminal.spawnAgent` (`src/gateway/server-methods/terminal.ts:499`) is the
sub-agent / autonomous-run terminal-first spawn entry point. Before this
session it accepted no `nodeId` and **always spawned a PTY on the receiving
gateway**.

| # | Caller | nodeId in scope? | After this session |
|---|--------|------------------|--------------------|
| 1 | `src/autonomy/orchestrator.ts:180` (defaultSpawnSubagent terminal path) | No (queue items don't carry a target node) | Unchanged — defaults to local. Adding nodeId here would require schema changes to the autonomy queue + UI; **out of scope** for this branch (a separate cloud agent is patching autonomy under `coder-autonomy-reliability-patch-nxtf` — see hard constraints). |
| 2 | `src/workflows/runner.ts:583` (one-shot workflow run, terminal mode) | No (workflow row has no nodeId column) | Unchanged — defaults to local. Tracked as follow-up. |
| 3 | `src/workflows/continuous-runner.ts:385` (per-iteration spawn) | No | Same as #2. |
| 4 | `src/gateway/server-methods/sessions.ts:860` (sessions.spawn fallback when target agent's `execution.mode === "terminal"`) | Indirect — the surrounding handler already routes to a remote node via `spawnRemoteSession` for non-local node params (see `sessions-spawn-tool.ts:229`), so the terminal-fallback path is only hit for local spawns. No change needed. | Unchanged (already correct for local). |
| 5 | `src/agents/tools/sessions-spawn-tool.ts:384` | Same as #4 — node param handled earlier in the same function. | Unchanged. |
| 6 | `src/agents/tools/infinibot-terminal-tool.ts:328` (MCP tool `spawn-coder` action) | No (tool signature doesn't expose `nodeId` yet) | Unchanged — defaults to local. Tracked as follow-up. |

The fix shipped in this session adds the **server-side capability** for any
caller to opt into remote spawning. Adding `nodeId` to the caller signatures
above (workflows, orchestrator queue, MCP tool params) is left as
follow-up — each requires its own product decision (UI affordance, schema
change, etc.).

---

## Section C — Gateway handlers wrapped with `applySatelliteProxy`

`applySatelliteProxy` (`src/federation/satellite-context.ts:64`) wraps an
entire handler map so that, in connected-satellite mode, every method is
forwarded to master via `satellite.invokeOnMaster`.

| Handler file | Wrap status (before this session) | Comment |
|--------------|-----------------------------------|---------|
| `databases.ts` | `applySatelliteProxy` (line 1337) | Correctly wrapped. All `db.*` reads/writes go to master in satellite mode. |
| everything else | **Not wrapped** | Every other handler runs locally on satellites, even when its state is logically master-owned. |

### Why "every other file" is mixed:

Many handlers **must** run locally on satellites (per-node resources):
`terminal.*`, `exec.*`, `fs.*`, `git.*`, `mcp-*`, `browser.*`, `ssh-devices.*`,
`file-transfer.*`, `agent-tools.*`. Wrapping these would break local PTYs,
local file I/O, local git operations, etc.

Other handlers **should** be proxied because their state is logically global
to the federation (master-owned):
- `notes.ts` (302 lines) — UI notes registry. Currently each satellite has its
  own notes file → they don't see each others' notes.
- `characters.ts` (278 lines) — character store. Same issue.
- `kanban.ts` (1907 lines) — projects/tasks. Note: the kanban store is in
  fact migrated through the `databases.ts` adapter (kanban-adapter.ts), which
  IS proxied. **Verify whether kanban.ts handlers go through the wrapped
  adapter before wrapping** — risk of double-proxy or local short-circuit.
- `channels.ts` (288 lines) — recent fix d8f9bcd restored config schema
  delivery. The schema is config-driven and should match master. Channel
  **inbound webhook delivery** must stay local though, so a blanket wrap is
  not safe — needs selective per-method wrapping.
- `discipline.ts` (717 lines) — daily logging. State should be shared so the
  user's discipline view is identical on every device.
- `projects.ts` (680 lines) — same situation as kanban.
- `business-summary.ts`, `business.ts`, `kpi.ts`, `leads.ts` — org-global
  data, should proxy.
- `autonomy.ts` — **DO NOT TOUCH** this session (a separate cloud agent is
  patching it under runId `coder-autonomy-reliability-patch-nxtf`).
- `autopilot.ts`, `subagents.handlers.ts` — autonomy-adjacent; leave for
  follow-up coordinated with the autonomy patch.

### Mitigation shipped this session:

Added `proxyMethodsToMaster(handlers, methodNames)` to
`src/federation/satellite-context.ts` so future PRs can wrap **specific**
methods of a handler file without committing to a full wrap. Test added.

**No handler files were newly wrapped in this session** — the risk of a
silent local-side-effect breakage on a satellite is non-trivial and each
handler needs its own per-method audit. The new helper makes that audit a
mechanical one-method-at-a-time process for follow-up.

---

## Section D — Top-level views (priority pages only)

For each, the gateway calls invoked from the view + its controller, and the
satellite-parity status.

| View | Gateway calls | Satellite parity | Reason / notes |
|------|---------------|------------------|----------------|
| `terminals.ts` (+ `controllers/terminals.ts`) | `terminal.list`, `terminal.create`, `terminal.write`, `terminal.resize`, `terminal.destroy`, `terminal.isAlive`, `terminal.getBuffer`, `terminal.agent.invoke`, `terminal.claude.invoke`, `federation.nodes.list`, `node.list`, `federation.resources.directories`, `directories.list`, `git.*`, `fs.systemDirs`, `fs.browseFolder`, `kanban.projects.list`, `kanban.tasks.list`, `sessions.list`, `exec.run` | ✅ **Works on satellite** (intentionally — PTYs run locally; restoreLiveTerminals fans out to peers using `federation.node.invoke`) | The controller already supports federated terminal listing across nodes (controllers/terminals.ts:714-792). |
| `masonry-grid.ts` (+ controller) | (no controller-level gateway calls; panels fetch their own state) | ✅ — terminal panels reuse the terminals pipeline. Other panel types (kanban, database, chat) inherit whatever parity their underlying handlers provide. | Kanban/database panels work via the wrapped `databases.ts`. Chat panel parity depends on `sessions.ts` / `chat.ts` which are NOT wrapped — see gap below. |
| `kanban.ts` view | `kanban.projects.*`, `kanban.tasks.*`, plus database adapter calls | ⚠️ **Mixed** — database-backed rows go through the wrapped `databases.ts` (✅), but any methods on `kanban.ts` that bypass the adapter (e.g. legacy stores) run locally on satellite (❌). Untested — needs per-method verification. | 1907-line handler file; requires careful per-method audit before wrapping. |
| `agents.ts`, `agents-real.ts`, `agents-management.ts` | `agents.*`, `agent.*`, `agent-mesh.*` | ⚠️ Unknown — likely renders local agent registry only. | These handler files are not wrapped. Out of scope for this session — needs follow-up. |
| `chat.ts`, `chat-sessions.ts` | `chat.*`, `sessions.list`, `sessions.send`, `sessions.spawn` | ⚠️ **Sessions spawn IS federation-aware** (sessions-spawn-tool already routes via spawnRemoteSession), but the chat history/message persistence path is **not wrapped** → satellite users see only their local conversation slice. | Follow-up. |
| `directories.ts` | `directories.list`, `federation.resources.directories` | ✅ Already federated via `federation.resources.directories` aggregator. | No change needed. |
| `federation-dashboard.ts` | `federation.*` | ✅ Master-aware by design. | No change. |
| `activity-panel.ts` (and feed) | `sessions.list`, `subagents.list`, `subagents.history`, autonomy event bus | ⚠️ Per-node only. Satellite shows its own activity feed (which is consistent with PTYs being local) but cross-node activity aggregation is missing. | Out of scope — design question. |
| `autonomy.ts` | `autonomy.*` | ⚠️ Not wrapped. | **Out of scope this session per task constraint** (separate cloud agent is patching autonomy). |
| `channels.ts` | `channels.list`, `channels.config.get/set`, etc. | ⚠️ Mixed — recent fix d8f9bcd restored config schema delivery. The CONFIG state should be master-shared, but inbound webhook delivery must stay local. Needs per-method wrap. | Use the new `proxyMethodsToMaster` helper in follow-up. |

---

## Section E — `federation.devices.list` (canonical device list)

The canonical method is **`federation.nodes.list`**
(`src/gateway/server-methods/federation.ts:574`).

Returned shape:
- On master: array of `{ id, name, status, role, isLocal, capabilities,
  platform, lastSeen, connectedAt?, lastHeartbeat?, load?, activeSessions? }`
- On satellite: a single entry pointing at master.
- On standalone: a single local entry.

UI consumption (`ui/src/ui/controllers/terminals.ts:547-601`) calls this
method **and** the mobile-paired-devices `node.list`, merges by id, and
falls back to a synthetic local entry on failure. This is the canonical
source the dropdowns already consume.

**Result: no new gateway method is required.** No duplicate "devices list"
endpoint needs to be added. `federation.nodes.list` is already wired into
every place the dropdown gets its options.

---

## Section F — `hub.invokeOnNode` usage / routing pattern

`FederationHub.invokeOnNode` is **private** on the hub class
(`src/federation/hub.ts:2897`). The public RPC mechanism for cross-node
gateway calls is `federation.node.invoke`
(`src/gateway/server-methods/federation.ts:1193`), which casts to access
`invokeOnNode` and routes through `gateway.proxy.invoke` on the satellite
side.

Existing routing patterns:
- `agent` / `session.spawn` → `hub.spawnRemoteSession`
- `session.send` → `hub.sendRemoteMessage`
- Everything else → generic `gateway.proxy.invoke` (the satellite gateway
  parses the inner method/params and dispatches to its own handler map,
  returning a JSON-encoded reply)

This is the same wire format the fix in this session uses for
`terminal.spawnAgent` cross-node forwarding (see Section G).

---

## Section G — Fixes shipped in this session

### G.1 `terminal.spawnAgent` accepts and forwards `nodeId`

**File:** `src/gateway/server-methods/terminal.ts:499`

Added an optional `nodeId?: string` param. When supplied with a non-local
value AND a federation hub is present, the call is forwarded to the target
satellite via `hub.invokeOnNode(nodeId, "gateway.proxy.invoke", { method:
"terminal.spawnAgent", params })`. The satellite spawns the PTY in its own
manager; output is streamed back via the existing `terminal.output`
broadcast already wired through the federation pipe
(`src/gateway/server-runtime-state.ts`).

When `nodeId` is `"local"`, empty, or omitted, behavior is unchanged.

**Helper:** `src/federation/hub-forward.ts` (new) —
`forwardGatewayCallToNode(nodeId, method, params, timeoutMs?)` encapsulates
the cast-to-private-method + reply-parsing dance so any future handler can
reuse the same forwarding pattern in one line.

### G.2 `proxyMethodsToMaster` helper

**File:** `src/federation/satellite-context.ts`

Added an opt-in `proxyMethodsToMaster(handlers, methodNames)` helper that
wraps **only the named methods** of a handler map with the satellite proxy.
Use this in follow-up to wire per-method satellite parity for handlers
where a blanket `applySatelliteProxy` would break local-only side effects
(channels webhook delivery, kanban event broadcasts, etc.).

### G.3 Tests

- `src/gateway/server-methods/terminal.spawnAgent.test.ts` — verifies the
  new nodeId routing: local short-circuit, non-local forwarding, no-hub
  fallback.
- Extended `src/federation/satellite-context.test.ts` with cases for
  `proxyMethodsToMaster`.

---

## Section H — Recommended follow-up (ordered by user-visible impact)

1. **Wire `nodeId` through the workflow runner & autonomy queue** so a user
   can choose a target satellite when scheduling an autonomous run. Touches:
   workflow schema, autonomy queue rows, scheduling UI, callers in
   `runner.ts:583`, `continuous-runner.ts:385`, `orchestrator.ts:180`. ~300+
   LOC across packages — schedule with the autonomy patch agent.
2. **Apply `proxyMethodsToMaster` to `notes.ts`** (full wrap is safe — notes
   are read/write store-only). ~5 LOC.
3. **Apply `proxyMethodsToMaster` to `characters.ts`** (same reasoning). ~5
   LOC.
4. **Per-method wrap of `channels.ts`** — wrap `channels.list`,
   `channels.config.*` but NOT `channels.deliver` / webhook ingress.
   Requires reading channels.ts handler-by-handler. ~30 LOC.
5. **Per-method wrap of `discipline.ts`** — same approach.
6. **Audit `agents.ts` / `agents-real.ts` / `agents-management.ts`** for
   per-node vs federation-shared splits. Probably the biggest unknown.
7. **Make `infinibot-terminal-tool.ts` accept `nodeId` in its `spawn-coder`
   action** so MCP-driven workflows can target satellites. ~10 LOC + tool
   schema update.
8. **Cross-node activity aggregation** — surface satellite agent activity
   in the master's `/activity` feed. Design question first, then implement.

---

## Section I — UI parity claims explicitly NOT verified

Per task constraint ("you can't easily test the actual rendering — document
what you wired and explicitly state that visual verification needs the
user"):

- I have **not** opened the UI and confirmed visually that dropdowns,
  panels, or satellite-mode views render correctly. The audit above is
  code-level: it confirms that the wiring exists and that the data flows
  through the right RPC paths. **A human or QA agent needs to load each
  priority page in satellite mode and confirm the UI matches the master.**
- The new `terminal.spawnAgent` nodeId routing has unit tests but **has
  not been exercised against a real federation pair**. End-to-end
  verification requires two gateways with one connected as a satellite.

---

## Section J — Follow-up session (later same day) — Pillars completed

Continued the rollout after the helper landed:

### J.1 Handler wraps applied (Job 1 — Pillar 2)

| Handler file | Wrap | Notes |
|--------------|------|-------|
| `notes.ts` | `applySatelliteProxy` (blanket) | All 7 methods proxy. Notes are a single shared store. |
| `characters.ts` | `proxyMethodsToMaster` (all 9 methods enumerated) | `create`/`addImage` still take optional `imagePath` — when proxied that path is interpreted on master, so the supported satellite flow is the base64 `imageData` path. |
| `channels.ts` | `proxyMethodsToMaster(["channels.status"])` | Only the pure-read status proxies. `channels.logout` runs locally (per-node `context.stopChannel`). |
| `discipline.ts` | `applySatelliteProxy` (blanket) | All ~25 methods proxy. Module-level `startDisciplineAutoLog` / `startTelegramBridge` still fire on satellite at boot — those are NOT request handlers, so the proxy wrap doesn't reach them; tracked as separate follow-up. |
| `tts.ts` | `proxyMethodsToMaster` (7 of 8 methods) | Wrapped: status, enable, disable, providers, setProvider, voiceSettings, setVoiceSettings. NOT wrapped: `tts.convert` (writes a node-local audio file path). |
| `voicewake.ts` | `applySatelliteProxy` (blanket) | Wake-word triggers are user-level. |

Tests added: `src/gateway/server-methods/satellite-wrap.test.ts` (9 cases),
`src/gateway/server-methods/voice-satellite-wrap.test.ts` (7 cases).

### J.2 `sessions.spawn` nodeId routing (Job 2)

Added optional `nodeId?: string` (and legacy `node?: string` accepted as
fallback for the existing UI wire). When the param resolves to a non-local,
non-empty value AND a federation hub is present, the entire call is
forwarded via `forwardGatewayCallToNode` (same pattern as
`terminal.spawnAgent` commit 786813a). The remote satellite spawns the
sub-agent locally; on the master the run is recorded via
`registerRemoteSession` so the activity feed renders the cyan node chip
that landed in commit 2c846f6.

UI: `ui/src/ui/controllers/agent-mesh.ts` `spawnAgent()` now sends BOTH
`node` (legacy) and `nodeId` (canonical), so the dialog's existing
`targetNodeId` actually reaches the backend.

MCP: `src/agents/tools/sessions-spawn-tool.ts` was already federation-aware
via `spawnRemoteSession` + `node` arg — no change needed there.

Tests: `src/gateway/server-methods/sessions.spawn.test.ts` (8 cases: local
short-circuit, remote forward, legacy `node` param, no-hub fallback,
forward-error surface, empty-task guard).

### J.3 Voice page routing from satellites (Job 3)

Audited every `src/gateway/server-voice-*.ts` file:

- All voice surfaces are HTTP-route handlers (`/api/voice/tts`,
  `/api/voice/stt`, `/api/voice/chat`, `/api/voice/proxy`,
  `/api/voice/connection`, `/api/voice/spawn`, `/api/voice/stream`,
  `/api/voice/config`), NOT gateway methods. They resolve their upstream
  Kokoro/Speaches/LiveKit URL via `resolveVoiceUrls()` which is already
  master-aware (commit ce8da33) — so the audio bytes always traverse the
  master's voice containers regardless of which node served the HTTP
  request. No wrap needed for these.
- The gateway methods that DO exist for voice are config / prefs
  (`tts.*`, `voicewake.*`). Those got the per-method wrap (see J.1) so
  the satellite UI shows + edits master's voice prefs.
- `talk.mode` (server-methods/talk.ts) intentionally left LOCAL: it
  broadcasts to the satellite's WS clients, which is the desired
  behavior (per-device UI cue).

Voice-driven sub-agent spawn from a satellite now goes through the Job 2
sessions.spawn nodeId routing, so the spawn either runs on the satellite
(if no node selected) or routes to the chosen master/peer.

### J.4 Intentionally deferred

- Module-level voice startups (`startDisciplineAutoLog`,
  `startTelegramBridge`, `startTelegramCompletions`) still run on a
  satellite at boot. Stopping them on satellites needs a separate
  boot-time guard; not a request-handler concern.
- `tts.convert` deliberately not wrapped — would return a master-local
  audio path the satellite couldn't read. The HTTP `/api/voice/tts`
  route already handles cross-node TTS correctly (audio bytes stream
  back to the satellite browser).
- `channels.logout` left local: token-clearing is a master-side concern
  but the per-node `context.stopChannel` step is local. A future
  per-method split is the right fix.
- Wiring `nodeId` through workflow runner + autonomy queue (audit
  follow-up #1) — still outstanding, hard cap reached.
- `terminal.spawnAgent` invocations from autonomy / workflows
  (callers #1–#6 in Section B) still default to local; only the new
  `sessions.spawn` and `terminal.spawnAgent` entry points accept
  `nodeId` today.
