bot0-remote-daemon.md

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

  1. Overview
  2. Architecture Diagram
  3. Security Model
  4. Hub Protocol
  5. Hub Server
  6. Daemon Hub Client
  7. Desktop Hub Client
  8. Desktop UI Integration
  9. Schema & Data Layer
  10. Bytespace API Routes
  11. Discovery & Heartbeat
  12. Connection Lifecycle
  13. 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

TermMeaning
HubWebSocket relay server on Fly.io. Routes messages between daemons and desktops.
DaemonHubClientRuns inside the daemon process. Connects to Hub, receives remote tasks, streams results back.
DesktopHubClientRuns inside the Electron main process. Connects to Hub, submits tasks to remote daemons, receives progress.
deviceIdSHA-256 hash of the device's public key. Hardware-bound, globally unique.
sourceDesktopDeviceIdThe deviceId of the Desktop that submitted a task. Used for routing responses back.
targetDaemonDeviceIdThe 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:

PlatformHardwareKey Storage
macOSSecure EnclaveApple Silicon / T2 chip
WindowsTPM 2.0Hardware TPM module
LinuxTPM 2.0tpm2-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:

typescript
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:

  1. Client validation (/api/proxy/hub/validate): Uses the client's own credentials (session + device signature), forwarded in HTTP headers. Standard authenticateProxyRequest() pattern.

  2. 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.

typescript
const authTimeout = setTimeout(() => { ws.close(4001, 'Authentication timeout'); }, AUTH_TIMEOUT); // 10_000 ms

What the Hub Stores

Nothing. The Hub is a pure relay:

DataStored?Details
MessagesNoForwarded immediately, never persisted
CredentialsNoSession tokens forwarded to Bytespace for validation, not stored
API keysNoHub has no concept of API keys
Session historyNoDaemon handles all persistence
User dataNoHub only knows userId for routing isolation
Connection stateIn-memory onlyMap<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:

typescript
interface HubMessageBase { type: HubMessageType; // Discriminator requestId: string; // Unique ID for correlation timestamp: number; // Unix milliseconds }

Message Type Reference

MessageDirectionPurposeKey Fields
hub:authClient → HubAuthenticate connectionclientType, sessionToken, deviceId, deviceSignature, deviceTimestamp
hub:auth_resultHub → ClientAuth success/failuresuccess, userId?, error?
hub:daemon_registerDaemon → HubAnnounce daemon namedaemonName, daemonId, deviceId
hub:desktop_registerDesktop → HubRegister desktopdeviceId
hub:task_submitDesktop → Hub → DaemonSend tasktargetDaemonDeviceId, sourceDesktopDeviceId, prompt, context?, sessionId?, agentMode?, windowId?
hub:task_progressDaemon → Hub → DesktopStream progresstargetDesktopDeviceId, taskId, progressType, content?, tool?, input?, result?, error?, tokenCount?, metadata?, agentMode?, permissionRequestId?, permission?, questionRequestId?, questions?
hub:task_resultDaemon → Hub → DesktopFinal resulttargetDesktopDeviceId, taskId, sessionId?, success, text?, error?, iterations?, agentMode?
hub:permission_replyDesktop → Hub → DaemonPermission responsetargetDaemonDeviceId, permissionRequestId, reply (once/always/reject), message?
hub:question_replyDesktop → Hub → DaemonQuestion answertargetDaemonDeviceId, questionRequestId, answers (string[][])
hub:task_cancelDesktop → Hub → DaemonCancel tasktargetDaemonDeviceId, taskId
hub:list_sessionsDesktop → Hub → DaemonRequest session listtargetDaemonDeviceId, sourceDesktopDeviceId, limit?
hub:session_listDaemon → Hub → DesktopSession list responsetargetDesktopDeviceId, sessions[]
hub:load_historyDesktop → Hub → DaemonRequest session historytargetDaemonDeviceId, sourceDesktopDeviceId, rootSessionId
hub:history_resultDaemon → Hub → DesktopFull session historytargetDesktopDeviceId, sessionId, messages[], title?, todos?, agentMode?
hub:heartbeatClient → HubKeepalive(base fields only)
hub:heartbeat_ackHub → ClientKeepalive response(base fields only)
hub:errorHub → ClientError notificationerror, 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:

progressTypeContentDescription
thinkingAgent is processing (includes tokenCount)
assistanttextAgent response text (streamed incrementally)
tool_calltool name + inputTool invocation started
tool_resultresult textTool execution completed
tool_metadatametadata objectLive tool output (bash streaming, subagent events)
errorerror textExecution error
doneTask completed (includes sessionId, agentMode)
permission_requestpermission detailsTool needs user approval
question_requestquestion detailsAgent is asking the user a question
compactionsummary textContext 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

typescript
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 internal clientId
  • daemonClients: Daemons indexed by deviceId for task routing
  • desktopClients: Desktops indexed by deviceId for progress routing

Connection Lifecycle

