Remote Daemon Communication — System Architecture
This document describes the full architecture of bot0's remote daemon communication system. It covers the Hub relay server, protocol design, client implementations, security model, UI integration, and operational details. Every component, message type, data flow, and security boundary is documented.
Table of Contents
- Overview
- Architecture Diagram
- Security Model
- Hub Protocol
- Hub Server
- Daemon Hub Client
- Desktop Hub Client
- Desktop UI Integration
- Schema & Data Layer
- Bytespace API Routes
- Discovery & Heartbeat
- Connection Lifecycle
- File Reference
Overview
bot0 daemons run on user machines (laptops, servers, VMs). Each daemon communicates locally with its Desktop app via Unix socket IPC. The remote daemon communication system enables a Desktop app on one machine to talk to a daemon on another machine, with the exact same capabilities: streaming responses, tool execution, permission prompts, question prompts, session resume, and task cancellation.
Design principles:
- Hub is DUMB — No AI, no data storage, no credentials. Pure authenticated message routing.
- Same UX — Remote tasks look identical to local tasks in the Terminal UI. Same streaming, same permission prompts, same progress events.
- Security first — Every WebSocket connection requires session token + hardware-bound device signature. Cross-user isolation enforced at the Hub.
- Local unaffected — Hub connectivity is optional. Local daemon communication via Unix socket is completely independent.
- Resilience — Exponential backoff reconnect, heartbeat-based dead connection detection, graceful degradation when Hub is unreachable.
Key terms
| Term | Meaning |
|---|---|
| Hub | WebSocket relay server on Fly.io. Routes messages between daemons and desktops. |
| DaemonHubClient | Runs inside the daemon process. Connects to Hub, receives remote tasks, streams results back. |
| DesktopHubClient | Runs inside the Electron main process. Connects to Hub, submits tasks to remote daemons, receives progress. |
| deviceId | SHA-256 hash of the device's public key. Hardware-bound, globally unique. |
| sourceDesktopDeviceId | The deviceId of the Desktop that submitted a task. Used for routing responses back. |
| targetDaemonDeviceId | The deviceId of the daemon that should execute a task. |
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────────┐
│ MACHINE A (Desktop) │
│ │
│ ┌──────────────────┐ IPC ┌──────────────────┐ │
│ │ Desktop App │◄────────────►│ Local Daemon │ │
│ │ (Electron) │ (Unix sock) │ (Node.js) │ │
│ │ │ │ │ │
│ │ DesktopHubClient│──┐ │ DaemonHubClient │──┐ │
│ └──────────────────┘ │ └──────────────────┘ │ │
│ │ WSS │ WSS │
└────────────────────────┼──────────────────────────────────┼─────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ HUB │
│ (Fly.io) │
│ │
│ • Authenticates via Bytespace API │
│ • Routes by deviceId within same userId │
│ • Heartbeat + dead connection detection │
│ • Updates daemon online status in Bytespace │
│ │
│ STORES NOTHING — PURE RELAY │
└──────────────────────────┬───────────────────────┘
│
┌───────────────┼───────────────┐
│ WSS │ WSS
▼ ▼
┌────────────────────────────────────┐ ┌────────────────────────────────┐
│ MACHINE B (Server) │ │ MACHINE C (Laptop) │
│ │ │ │
│ ┌──────────────────┐ │ │ ┌──────────────────┐ │
│ │ Remote Daemon │ │ │ │ Remote Daemon │ │
│ │ (Node.js) │ │ │ │ (Node.js) │ │
│ │ │ │ │ │ │ │
│ │ DaemonHubClient │ │ │ │ DaemonHubClient │ │
│ └──────────────────┘ │ │ └──────────────────┘ │
└────────────────────────────────────┘ └────────────────────────────────┘
Data flow for a remote task
Desktop A Hub Daemon B
│ │ │
│ hub:task_submit │ │
│ ───────────────────────►│ │
│ │ hub:task_submit │
│ │ ────────────────────────►│
│ │ │
│ │ │ (runs agent loop)
│ │ │
│ │ hub:task_progress │
│ hub:task_progress │◄────────────────────────│ (streaming)
│◄────────────────────────│ │
│ │ │
│ │ hub:task_progress │
│ hub:task_progress │◄────────────────────────│ (permission_request)
│◄────────────────────────│ │
│ │ │
│ hub:permission_reply │ │
│ ───────────────────────►│ │
│ │ hub:permission_reply │
│ │ ────────────────────────►│
│ │ │
│ │ │ (continues execution)
│ │ │
│ │ hub:task_result │
│ hub:task_result │◄────────────────────────│
│◄────────────────────────│ │
│ │ │
Security Model
Authentication Chain
Every WebSocket connection to the Hub is authenticated via a three-layer verification chain:
Client (Daemon/Desktop)
│
│ 1. Session token (Supabase JWT)
│ 2. Device ID (SHA-256 of hardware public key)
│ 3. Device signature (ECDSA P-256, signed by hardware key)
│
▼
Hub Server
│
│ Forwards credentials to Bytespace API for validation
│
▼
Bytespace API (/api/proxy/hub/validate)
│
│ 1. Validates session token (Supabase auth)
│ 2. Validates device signature (ECDSA P-256 verify)
│ 3. Verifies device belongs to authenticated user
│ 4. Verifies device is active and not revoked
│ 5. Checks signature timestamp freshness (< 5 min)
│
▼
Returns: { valid: true, userId: "..." }
Hardware-Bound Device Authentication
Device signatures use hardware-bound keys that never leave the hardware security module:
| Platform | Hardware | Key Storage |
|---|---|---|
| macOS | Secure Enclave | Apple Silicon / T2 chip |
| Windows | TPM 2.0 | Hardware TPM module |
| Linux | TPM 2.0 | tpm2-tools interface |
The deviceId is the SHA-256 hash of the device's public key. The private key never leaves the hardware — signing operations happen inside the Secure Enclave / TPM. This means:
- Stolen session tokens are useless without the device's hardware key
- Stolen device IDs are useless without the corresponding private key
- Replay attacks are prevented by signature timestamps (rejected if > 5 min old)
Cross-User Isolation
The Hub enforces strict user isolation on every routed message:
private routeToDaemon(client: AuthenticatedClient, msg: HubMessage): void { const target = this.daemonClients.get(targetDeviceId); // Cross-user isolation: verify same userId if (target.userId !== client.userId) { this.sendError(client.ws, 'Access denied', msg.requestId); return; } this.sendMessage(target.ws, msg); }
A user's Desktop can only communicate with that same user's daemons. There is no mechanism to address another user's daemon.
Hub-to-Bytespace Server Auth
The Hub server calls Bytespace API for two purposes:
-
Client validation (
/api/proxy/hub/validate): Uses the client's own credentials (session + device signature), forwarded in HTTP headers. StandardauthenticateProxyRequest()pattern. -
Daemon status updates (
/api/proxy/hub/daemon-status): Uses a shared secret (HUB_API_SECRET), configured as an environment variable on both Hub and Bytespace. This is server-to-server authentication — the Hub reports daemon connect/disconnect events.
Hub ──Bearer HUB_API_SECRET──► Bytespace /api/proxy/hub/daemon-status
Auth Timeout
Clients must authenticate within 10 seconds of connecting. If the hub:auth message is not received within this window, the connection is terminated with code 4001.
const authTimeout = setTimeout(() => { ws.close(4001, 'Authentication timeout'); }, AUTH_TIMEOUT); // 10_000 ms
What the Hub Stores
Nothing. The Hub is a pure relay:
| Data | Stored? | Details |
|---|---|---|
| Messages | No | Forwarded immediately, never persisted |
| Credentials | No | Session tokens forwarded to Bytespace for validation, not stored |
| API keys | No | Hub has no concept of API keys |
| Session history | No | Daemon handles all persistence |
| User data | No | Hub only knows userId for routing isolation |
| Connection state | In-memory only | Map<deviceId, AuthenticatedClient> — lost on restart |
Transport Security
- Production: WSS (TLS) enforced by Fly.io edge termination
- Development: WS (plaintext) on localhost:3001
- Hub URL selection:
is.dev ? 'ws://localhost:3001' : 'wss://bot0-hub.fly.dev'
Hub Protocol
All messages are JSON over WebSocket. Every message extends HubMessageBase:
interface HubMessageBase { type: HubMessageType; // Discriminator requestId: string; // Unique ID for correlation timestamp: number; // Unix milliseconds }
Message Type Reference
| Message | Direction | Purpose | Key Fields |
|---|---|---|---|
hub:auth | Client → Hub | Authenticate connection | clientType, sessionToken, deviceId, deviceSignature, deviceTimestamp |
hub:auth_result | Hub → Client | Auth success/failure | success, userId?, error? |
hub:daemon_register | Daemon → Hub | Announce daemon name | daemonName, daemonId, deviceId |
hub:desktop_register | Desktop → Hub | Register desktop | deviceId |
hub:task_submit | Desktop → Hub → Daemon | Send task | targetDaemonDeviceId, sourceDesktopDeviceId, prompt, context?, sessionId?, agentMode?, windowId? |
hub:task_progress | Daemon → Hub → Desktop | Stream progress | targetDesktopDeviceId, taskId, progressType, content?, tool?, input?, result?, error?, tokenCount?, metadata?, agentMode?, permissionRequestId?, permission?, questionRequestId?, questions? |
hub:task_result | Daemon → Hub → Desktop | Final result | targetDesktopDeviceId, taskId, sessionId?, success, text?, error?, iterations?, agentMode? |
hub:permission_reply | Desktop → Hub → Daemon | Permission response | targetDaemonDeviceId, permissionRequestId, reply (once/always/reject), message? |
hub:question_reply | Desktop → Hub → Daemon | Question answer | targetDaemonDeviceId, questionRequestId, answers (string[][]) |
hub:task_cancel | Desktop → Hub → Daemon | Cancel task | targetDaemonDeviceId, taskId |
hub:list_sessions | Desktop → Hub → Daemon | Request session list | targetDaemonDeviceId, sourceDesktopDeviceId, limit? |
hub:session_list | Daemon → Hub → Desktop | Session list response | targetDesktopDeviceId, sessions[] |
hub:load_history | Desktop → Hub → Daemon | Request session history | targetDaemonDeviceId, sourceDesktopDeviceId, rootSessionId |
hub:history_result | Daemon → Hub → Desktop | Full session history | targetDesktopDeviceId, sessionId, messages[], title?, todos?, agentMode? |
hub:heartbeat | Client → Hub | Keepalive | (base fields only) |
hub:heartbeat_ack | Hub → Client | Keepalive response | (base fields only) |
hub:error | Hub → Client | Error notification | error, originalRequestId? |
Routing Model
Messages are routed based on two fields:
- Desktop → Daemon:
targetDaemonDeviceId— Hub looks up the daemon client by device ID - Daemon → Desktop:
targetDesktopDeviceId— Hub looks up the desktop client by device ID
Both lookups enforce target.userId === client.userId before forwarding.
Request-Response Correlation
Some operations are request-response (list_sessions, load_history). The requestId field correlates requests with responses:
Desktop sends: { type: "hub:list_sessions", requestId: "abc123", ... }
Daemon responds: { type: "hub:session_list", requestId: "abc123", ... }
The DesktopHubClient uses a pendingRequests map to match responses to waiting promises with configurable timeouts (15s for session lists, 30s for history loads).
Progress Event Types
The hub:task_progress message carries a progressType field that maps directly to the daemon's local progress types:
| progressType | Content | Description |
|---|---|---|
thinking | — | Agent is processing (includes tokenCount) |
assistant | text | Agent response text (streamed incrementally) |
tool_call | tool name + input | Tool invocation started |
tool_result | result text | Tool execution completed |
tool_metadata | metadata object | Live tool output (bash streaming, subagent events) |
error | error text | Execution error |
done | — | Task completed (includes sessionId, agentMode) |
permission_request | permission details | Tool needs user approval |
question_request | question details | Agent is asking the user a question |
compaction | summary text | Context was auto-compacted |
Hub Server
File: apps/hub/src/index.ts
Deployment: Fly.io (apps/hub/fly.toml)
Runtime: Node.js with ws WebSocket server
Architecture
class Hub { private clients: Map<string, AuthenticatedClient> = new Map(); private daemonClients: Map<string, AuthenticatedClient> = new Map(); // deviceId → client private desktopClients: Map<string, AuthenticatedClient> = new Map(); // deviceId → client }
Three lookup tables:
clients: All authenticated clients by internalclientIddaemonClients: Daemons indexed bydeviceIdfor task routingdesktopClients: Desktops indexed bydeviceIdfor progress routing
Connection Lifecycle
- TCP connect → WebSocket handshake succeeds
- Pre-auth handler installed — only
hub:authmessages accepted - Auth timeout (10s) starts counting
- Client sends
hub:authwith session token + device signature - Hub calls
POST /api/proxy/hub/validateon Bytespace - If valid: client registered in lookup tables, post-auth handler installed,
hub:auth_result(success) sent - If invalid:
hub:auth_result(failure) sent, connection closed with code4003 - Client sends
hub:daemon_registerorhub:desktop_register
Heartbeat
The Hub uses WebSocket ping/pong for dead connection detection:
Every 30 seconds:
1. Hub iterates all clients
2. If client.alive === false → terminate (dead)
3. Set client.alive = false
4. Send WebSocket ping
5. On pong → client.alive = true
Clients also send hub:heartbeat messages (application-level keepalive), which the Hub acknowledges with hub:heartbeat_ack.
Daemon Status Updates
When a daemon connects or disconnects, the Hub notifies Bytespace:
Daemon connects → POST /api/proxy/hub/daemon-status { deviceId, hubConnectionId: clientId }
Daemon disconnects → POST /api/proxy/hub/daemon-status { deviceId, hubConnectionId: null }
This updates the hub_connection_id column in ctx0_daemons, enabling the sidebar to show live online/offline status.
Environment Variables
| Variable | Required | Description |
|---|---|---|
PORT | No | WebSocket port (default: 3001) |
HOST | No | Bind address (default: 0.0.0.0) |
BYTESPACE_URL | No | Bytespace API URL (default: https://www.bytespace.ai) |
HUB_API_SECRET | Yes | Shared secret for server-to-server auth with Bytespace |
Daemon Hub Client
File: packages/daemon/src/hub/client.ts
Entrypoint: packages/daemon/src/hub/index.ts
Integrated in: packages/daemon/src/index.ts (Daemon class)
The DaemonHubClient runs inside every daemon process. It connects to the Hub, authenticates, and handles incoming remote tasks using the exact same agent loop as local IPC tasks.
Dependency Injection
interface DaemonHubClientDeps { getAgent: () => Bot0Agent; tools: ToolRegistry; getSessionManager: () => SessionManager | null; getSigningProvider: () => SigningProvider | null; getDb: () => ProxiedSupabaseClient | null; getDaemonName: () => string; getDeviceId: () => string | undefined; getSessionToken: () => string | undefined; }
All dependencies are provided as lazy getter callbacks to handle the daemon's initialization order (signing provider, session manager, and DB client may not be ready when the Hub client is created).
Task Execution Flow
When a hub:task_submit arrives:
- Generate task ID and create
AbortController - Track source desktop in
taskSourceDesktopmap (for routing progress back) - Determine agent mode — if
plan, create a restricted agent with plan-only tools - Create or resume session via
SessionManager - Build context from history if resuming an existing session
- Persist user message to session
- Run agent via
runWithTaskContext()with progress callback - Stream progress events to the source desktop via Hub
- Persist all messages (assistant, tool_call, tool_result) via ordered persist chain
- Send final result (
hub:task_result) with sessionId, success status, and agent mode - Cleanup — release resource locks, remove task from active maps
Event Bus Integration
The DaemonHubClient subscribes to daemon event bus events to forward interactive prompts:
PermissionAsked→ Forwarded ashub:task_progresswithprogressType: 'permission_request'QuestionAsked→ Forwarded ashub:task_progresswithprogressType: 'question_request'
When the Desktop responds:
hub:permission_reply→ CallspermissionManager.reply()to unblock the waiting toolhub:question_reply→ CallsquestionManager.reply()to unblock the waiting question
Reconnect Strategy
Exponential backoff with jitter cap:
delay = min(2000 * 2^attempts, 60000)
- Base delay: 2 seconds
- Max delay: 60 seconds
- Resets to 0 on successful connection
- Skips connection if credentials not available (retries later)
Task Cancellation
On hub:task_cancel:
private handleTaskCancel(msg: HubTaskCancelMessage): void { const taskState = this.activeTasks.get(msg.taskId); if (taskState) { taskState.aborted = true; taskState.controller.abort(); // AbortController signal } }
The AbortController.signal is passed to the agent's run() method, which checks it between iterations.
Cleanup
On stop(), the client:
- Clears heartbeat and reconnect timers
- Unsubscribes from event bus (PermissionAsked, QuestionAsked)
- Cancels all active tasks (abort + release resource locks)
- Closes WebSocket with code
1000
Desktop Hub Client
File: packages/desktop/electron/src/hub-client.ts
Integrated in: packages/desktop/electron/src/main.ts
The DesktopHubClient runs in the Electron main process. It connects to the Hub and provides a public API for submitting tasks to remote daemons.
Public API
| Method | Returns | Description |
|---|---|---|
submitTask(targetDeviceId, prompt, options?) | void | Fire-and-forget task submission |
cancelTask(targetDeviceId, taskId) | void | Cancel a remote task |
replyPermission(targetDeviceId, requestId, reply, message?) | void | Respond to permission prompt |
replyQuestion(targetDeviceId, requestId, answers) | void | Respond to question prompt |
listSessions(targetDeviceId, limit?) | Promise<Session[]> | Request-response (15s timeout) |
loadHistory(targetDeviceId, rootSessionId) | Promise<History> | Request-response (30s timeout) |
isConnected | boolean (getter) | Check Hub connection status |
Request-Response Pattern
For listSessions and loadHistory, the client uses a promise-based correlation mechanism:
async listSessions(targetDeviceId, limit?) { const requestId = generateId(); // Register listener BEFORE sending (prevents race condition) const resultPromise = this.waitForResponse(requestId, 15_000); this.send({ type: 'hub:list_sessions', requestId, ... }); return resultPromise; }
The waitForResponse method creates a promise with a timeout. When the matching hub:session_list arrives (correlated by requestId), the promise resolves. If no response within the timeout, it rejects.
Progress Event Forwarding
Remote progress events are forwarded to renderer windows on the same channel as local progress events:
onProgress: (update) => windowManager?.broadcastToAll("daemon:progress", update), onTaskResult: (result) => windowManager?.broadcastToAll("daemon:progress", { taskId: result.taskId, type: 'done', content: result.text, error: result.error, sessionId: result.sessionId, agentMode: result.agentMode, }),
This means the Terminal UI handles local and remote progress events identically — no special rendering code needed.
IPC Handlers
Seven IPC handlers are registered in main.ts:
| IPC Channel | Hub Method | Description |
|---|---|---|
remote:runTask | submitTask() | Submit task (includes windowId from sender) |
remote:cancelTask | cancelTask() | Cancel task |
remote:respondToPermission | replyPermission() | Permission reply |
remote:respondToQuestion | replyQuestion() | Question reply |
remote:listTaskSessions | listSessions() | List remote sessions |
remote:loadSessionHistory | loadHistory() | Load remote history |
remote:isConnected | isConnected | Check connection |
Preload Bridge
The preload script exposes the remote daemon API to the renderer:
remoteDaemon: { runTask: async (targetId, prompt, context?, sessionId?, agentMode?) => ipcRenderer.invoke("remote:runTask", targetId, prompt, context, currentWindowId, sessionId, agentMode), cancelTask: async (targetId, taskId) => ipcRenderer.invoke("remote:cancelTask", targetId, taskId), respondToPermission: async (targetId, requestId, reply, message?) => ipcRenderer.invoke("remote:respondToPermission", targetId, requestId, reply, message), respondToQuestion: async (targetId, requestId, answers) => ipcRenderer.invoke("remote:respondToQuestion", targetId, requestId, answers), listTaskSessions: async (targetId, limit?) => ipcRenderer.invoke("remote:listTaskSessions", targetId, limit), loadSessionHistory: async (targetId, rootSessionId) => ipcRenderer.invoke("remote:loadSessionHistory", targetId, rootSessionId), isConnected: async () => ipcRenderer.invoke("remote:isConnected"), }
Note: currentWindowId is automatically injected between context and sessionId by the preload — the renderer never needs to know about it.
Desktop UI Integration
Terminal Routing
File: packages/desktop/src/components/Terminal/Terminal.tsx
The Terminal component routes operations to either the local daemon or a remote daemon based on the selected daemon:
const isRemoteDaemon = selectedDaemonId && localDeviceId && selectedDaemonId !== localDeviceId;
Six operations are routed:
| Operation | Local API | Remote API |
|---|---|---|
| Submit prompt | daemon.runTask(prompt, ...) | remoteDaemon.runTask(targetId, prompt, ...) |
| Cancel task | daemon.cancelTask(taskId) | remoteDaemon.cancelTask(targetId, taskId) |
| Permission reply | daemon.respondToPermission(...) | remoteDaemon.respondToPermission(targetId, ...) |
| Question reply | daemon.respondToQuestion(...) | remoteDaemon.respondToQuestion(targetId, ...) |
| Question reject | daemon.rejectQuestion(...) | remoteDaemon.respondToQuestion(targetId, []) |
| Load history | daemon.loadSessionHistory(sid) | remoteDaemon.loadSessionHistory(targetId, sid) |
Ref Pattern for Stable Callbacks
To avoid stale closures in useCallback hooks, the Terminal uses refs for daemon selection state:
const selectedDaemonIdRef = useRef<string | null>(null); selectedDaemonIdRef.current = selectedDaemonId; const localDeviceIdRef = useRef<string | null>(null); localDeviceIdRef.current = localDeviceId;
Inside each callback, the latest values are read from refs:
const handlePermissionReply = useCallback(async (requestId, reply, message?) => { const targetId = selectedDaemonIdRef.current; const localId = localDeviceIdRef.current; const isRemote = targetId && localId && targetId !== localId; if (isRemote) { await window.bot0.remoteDaemon.respondToPermission(targetId, requestId, reply, message); } else { await window.bot0.daemon.respondToPermission(requestId, reply, message); } }, []); // No deps needed — refs always current
Local vs Remote Submit Differences
Local tasks (daemon.runTask):
- Returns
Promise<{ taskId, sessionId, success, error, agentMode }>— blocks until task completes sessionIdandagentModeextracted from return valuefinallyblock cleans up loading state
Remote tasks (remoteDaemon.runTask):
- Returns immediately (fire-and-forget via Hub)
taskIdcaptured from first progress event (existing behavior in progress handler)sessionIdandagentModeextracted from thedoneprogress event- Loading state cleared by the
doneevent handler (not afinallyblock)
// In the 'done' progress event handler: case 'done': { // For remote tasks, sessionId and agentMode arrive via the done progress event if (update.sessionId) { setSessionId(update.sessionId); } if (update.agentMode) { setAgentMode(update.agentMode); } // ... clear loading state }
Session Picker
File: packages/desktop/src/components/Terminal/SessionPicker.tsx
The SessionPicker accepts an optional targetDaemonDeviceId prop. When set, it fetches sessions from the remote daemon via Hub instead of the local daemon:
const result = targetDaemonDeviceId ? await window.bot0?.remoteDaemon?.listTaskSessions(targetDaemonDeviceId, 100) : await window.bot0?.daemon?.listTaskSessions(100);
Network Sidebar
File: packages/desktop/src/components/Terminal/NetworkSidebar.tsx
The sidebar displays all daemons in the user's bot0 network with live status:
Status dot logic:
function getStatusColor(daemon: NetworkDaemon, isLocal: boolean): 'green' | 'gray' | 'red' { if (isLocal) return 'green'; // Local daemon always green if (daemon.hubConnectionId) return 'green'; // Connected to Hub right now if (daemon.lastSeenAt) { const diffMs = Date.now() - new Date(daemon.lastSeenAt).getTime(); if (diffMs < 10 * 60 * 1000) return 'gray'; // Seen < 10 min ago } return 'red'; // Offline }
| Color | Meaning | Source |
|---|---|---|
| Green | Online now | hubConnectionId is non-null (set by Hub on connect) |
| Gray | Recently online | lastSeenAt < 10 minutes ago |
| Red | Offline | lastSeenAt > 10 minutes or never seen |
Features:
- Ordering: Local daemon always first, then alphabetical
- Selection: Click daemon to select. Selected daemon's sessions expand.
- Role badge: "daily" (green) for daily-driver machines, "24/7" (purple) for agent servers
- Discovery mini-bar: Shows app count, tab count, and CDP connections for selected remote daemons
- 30-second polling: Re-fetches daemon list periodically for live status updates
- Keyboard shortcuts:
Cmd+B/Ctrl+Btoggles sidebar
Command Adjustments
| Command | Behavior when remote daemon selected |
|---|---|
/resume | Lists remote daemon's sessions, loads history from remote |
/compact | Shows error: "Compaction is not supported for remote daemons" |
/plan | Works — sends prompt via remote routing |
/model | Always local (per-desktop setting) |
/settings | Always local (per-desktop setting) |
/voice0 | Always local (per-desktop setting) |
/clear | Always local (clears UI state only) |
/login / /logout | Always local (Bytespace auth is per-desktop) |
Input Area
When a remote daemon is selected:
- Placeholder text:
Ask {daemonName}... (Enter to send, Shift+Enter for new line) - Welcome screen: "Connected via Hub relay. Type a message to talk to this daemon."
- Header shows daemon name:
bot0 v{VERSION} — {daemonName}
Schema & Data Layer
New Columns on ctx0_daemons
File: packages/ctx0/src/schema/devices.ts
| Column | Type | Default | Description |
|---|---|---|---|
discovery_data | JSONB | {} | Environment discovery data (apps, windows, tabs, CDP connections, logins, projects) |
hub_connection_id | TEXT | NULL | Non-null when daemon is connected to Hub. Set to Hub's internal client ID on connect, NULL on disconnect. |
Proxy Type Changes
File: packages/proxy/src/lib/types.ts
Both DaemonRecord (snake_case, DB layer) and DaemonInfo (camelCase, app layer) updated with:
// DaemonRecord (DB) discovery_data: Record<string, unknown>; hub_connection_id: string | null; // DaemonInfo (App) discoveryData: Record<string, unknown>; hubConnectionId: string | null;
Desktop Types
File: packages/desktop/src/types/global.d.ts
New interfaces added:
interface NetworkDaemon { id: string; name: string; deviceId: string; defaultModel: string | null; deviceRole: 'daily-driver' | 'agent'; isActive: boolean; lastSeenAt: string | null; discoveryData: Record<string, unknown>; hubConnectionId: string | null; sessions: Array<{ id: string; title: string | null; messageCount: number; lastActivityAt: string; workingDirectory: string | null; }>; } interface NetworkDaemonsResult { success: boolean; daemons?: NetworkDaemon[]; currentDeviceId?: string; error?: string; }
ProgressUpdate type extended with sessionId?: string for remote task done events.
Bot0API interface extended with remoteDaemon namespace containing all 7 remote methods.
Bytespace API Routes
/api/proxy/hub/validate (POST)
File: apps/bytespace/src/app/api/proxy/hub/validate/route.ts
Called by the Hub server when a client connects. Validates session + device signature using the standard authenticateProxyRequest() function.
Request: Standard proxy auth headers (Authorization, X-Device-ID, X-Device-Signature, X-Device-Timestamp)
Response: { valid: true, userId: "...", deviceId: "..." } or { valid: false, error: "..." }
/api/proxy/hub/daemon-status (POST)
File: apps/bytespace/src/app/api/proxy/hub/daemon-status/route.ts
Called by the Hub server to update daemon connection status. Uses server-to-server auth with HUB_API_SECRET.
Request: { deviceId: string, hubConnectionId: string | null }
Auth: Authorization: Bearer {HUB_API_SECRET}
Response: { success: true }
/api/proxy/daemons/discovery (POST)
File: apps/bytespace/src/app/api/proxy/daemons/discovery/route.ts
Called by the Desktop app periodically (every 60s) to sync discovery data and heartbeat. Uses standard proxy auth (session + device signature).
Request: { discoveryData: { apps, windows, tabs, cdp, ... } }
Response: { success: true }
Discovery & Heartbeat
Desktop Discovery Sync
File: packages/desktop/electron/src/main.ts
Every 60 seconds, the Desktop app:
- Runs the
system:discovercommand on the local daemon - POSTs the discovery data to
/api/proxy/daemons/discoverywith device-signed auth - This updates
discovery_data+last_seen_atin the database
This serves dual purposes:
- Discovery data: Other desktops can see what apps/tabs/connections this daemon has
- Heartbeat: Updates
last_seen_atfor status dot accuracy
Hub-Level Heartbeat
Both DaemonHubClient and DesktopHubClient send hub:heartbeat messages every 30 seconds. The Hub responds with hub:heartbeat_ack.
Additionally, the Hub uses WebSocket ping/pong (protocol-level) to detect dead connections. If a client doesn't respond to a ping within 30 seconds, it's terminated.
Connection Lifecycle
Daemon Startup
1. Daemon.initialize()
2. Create DaemonHubClient with deps
3. hubClient.start()
4. Connect to Hub WebSocket
5. On open: authenticate (sign with hardware key)
6. On auth success: register daemon, subscribe to events, start heartbeat
7. Ready to receive remote tasks
Desktop Startup
1. Electron main process starts
2. Local daemon client connects (IPC socket)
3. Create DesktopHubClient with deps
4. hubClient.start()
5. Connect to Hub WebSocket
6. On open: authenticate (sign with hardware key via IPC)
7. On auth success: register desktop, start heartbeat
8. Ready to submit remote tasks
Graceful Shutdown
Daemon:
1. Daemon.cleanup()
2. hubClient.stop()
3. Cancel all active remote tasks (abort + release locks)
4. Unsubscribe from event bus
5. Close WebSocket (code 1000)
Desktop:
1. app.on('will-quit')
2. hubClient.stop()
3. Reject all pending request-response promises
4. Close WebSocket (code 1000)
Reconnect Behavior
Both clients use identical reconnect logic:
On disconnect (if not stopped):
delay = min(2000 * 2^attempts, 60000)
Wait delay
Reconnect attempt
On success: reset attempts to 0
On failure: increment attempts, schedule next
| Attempt | Delay |
|---|---|
| 0 | 2s |
| 1 | 4s |
| 2 | 8s |
| 3 | 16s |
| 4 | 32s |
| 5+ | 60s (max) |
File Reference
New Files Created
| File | Package | Description |
|---|---|---|
packages/core/src/types/hub-protocol.ts | @bot0/core | All Hub message type definitions (18 message types, union type) |
packages/daemon/src/hub/client.ts | @bot0/daemon | DaemonHubClient — remote task execution via Hub |
packages/daemon/src/hub/index.ts | @bot0/daemon | Barrel export for hub module |
packages/desktop/electron/src/hub-client.ts | @bot0/desktop | DesktopHubClient — remote task submission via Hub |
packages/desktop/src/components/Terminal/NetworkSidebar.tsx | @bot0/desktop | Sidebar showing daemon network with status dots |
apps/bytespace/src/app/api/proxy/hub/validate/route.ts | @bot0/bytespace | Hub auth validation endpoint |
apps/bytespace/src/app/api/proxy/hub/daemon-status/route.ts | @bot0/bytespace | Hub daemon status update endpoint |
apps/bytespace/src/app/api/proxy/daemons/discovery/route.ts | @bot0/bytespace | Daemon discovery data sync endpoint |
Modified Files
| File | Package | Changes |
|---|---|---|
apps/hub/src/index.ts | @bot0/hub | Full rewrite — authenticated relay with routing tables |
packages/core/src/types/index.ts | @bot0/core | Re-export hub-protocol types |
packages/ctx0/src/schema/devices.ts | @bot0/ctx0 | Added discovery_data + hub_connection_id columns |
packages/daemon/src/config.ts | @bot0/daemon | Added hubUrl config field |
packages/daemon/src/index.ts | @bot0/daemon | Integrated DaemonHubClient in Daemon class |
packages/proxy/src/lib/types.ts | @bot0/proxy | Added discoveryData + hubConnectionId to types |
packages/proxy/src/routes/daemons.ts | @bot0/proxy | Added updateDaemonDiscovery() + updateDaemonHubStatus() |
packages/proxy/src/index.ts | @bot0/proxy | Export new functions |
packages/desktop/electron/src/main.ts | @bot0/desktop | Hub client init, 7 remote IPC handlers, discovery heartbeat, sidebar IPC |
packages/desktop/electron/src/preload.ts | @bot0/desktop | Added remoteDaemon namespace + sidebar IPC methods |
packages/desktop/src/types/global.d.ts | @bot0/desktop | NetworkDaemon, remoteDaemon API, ProgressUpdate.sessionId |
packages/desktop/src/components/Terminal/Terminal.tsx | @bot0/desktop | Local/remote routing for 6 operations, daemon selection state, welcome text |
packages/desktop/src/components/Terminal/SessionPicker.tsx | @bot0/desktop | Optional targetDaemonDeviceId prop for remote session listing |
packages/desktop/src/components/Terminal/NetworkSidebar.tsx | @bot0/desktop | Status dots, discovery mini-bar, daemon selection UI |