-
Notifications
You must be signed in to change notification settings - Fork 0
Preload Bridge & IPC Contract
The preload layer is the only channel between the renderer and the main process. It has two halves that must stay in lockstep:
-
An enumerable channel inventory in
src/shared/ipc.ts— every IPC channel name is a key in one frozen object, so the set of capabilities the app has is greppable and typeable rather than scattered string literals. -
A narrow
contextBridgesurface insrc/preload/index.ts— a hand-authoredapiobject exposed aswindow.termsprawl, which wrapsipcRendererso the renderer never holds the raw Electron handle.
Before adding any new main/renderer capability, this is the pair of files to read and changes to make.
| Path | Role |
|---|---|
src/shared/ipc.ts |
Channel-name source of truth, the IpcChannel union, and the per-entity channel-suffix helpers. |
src/preload/index.ts |
Builds the api object, wires each method to invoke / send / on, and calls contextBridge.exposeInMainWorld('termsprawl', api). Also exports TermsprawlApi = typeof api. |
src/renderer/src/env.d.ts |
Ambient declare global { interface Window { termsprawl: … } } mirror so renderer code typechecks against the bridge without importing preload. |
src/preload/AGENTS.md |
The two hard rules: renderer never touches ipcRenderer; the build must resolve ../preload/index.mjs, not .js. |
IPC is declared as const at src/shared/ipc.ts#L4-L167, with a header comment stating the intent directly: "Channel names — single source of truth for every IPC channel. Never hardcode a channel string elsewhere." The observed revision contains 104 entries.
| Group | Count | Examples |
|---|---|---|
| Agent tools | 4 |
agent-tools:request, -reply, -status, -status-get
|
| App metadata / settings / updates | 15 |
app:version, shell:open-external, app:settings-get, update:*, settings:capabilities-get, settings:set-skill-enabled
|
| Workspace & projects | 13 |
workspace:snapshot, workspace:export-bundle, project:*, dialog:select-folder
|
| Terminal / PTY | 8 |
pty:create, pty:write, pty:resize, pty:destroy, terminal:close, pty:read-scrollback, plus two push channels |
| Node services | 5 |
diff:info, dialog:open-file, file:read, file:write, file:list
|
| Agent hooks | 2 |
agent:status, agent:session-name (both push-only) |
| Context links | 3 |
context:list, context:add, context:remove
|
| Node links | 5 |
links:run, links:mark-dirty, links:update, links:list, links:send-to-peer
|
| Managed accounts | 4 |
app:account-create, app:account-delete, app:permission-probe, app:login-command
|
| Source control | 14 |
git:snapshot … git:worktree-remove
|
| Cloud | 10 |
cloud:status, cloud:device-start/-poll, cloud:sign-out, cloud:backup-now, cloud:list-backups, cloud:space-*
|
| GitHub (desktop) | 3 |
github:repos, github:clone, github:disconnect
|
| GitHub (import) | 2 |
github:import, github:suggest (push-only) |
| Embedded browser | 5 |
browser:cdp-info, browser:register/-unregister, browser:navigate, browser:agent-open
|
| Chat driver | 4 |
chat:send, chat:stop, chat:approve, chat:event
|
| Relay | 7 |
relay:connect, -disconnect, -status, -mint, relay:frame:subscribe/-unsubscribe/-send
|
Several entries are annotated rather than merely named — the comments carry contract detail that would otherwise be lost: githubClone mints its credential-bearing URL inside main so it never crosses IPC (src/shared/ipc.ts#L121-L129); projectImport accepts a caller-supplied id and rejects on collision so snapshot restores keep ids stable across machines (#L39-L41); settingsSetSkillEnabled documents that folder-move is the only state a CLI respects (#L22-L24).
Five channels carry per-entity fan-out, so the base name lives in IPC and a suffix helper appends the id:
export function ptyDataChannel(sessionId: string): string { return `${IPC.ptyData}:${sessionId}` }
export function ptyExitChannel(sessionId: string): string { return `${IPC.ptyExit}:${sessionId}` }
export function agentSessionNameChannel(sessionId: string): string { return `${IPC.agentSessionName}:${sessionId}` }
export function chatEventChannel(nodeId: string): string { return `${IPC.chatEvent}:${nodeId}` }src/shared/ipc.ts#L171-L185
IpcChannel is derived, not enumerated by hand:
export type IpcChannel = (typeof IPC)[keyof typeof IPC]src/shared/ipc.ts#L169
Because IPC is as const, this is a union of string literals — any helper or handler typed as IpcChannel fails to compile on a typo. Note the observed asymmetry: agent:status is suffixed inline as `${IPC.agentStatus}:${sessionId}` in the preload (src/preload/index.ts#L197) instead of through a helper, so it is the one per-entity channel without a named constructor.
src/preload/index.ts builds a plain object literal, api, grouped into feature namespaces, and hands it to Electron:
contextBridge.exposeInMainWorld('termsprawl', api)
export type TermsprawlApi = typeof apisrc/preload/index.ts#L389-L391
Deriving the exported type from the value means the preload's own type can never disagree with the exposed object. Drift can only enter where the type is restated — see §3.
Every method in api is one of exactly three shapes, and the distinction is meaningful:
| Shape | Electron call | Return | Used for |
|---|---|---|---|
| Request/response | ipcRenderer.invoke(channel, …args) |
Promise<T> |
The vast majority — file reads, git ops, snapshots, settings. |
| Fire-and-forget | ipcRenderer.send(channel, …args) |
void |
Hot-path or advisory writes with no ack: pty.write, pty.resize, agentTools.reply, relay.onFrame's subscribe/unsubscribe pings. |
| Push subscription | ipcRenderer.on(channel, listener) |
() => void unsubscribe closure |
Main → renderer streams, named onX. |
Push subscriptions always return their own teardown, and the closure is the only way to remove the listener:
onData: (id: string, cb: (data: string) => void): (() => void) => {
const channel = ptyDataChannel(id)
const listener = (_event, data: string): void => cb(data)
ipcRenderer.on(channel, listener)
return () => { ipcRenderer.removeListener(channel, listener) }
}src/preload/index.ts#L160-L167
The same onX → unsubscribe contract is used for agentTools.onRequest, agentTools.onStatus, updates.onStatus, agent.onStatus, agent.onSessionName, and relay.onStatus.
-
agentTools—onRequest,reply,status,onStatus - top-level —
appVersion(),openExternal(url) -
runtime— a static{ kind: 'desktop' as const }literal (not IPC), used to discriminate the desktop build from the Server Edition shim's{ kind: 'server' } -
runtimeInfo()—packagedflag for UI gating -
settings— get/set, account create/delete,permissionSupported,loginCommand,capabilities,setSkillEnabled,setPluginEnabled,reinstallHooks,usage -
updates— check/download/install/dismiss plusonStatus announcements.get-
workspace— snapshot, saveNodes, bundle export/import, project add/close/archive/reopen/delete, updateSettings, renameProject, selectFolder -
pty— create/write/resize/destroy/closeNode/readScrollback plusonData/onExit -
diff.info,files(openDialog/read/write/list) -
agent—onStatus,onSessionName -
contextLinks— list/add/remove -
git— 14 operations, each taking aGitTarget({ cwd }local or{ remote }ssh) -
links— list/run/markDirty/update/sendToPeer -
relay— status/connect/mintInvite/disconnect/sendFrame plusonStatus/onFrame
relay.onFrame is the one subscription that has a side effect on subscribe, not just on delivery:
onFrame: (cb: (frame: { from: string; text: string }) => void): (() => void) => {
// Frames are push-gated: subscribing tells main to attach its frame
// listener (audit B7 — no listener, no decrypted frames flowing).
ipcRenderer.send(IPC.relayFrameSubscribe)
const listener = (_event, frame) => cb(frame)
ipcRenderer.on('relay:frame', listener)
return () => {
ipcRenderer.removeListener('relay:frame', listener)
ipcRenderer.send(IPC.relayFrameUnsubscribe)
}
}src/preload/index.ts#L375-L385
This makes the renderer's subscription state the source of truth for whether main decrypts and forwards peer frames at all — a deliberate capability-narrowing rather than an always-on stream.
sequenceDiagram
participant R as Renderer (window.termsprawl)
participant P as preload/index.ts
participant S as shared/ipc.ts
participant M as Main process
Note over R,M: invoke — request/response
R->>P: termsprawl.pty.create(req)
P->>S: IPC.ptyCreate
P->>M: ipcRenderer.invoke("pty:create", req)
M-->>P: PtyCreateResult
P-->>R: Promise<PtyCreateResult>
Note over R,M: send — fire and forget
R->>P: termsprawl.pty.write(id, data)
P->>S: IPC.ptyWrite
P->>M: ipcRenderer.send("pty:write", id, data)
Note over P,M: no reply path
Note over R,M: on — per-entity push
R->>P: termsprawl.pty.onData(id, cb)
P->>S: ptyDataChannel(id)
S-->>P: "pty:data:<id>"
P->>M: ipcRenderer.on("pty:data:<id>", listener)
M-->>P: payload on pty:data:<id>
P-->>R: cb(data)
R->>P: unsubscribe()
P->>M: removeListener("pty:data:<id>", listener)
Note over R,M: relay frames — subscribe gates decryption
R->>P: termsprawl.relay.onFrame(cb)
P->>M: send(relay:frame:subscribe)
M-->>P: relay:frame
P-->>R: cb({ from, text })
Key nodes. The renderer only ever names a namespace method — it never sees ipcRenderer and never constructs a channel string. The preload is the single translation point from a method call to a channel constant. Per-entity push channels are distinct listeners per id, so a renderer that forgets to call the returned teardown accumulates live ipcRenderer listeners indefinitely. The relay subscription is the only path where the renderer's listener presence changes main-process behavior.
src/renderer/src/env.d.ts redeclares the whole surface as an ambient global:
declare global {
interface Window {
termsprawl: {
agentTools?: {
onRequest(callback: (request: CanvasToolRequest) => void): …src/renderer/src/env.d.ts#L50-L54
This is a hand-written mirror, not an import of TermsprawlApi. It independently re-imports every payload type from @shared/types, @shared/agent-status, @shared/update-status, and ../../core/chat/types. It is therefore the one place in the contract where drift is structurally possible: a method added to the preload without a matching entry here compiles fine in preload and fails only at the renderer call site. Note also that agentTools is declared optional (agentTools?), so renderer code is expected to treat the agent-tool surface as potentially absent.
-
Renderer never touches
ipcRenderer. Stated as a rule insrc/preload/AGENTS.md#L12. Everything crosses the bridge by structured clone, so payloads must be plain serializable data — the shared types imported by the preload are all plain object shapes. -
Channel strings live only in
src/shared/ipc.ts. Two observed exceptions bypass the map:ipcRenderer.invoke('app:runtime-info')(src/preload/index.ts#L84) andipcRenderer.on('relay:frame', …)(#L380). These are the escape hatches through which theIpcChannelunion no longer protects anything. -
runtimeInfohas a fail-soft fallback.invoke('app:runtime-info').catch(() => ({ packaged: true }))means a missing or failing handler resolves to the packaged-shaped value rather than rejecting. -
Build artifact path is a silent-failure trap. The preload builds as
index.mjs(ESM) and main must reference../preload/index.mjs, not.js; a stale path "silently breaks the bridge in production builds" (src/preload/AGENTS.md#L13-L15). A silently broken bridge presents aswindow.termsprawl === undefined, which is why the renderer declaration matters. -
Credential material stays in main.
githubClonereceives only the repo reference; the handler mints the credential-bearing clone URL and hands it straight to the local clone (src/shared/ipc.ts#L121-L129). This is the pattern any new credential-touching capability should follow. -
Edition discrimination is a literal on the surface. The desktop preload hardcodes
runtime: { kind: 'desktop' }; the Server Edition shim carries{ kind: 'server' }. Settings UI gates on this so desktop-only surfaces do not render in the browser edition (src/preload/index.ts#L76-L84). -
Declared-but-unexposed channels exist.
IPC.projectImport(src/shared/ipc.ts#L41) has no corresponding method in the read preload ranges. The inventory being a superset of the desktop bridge is expected — Server Edition RPC and push-only channels (githubSuggest,browserAgentOpen,chatEvent) share the same map.
-
Add the channel to the
IPCobject insrc/shared/ipc.ts. Never write the string anywhere else; pick the existing prefix convention (pty:,git:,cloud:,settings:…) so the group stays greppable. -
If it is per-entity push, add an
xChannel(id)helper next toptyDataChannel/ptyExitChannel/agentSessionNameChannel/chatEventChannelrather than interpolating inline. -
Add the method to the
apiobject insrc/preload/index.ts, choosing the correct shape:invokereturning aPromisefor request/response,sendreturningvoidfor ack-less writes, andonX(channelId, cb)returning a teardown closure for pushes. Keep the namespace grouping. -
Mirror it in
src/renderer/src/env.d.ts. This is the step the compiler will not remind you about. -
Register the handler on the other side — the Electron main dispatcher, or the Server Edition WS-RPC table for methods that must also work in the browser edition. A method that exists only in the preload will surface at runtime as an unresolved
invoke(or theruntimeInfo-style fallback, if you add one).
Sources: src/shared/ipc.ts, src/preload/index.ts, src/preload/AGENTS.md, src/renderer/src/env.d.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance