@acpjs/electron
v0.2.2
Published
acpjs Electron bridge: main host adapter, preload handshake surface, renderer transport (three entries).
Readme
@acpjs/electron
Electron bridge for acpjs. Three subpath entries that never import each other's runtime code — they only move acpjs host-client envelopes; ACP protocol handling stays in @acpjs/core.
Install
pnpm add @acpjs/electron @acpjs/core @acpjs/client @acpjs/registryESM-only, node >= 24. Peer: electron >= 30. import 'electron' runs only in /main and /preload; /renderer is environment-neutral (plain MessagePort).
Entries
@acpjs/electron/main—attachAcpBridge(host): () => void. Builds a host endpoint, registers oneipcMain.handle('acpjs:handshake'), creates oneMessageChannelMainper window (port1bridged to the endpoint,port2transferred to the window). RequirescontextIsolation: true. Returnsdetach.@acpjs/electron/preload—exposeAcp(). Exposes{ connect(): Promise<void> }viacontextBridge.exposeInMainWorld('acp', …)and relays the transferred port into the main world.@acpjs/electron/renderer—electronTransport(options?): HostClientTransport. Triggers the handshake viawindow.acp.connect(), then implements the full contract on aMessagePort. Inject{ requestPort }for a custom handshake.
Usage
Main
import { app, BrowserWindow } from 'electron'
import { createAcpHost } from '@acpjs/core'
import { attachAcpBridge } from '@acpjs/electron/main'
const host = createAcpHost({ restart: 'on-crash' })
let detach: (() => void) | undefined
app.whenReady().then(async () => {
await host.spawnAgent({ id: 'a', command: 'npx', args: ['some-acp-agent'] })
detach = attachAcpBridge(host)
await new BrowserWindow({
webPreferences: { preload, contextIsolation: true },
}).loadFile('index.html')
})
app.on('before-quit', async () => {
detach?.() // send closed to renderers + close ports, BEFORE host.dispose()
await host.dispose()
})Preload
import { exposeAcp } from '@acpjs/electron/preload'
exposeAcp()sandbox: true (Electron default): preload must be a single CJS bundle with electron external. sandbox: false: can load the ESM entry directly via .mjs.
Renderer
import { createAcpClient } from '@acpjs/client'
import { electronTransport } from '@acpjs/electron/renderer'
const client = createAcpClient({ transport: electronTransport() })AgentDefinition is main-only — do not smuggle one to the renderer. Hydrate an existing agent/session instead:
const [snap] = await client.agents.list()
const agent = await client.agents.attach(snap.agentId)
const session = await agent.sessions.create({
cwd,
mcpServers: [],
additionalDirectories: [],
})Key semantics
- Shutdown order: call
detach()beforehost.dispose()— reversing kills child processes before the close signal reaches renderers. attachAcpBridgehas no ordering dependency on window creation (registerable beforewhenReady); calling it twice throws (re-registeripcMain.handle) —detachfirst.- Renderer transport is single-use: after
close(incl. main-sidedetach, window destruction, or peer port close),connectrejects permanently withacpjs/transport-closed. After a reload you must rebuildelectronTransport()+createAcpClient; catch up viafromSeq(handled by@acpjs/clientonsubscribe). Re-attach a session withclient.sessions.attach(previousSessionId). - Per-window isolation: each handshake gets an independent port; windows never affect each other.
- Port message protocol (internal): renderer→main
request | subscribe | unsubscribe | inbound-response | close; main→rendererresponse | event | sub-error | inbound-request | inbound-ack | closed. - Subscription failure: a synchronous endpoint
subscribethrow (e.g.acpjs/session-closed) is reported assub-error→onSubscriptionError; blast radius is limited to that subscription. - Errors: transport errors are
ErrorwithErrorObjectfields (code/retryable/data?),name: 'AcpElectronTransportError', recognized by@acpjs/client. contextIsolationcheck relies on the trusted preload reportingprocess.contextIsolated; guards against misconfiguration, not a malicious page.
