Skip to content

RPC Dispatch, Handlers & Service Bridges

dazeb edited this page Sep 17, 2026 · 2 revisions

RPC Dispatch, Handlers & Service Bridges

The Server Edition reuses the desktop IPC vocabulary but replaces the Electron transport with a WebSocket JSON-RPC channel. This page covers the three pieces of that substitution: the dispatcher (rpc.ts), the handler table (handlers.ts), and the boot wiring in index.ts that starts the agent bridge and space-sync hooks alongside the HTTP/WS shell. The renderer-side counterpart is documented under Renderer Shim & Server Boundary.

The dispatcher contract (src/server/rpc.ts)

rpc.ts is deliberately tiny and transport-free. Its contract is:

  • RpcRequest = { id, method, args }, RpcResponse = { id, ok, result?, error? }.
  • createDispatcher(handlers) returns async (message: unknown) => Promise<RpcResponse | null>.
  • A message that is not an object with a string method returns null — the dispatcher drops it silently rather than replying.
  • Unknown methods return { ok: false, error: "unhandled: <method>" }, so a half-added capability fails loudly on the caller side.
  • Handler invocation is wrapped in try/catch; a thrown error becomes { ok: false, error: String(error) }. Non-array args are coerced to [].
  • Handlers may be sync or async — the result is awaited before the envelope is built.
flowchart TD
  A[WebSocket frame] --> B{object with string method?}
  B -- no --> X[return null - dropped]
  B -- yes --> C{handlers method exists?}
  C -- no --> E["ok:false, error: unhandled: method"]
  C -- yes --> D["await handler(args or [])"]
  D -- resolves --> F["ok:true, result"]
  D -- throws --> G["ok:false, error, String(error)"]
Loading

Key nodes: the null return at B means malformed frames die at the dispatcher and never reach a handler; the unhandled branch at C is the observable signal that the server table and the renderer shim have drifted out of sync; the catch at D→G guarantees a rejection is always converted into an envelope rather than killing the socket.

Building the handler table (src/server/handlers.ts)

buildHandlers(platform: CorePlatform, lifecycle?) returns Record<string, RpcHandler> keyed by IPC constants from ../shared/ipc. The method name in a request is the IPC channel name, which is what lets the browser shim emulate the preload API without a translation layer.

Construction order inside buildHandlers matters:

  1. readVersion() parses package.json for IPC.appVersion (fallback '0.0.0').
  2. WorkspaceStore(platform) — the single on-disk source of truth for projects, nodes and links.
  3. A let linkService is declared (not yet constructed) so the PTY broadcast tap below can reach it.
  4. PtyManager({ userDataPath, broadcast }) — the broadcast wrapper forwards every event to platform.broadcast, and additionally, for channels prefixed ${IPC.ptyData}:, calls linkService?.notePtyActivity(nodeId). PTY output is therefore both a renderer stream and a link-engine activity signal.
  5. chatRuntime via createChatRuntime — provider resolution reads app settings plus an TERMSPRAWL_PROVIDER_KEY_<PROVIDER_ID> environment override; provider kind is inferred as anthropic vs openai by regex over baseUrl/name. Chat events are broadcast on chat:event:<nodeId>.
  6. linkService = new LinkService({...}) — wired to WorkspaceStore (links, project lookup, node lookup, run recording), to PtyManager (capturePane, write), to platform.broadcast for chat replies, and to the A2A peer client. It is registered with lifecycle?.onDispose(() => linkService?.dispose()).

Two construction-time helpers protect the boundary:

  • safeResolve(root, rel) resolves a leaf under a root and throws path outside root on traversal; it backs the file list/read surface used by the file tree.
  • redactSettings(settings) structurally clones settings and replaces chat.keys[].key with { providerId, hasKey }, and nulls telegram.token. Secrets never leave the process on app:settings-get; writes go back through app:settings-set with full values.

resolveRepoRoot(target) is the Git gate: it calls resolveGitScope, accepts only kind === 'local', and then findRepoRoot(cwd). A target that is not a known project resolves to null, and every git handler short-circuits to a failure result instead of touching the filesystem.

The method table by domain

Domain Channels (representative) Backing service Notable behavior
App / boot reads app:version, app:settings-get/set, update:*, announcement:get readVersion, loadAppSettings/saveAppSettings, idleUpdateStatus Updates are inert; announcements return null; settings reads are redacted
Capability pages settings:capabilities-get, skill/plugin/hook setters, settings:usage-get — Return supported: false payloads, or throw explicit "unavailable in Server Edition" errors
Workspace workspace:snapshot, workspace:save-nodes WorkspaceStore Direct pass-through to the store
Projects project:add/import/close/archive/reopen/delete/update-settings/rename, github:import WorkspaceStore, ensureFolderProjectRoot, importGitHubRepo project:add dedupes by cwd and validates the folder up front; project:import preserves the original project id; github:import requires TS_CLOUD_API + TS_SPACE_BOOT_TOKEN and clones under <userData>/projects-src
Git stage/unstage/discard/commit/commit-message/create-branch/checkout/push/pull/publish/worktrees list/add/remove git-service, commit-message Every handler funnels through resolveRepoRoot; when it is null they return { code: 1, stderr: 'no project folder' } (or [] / { ok:false })
Chat chat:send, chat:stop, chat:approve createChatRuntime, projectChatTools send validates nodeId + messages array before touching the runtime; approve accepts only 'approve' | 'deny'; tools are anchored to the first open local project
Links / A2A links:list, links:run, links:mark-dirty, links:update, links:send-to-peer LinkService, loadProjectFile, sendText update persists via saveLinks then calls linksChanged(); peer sends resolve the peer from settings with an env-token override and a 20 s timeout