  1. TCP connect → WebSocket handshake succeeds
  2. Pre-auth handler installed — only hub:auth messages accepted
  3. Auth timeout (10s) starts counting
  4. Client sends hub:auth with session token + device signature
  5. Hub calls POST /api/proxy/hub/validate on Bytespace
  6. If valid: client registered in lookup tables, post-auth handler installed, hub:auth_result (success) sent
  7. If invalid: hub:auth_result (failure) sent, connection closed with code 4003
  8. Client sends hub:daemon_register or hub: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

VariableRequiredDescription
PORTNoWebSocket port (default: 3001)
HOSTNoBind address (default: 0.0.0.0)
BYTESPACE_URLNoBytespace API URL (default: https://www.bytespace.ai)
HUB_API_SECRETYesShared 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

typescript
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:

  1. Generate task ID and create AbortController
  2. Track source desktop in taskSourceDesktop map (for routing progress back)
  3. Determine agent mode — if plan, create a restricted agent with plan-only tools
  4. Create or resume session via SessionManager
  5. Build context from history if resuming an existing session
  6. Persist user message to session
  7. Run agent via runWithTaskContext() with progress callback
  8. Stream progress events to the source desktop via Hub
  9. Persist all messages (assistant, tool_call, tool_result) via ordered persist chain
  10. Send final result (hub:task_result) with sessionId, success status, and agent mode
  11. 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 as hub:task_progress with progressType: 'permission_request'
  • QuestionAsked → Forwarded as hub:task_progress with progressType: 'question_request'

When the Desktop responds:

  • hub:permission_reply → Calls permissionManager.reply() to unblock the waiting tool
  • hub:question_reply → Calls questionManager.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:

typescript
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:

  1. Clears heartbeat and reconnect timers
  2. Unsubscribes from event bus (PermissionAsked, QuestionAsked)
  3. Cancels all active tasks (abort + release resource locks)
  4. 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

MethodReturnsDescription
submitTask(targetDeviceId, prompt, options?)voidFire-and-forget task submission
cancelTask(targetDeviceId, taskId)voidCancel a remote task
replyPermission(targetDeviceId, requestId, reply, message?)voidRespond to permission prompt
replyQuestion(targetDeviceId, requestId, answers)voidRespond to question prompt
listSessions(targetDeviceId, limit?)Promise<Session[]>Request-response (15s timeout)
loadHistory(targetDeviceId, rootSessionId)Promise<History>Request-response (30s timeout)
isConnectedboolean (getter)Check Hub connection status

Request-Response Pattern

For listSessions and loadHistory, the client uses a promise-based correlation mechanism:

typescript
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:

typescript
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 ChannelHub MethodDescription
remote:runTasksubmitTask()Submit task (includes windowId from sender)
remote:cancelTaskcancelTask()Cancel task
remote:respondToPermissionreplyPermission()Permission reply
remote:respondToQuestionreplyQuestion()Question reply
remote:listTaskSessionslistSessions()List remote sessions
remote:loadSessionHistoryloadHistory()Load remote history
remote:isConnectedisConnectedCheck connection

Preload Bridge

The preload script exposes the remote daemon API to the renderer:

typescript
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:

typescript
const isRemoteDaemon = selectedDaemonId && localDeviceId && selectedDaemonId !== localDeviceId;

Six operations are routed:

OperationLocal APIRemote API
Submit promptdaemon.runTask(prompt, ...)remoteDaemon.runTask(targetId, prompt, ...)
Cancel taskdaemon.cancelTask(taskId)remoteDaemon.cancelTask(targetId, taskId)
Permission replydaemon.respondToPermission(...)remoteDaemon.respondToPermission(targetId, ...)
Question replydaemon.respondToQuestion(...)remoteDaemon.respondToQuestion(targetId, ...)
Question rejectdaemon.rejectQuestion(...)remoteDaemon.respondToQuestion(targetId, [])
Load historydaemon.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:

typescript
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:

typescript
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
  • sessionId and agentMode extracted from return value
  • finally block cleans up loading state

Remote tasks (remoteDaemon.runTask):

  • Returns immediately (fire-and-forget via Hub)
  • taskId captured from first progress event (existing behavior in progress handler)
  • sessionId and agentMode extracted from the done progress event
  • Loading state cleared by the done event handler (not a finally block)
typescript
// 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:

typescript
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:

typescript
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 }
ColorMeaningSource
GreenOnline nowhubConnectionId is non-null (set by Hub on connect)
GrayRecently onlinelastSeenAt < 10 minutes ago
RedOfflinelastSeenAt > 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+B toggles sidebar

Command Adjustments

CommandBehavior when remote daemon selected
/resumeLists remote daemon's sessions, loads history from remote
/compactShows error: "Compaction is not supported for remote daemons"
/planWorks — sends prompt via remote routing
/modelAlways local (per-desktop setting)
/settingsAlways local (per-desktop setting)
/voice0Always local (per-desktop setting)
/clearAlways local (clears UI state only)
/login / /logoutAlways 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

ColumnTypeDefaultDescription
discovery_dataJSONB{}Environment discovery data (apps, windows, tabs, CDP connections, logins, projects)
hub_connection_idTEXTNULLNon-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:

typescript
// 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:

typescript
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:

  1. Runs the system:discover command on the local daemon
  2. POSTs the discovery data to /api/proxy/daemons/discovery with device-signed auth
  3. This updates discovery_data + last_seen_at in the database

This serves dual purposes:

  • Discovery data: Other desktops can see what apps/tabs/connections this daemon has
  • Heartbeat: Updates last_seen_at for 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
AttemptDelay
02s
14s
28s
316s
432s
5+60s (max)

File Reference

New Files Created

FilePackageDescription
packages/core/src/types/hub-protocol.ts@bot0/coreAll Hub message type definitions (18 message types, union type)
packages/daemon/src/hub/client.ts@bot0/daemonDaemonHubClient — remote task execution via Hub
packages/daemon/src/hub/index.ts@bot0/daemonBarrel export for hub module
packages/desktop/electron/src/hub-client.ts@bot0/desktopDesktopHubClient — remote task submission via Hub
packages/desktop/src/components/Terminal/NetworkSidebar.tsx@bot0/desktopSidebar showing daemon network with status dots
apps/bytespace/src/app/api/proxy/hub/validate/route.ts@bot0/bytespaceHub auth validation endpoint
apps/bytespace/src/app/api/proxy/hub/daemon-status/route.ts@bot0/bytespaceHub daemon status update endpoint
apps/bytespace/src/app/api/proxy/daemons/discovery/route.ts@bot0/bytespaceDaemon discovery data sync endpoint

Modified Files

FilePackageChanges
apps/hub/src/index.ts@bot0/hubFull rewrite — authenticated relay with routing tables
packages/core/src/types/index.ts@bot0/coreRe-export hub-protocol types
packages/ctx0/src/schema/devices.ts@bot0/ctx0Added discovery_data + hub_connection_id columns
packages/daemon/src/config.ts@bot0/daemonAdded hubUrl config field
packages/daemon/src/index.ts@bot0/daemonIntegrated DaemonHubClient in Daemon class
packages/proxy/src/lib/types.ts@bot0/proxyAdded discoveryData + hubConnectionId to types
packages/proxy/src/routes/daemons.ts@bot0/proxyAdded updateDaemonDiscovery() + updateDaemonHubStatus()
packages/proxy/src/index.ts@bot0/proxyExport new functions
packages/desktop/electron/src/main.ts@bot0/desktopHub client init, 7 remote IPC handlers, discovery heartbeat, sidebar IPC
packages/desktop/electron/src/preload.ts@bot0/desktopAdded remoteDaemon namespace + sidebar IPC methods
packages/desktop/src/types/global.d.ts@bot0/desktopNetworkDaemon, remoteDaemon API, ProgressUpdate.sessionId
packages/desktop/src/components/Terminal/Terminal.tsx@bot0/desktopLocal/remote routing for 6 operations, daemon selection state, welcome text
packages/desktop/src/components/Terminal/SessionPicker.tsx@bot0/desktopOptional targetDaemonDeviceId prop for remote session listing
packages/desktop/src/components/Terminal/NetworkSidebar.tsx@bot0/desktopStatus dots, discovery mini-bar, daemon selection UI