A note on the file header: it describes a deliberately narrow "v1 scope" (projects + terminals + boot reads, with git/cloud/accounts/hooks called out as unimplemented). The table returned at the bottom of the same file now contains the full git surface, so the header is best read as historical context rather than a current capability list. The authoritative scope limit is the renderer shim, which decides which channels the browser is allowed to call.

Request path and push path

Dispatch is unidirectional for requests; broadcasts bypass it entirely.

sequenceDiagram
  autonumber
  participant R as Renderer (shim.js)
  participant W as WS layer (index.ts)
  participant D as Dispatcher (rpc.ts)
  participant H as Handler table (handlers.ts)
  participant S as Core services
  R->>W: RpcRequest { id, method, args }
  W->>D: dispatch(message)
  D->>H: handlers[method](args)
  H->>S: PtyManager / Git / chat runtime / LinkService
  S-->>H: result
  H-->>D: result
  D-->>W: RpcResponse { id, ok, result }
  W-->>R: RpcResponse frame
  Note over S,W: Async pushes skip the request path:<br/>platform.broadcast(channel, payload) fans out to WS clients,<br/>e.g. pty:data:<nodeId>, chat:event:<nodeId>
Loading

Key points: the dispatcher never sees broadcasts, so a streaming chat:event:* or pty:data:* message has no request id and is not awaitable. lifecycle.onDispose is the only teardown hook registered here, and it exists because LinkService holds timers/subscriptions that must be released when the boot gate shuts down.

Boot wiring and bridges (src/server/index.ts)

index.ts is the shell that makes the above reachable. It:

  • Resolves the port from PORT, then argv[2], then 3110; serves out/renderer with src/server/shim.js injected; serves only existing regular files (isRegularFile) with a MIME table.
  • Extracts ?token= from upgrade URLs as the browser fallback auth channel (tokenFromUrl), delegating the actual decision to createAuthPolicy / authorizeUpgrade / timingSafeCompare.
  • Enforces bind safety with assertSafeServerBind and path containment with resolveContainedPath.
  • Constructs ServerPlatform, then buildHandlers(platform), then createDispatcher(handlers) — the only place the handler table is instantiated.
  • Starts two independent service bridges next to the dispatcher: startAgentBridge(...) and createSpacePusher(...) / restoreFromCloud(...) from space-sync-wiring. Both are wired here rather than inside handlers.ts, which keeps the RPC table free of background-service lifecycle.

Boundary conditions

  • Malformed frames are dropped (null), not answered — callers relying on any reply should still guard with a timeout.
  • Unknown methods answer unhandled: <method>; this is the failure mode when a new IPC channel is added to shared/ipc and the shim but not to buildHandlers.
  • Missing secret material never crosses the boundary: provider keys become hasKey flags, the Telegram token becomes undefined.
  • Path traversal is rejected inside safeResolve; git targets outside known local projects are rejected inside resolveRepoRoot.
  • Space-only features (github:import) degrade to { ok: false, error: 'space env missing' } when the boot tokens are absent.
  • Desktop-only features (skill/plugin/hook management, capability discovery, usage) either return supported: false or throw a named error, keeping the server surface honest about what it cannot do.
  • Disposal: anything constructed during boot that owns resources must be registered via lifecycle.onDispose; currently LinkService is the only RPC-table-owned subscription.

Extension points

  1. New channel: define the constant in src/shared/ipc.ts, add a key to the object returned by buildHandlers, and expose it in the shim. The dispatcher needs no change — the table is the registry.
  2. New broadcast stream: call platform.broadcast(channel, payload) from the handler or from a bridged service; prefix-based taps (like the PTY-to-link activity hook) can be layered on top of the same call.
  3. New background service: follow the agent-bridge / space-sync-wiring pattern — a separate module with a start*/create* entry, invoked from index.ts and given the platform/workspace handles it needs.
  4. New service bridge: mirror LinkService's construction — a callback bag of narrow functions wired to WorkspaceStore and PtyManager, plus disposal registration.

Sources: src/server/rpc.ts, src/server/handlers.ts, src/server/handlers.ts, src/server/handlers.ts, src/server/index.ts, src/server/agent-bridge.ts, src/server/space-sync-wiring.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally