rules/electron-ipc.md
This project uses a contract-driven IPC architecture. Contracts in src/ipc/types/*.ts are the single source of truth for channel names, input/output schemas (Zod), and auto-generated clients.
defineContract + createClient) — Standard request-response calls.defineEvent + createEventClient) — Main-to-renderer pub/sub push events.defineStream + createStreamClient) — Invoke that returns chunked data over multiple events (e.g., chat streaming).| Layer | File | Role |
|---|---|---|
| Contract core | src/ipc/contracts/core.ts | defineContract, defineEvent, defineStream, client generators |
| Domain contracts + clients | src/ipc/types/*.ts (e.g., settings.ts, app.ts, chat.ts) | Per-domain contracts and auto-generated clients |
| Unified client | src/ipc/types/index.ts | Re-exports all clients; also exports ipc namespace object |
| Preload allowlist | src/preload.ts + src/ipc/preload/channels.ts | Channel whitelist auto-derived from contracts |
| Handler registration | src/ipc/ipc_host.ts | Calls register*Handlers() from src/ipc/handlers/ |
| Handler base | src/ipc/handlers/base.ts | createTypedHandler with runtime Zod validation |
src/ipc/types/<domain>.ts file using defineContract().createClient(contracts) from the same file.src/ipc/types/index.ts.src/ipc/handlers/<domain>_handlers.ts using createTypedHandler(contract, handler).src/ipc/ipc_host.ts.// Individual domain client
import { appClient } from "@/ipc/types";
const app = await appClient.getApp({ appId });
// Or use the unified ipc namespace
import { ipc } from "@/ipc/types";
const settings = await ipc.settings.getUserSettings();
// Event subscriptions (main -> renderer)
const unsub = ipc.events.agent.onTodosUpdate((payload) => { ... });
// Streaming
ipc.chatStream.start(params, { onChunk, onEnd, onError });
createStreamClient(...).start(input, callbacks, opts?) returns the correlation identity for that start() call: an InvocationRef when supplied, or a legacy monotonic numeric streamId. It is not an abort handle — aborting still goes through the domain channel (e.g. chat:cancel).start() for the same key replaces the previous entry, so events can never reach a replaced entry's callbacks (structural stale-event rejection).InvocationRef. When present, createStreamClient routes chunk/end/error events only to the matching operation; an absent ref preserves legacy key-only routing for in-flight streams crossing an app update. Numeric streamId matching remains only for older stream contracts.InvocationRef through the injected IdSource at the authoritative start boundary. Globally unique operation IDs eliminate cross-controller lifetime reuse without retaining per-key generation maps.onEnd/onError (including invoke rejection) must delete the entry only when the map still points to the generation that ended; an unconditional keyed delete can orphan the replacement stream.autoRelease: true). Pass { autoRelease: false } to keep receiving events after a terminal event, and call release(key, { invocationRef }) when done — the chat stream machine uses this to keep entry ownership with its controller until finalization side effects complete (a stale release is a no-op).ipc.chatStream.start or guard against duplicate streams from renderer code. The main-owned chat_stream actor is the single lifecycle and queue authority; submit through useStreamChat().streamMessage or ChatStreamRemoteManager.ensure(chatId).send({ type: "submit", ... }).requestedChatMode sentinel instead of sending the computed display mode as
an explicit override; otherwise main cannot apply the latest provider/quota
state before the first turn.invalid-event. If a bounded domain
schema legitimately permits larger payloads (for example base64 chat
attachments), set that machine's remote.maxDispatchEnvelopeBytes to match
the schema limit instead of raising the global transport limit.writeSettings)writeSettings(partial) does a shallow top-level merge: { ...currentSettings, ...partial }. This means passing { supabase: { organizations: { ... } } } replaces the entire supabase key, losing sibling fields like legacy tokens. Callers must spread the existing parent object:
// WRONG — destroys supabase.organizations and other fields
writeSettings({ supabase: { accessToken: { value: newToken } } });
// RIGHT — preserves sibling fields
const settings = readSettings();
writeSettings({
supabase: { ...settings.supabase, accessToken: { value: newToken } },
});
Stale-read race condition: If you call readSettings() before an async operation (network call, file I/O), then use the snapshot to construct the write, any concurrent settings changes during the async gap will be silently overwritten. Always call readSettings() immediately before writeSettings() — never across an await boundary.
Stream-admission barrier atomicity: In chat_stream_handlers.ts, a stream's final admission-block check (streamAdmissionBlockCounts) and its admissionPendingStreams.delete(controller) "start" transition must run in the same synchronous frame — no await between them. cancelActiveStreamsForApp (used by restore-to-message) deliberately skips controllers still in admissionPendingStreams, so a restore that installs its blockNewStreamsForApp barrier in a gap between the check and the marker removal would neither cancel the stream nor make it re-observe the new barrier — letting it start mid-restore and dirty the freshly reverted tree. Adding any await in that window silently reintroduces this race.
Electron readiness: readSettings() and writeSettings() may decrypt/encrypt secrets through Electron safeStorage, which throws safeStorage cannot be used before app is ready before app.whenReady(). Queue pre-ready entry points like deep links (open-url, second-instance) until the app/window is ready before calling OAuth/settings handlers. In a multi-window flow, tie renderer readiness to the current delivery target: mark delivery not-ready when the target changes to a loading window, and drain only after that target finishes loading. A global first-window-ready flag can flush payloads to a different renderer before its listeners exist.
did-finish-load can still precede React effect subscriptions, so fire-and-forget startup events must register a renderer-module-level listener before bootstrap and replay buffered payloads when their UI consumer mounts.
Mark transport readiness before development-only completed-load filters: a DevTools reload can abort the initial navigation, making the filtered completion the window's only did-finish-load event.
Clear a window's renderer readiness only for a non-in-place main-frame did-start-navigation. did-start-loading is broader and can leave deep links queued when a usable renderer triggers loading activity that has no matching top-level did-finish-load.
When explicit window creation awaits loadURL() / loadFile(), start any
development-only DevTools reload only after that initial load promise resolves.
Scheduling the reload first can reject the awaited promise with
ERR_ABORTED (-3) and incorrectly roll back a healthy window.
When a loaded window consumes and clears a persisted one-shot event, send the
event through that known-ready window rather than BrowserWindow.getAllWindows()[0].
In multi-window startup the first global window may still be loading, which
would drop the only replay before its early listener exists.
For cross-window ownership transfer, a successful webContents.send() is not
an acknowledgement. Buffer the request before React mounts, require a
correlated renderer receipt after local persistence, retain the main-process
transfer until that receipt arrives, and roll back source or destination state
on timeout/failure so the same stable identity cannot remain in both windows.
The typed IPC handler must return/await the receipt promise; dropping it reports
success early and turns later rejection into an unhandled main-process promise.
The persistence path used for the receipt must propagate or verify write
failure—best-effort storage adapters that log and swallow errors do not prove
durability.
If either side writes ownership to durable renderer storage before the receipt,
also reconcile duplicate stable identities across restorable sessions during
bootstrap. An in-memory coordinator cannot resolve the crash/restart window by
itself.
If the source durably removes transferred state before the destination observes
its acknowledgement, persist a correlated removal marker until the destination
observes that acknowledgement. On a lost receipt, the destination can use the
marker to keep its durable adoption instead of rolling back both copies.
The receipt timeout must retain that correlated settlement long enough for a
late confirmation; the destination's explicit rollback/rejection is the abort
decision that makes later source confirmation invalid.
When a replayed renderer event mutates persisted session state, do not consume it until the session's derived atoms have hydrated. Persisting from empty pre-hydration atoms can erase unrelated restored entities.
Custom-protocol debugging: Before using git bisect on a dyad:// flow, quit every dev and packaged Dyad instance and verify which build owns the protocol registration. macOS may route the link to a different running/registered build, producing a convincing but false good/bad result.
throw new Error("...") on failure instead of returning { success: false } style payloads.DyadError with the right DyadErrorKind so PostHog does not flood with $exception events — see rules/dyad-errors.md.createTypedHandler(contract, handler) which validates inputs at runtime via Zod.createTypedHandler, createLoggedHandler, or registerTrustedIpcHandler; never call ipcMain.handle or ipcMain.handleOnce directly outside trusted_handle.ts. The facade enforces the trusted-main-frame policy for both contract and legacy channels.ipcMain.handle callback to the trusted facade, extract a named local handler first. Adding another wrapper level around the inline callback makes the formatter reindent the entire body and obscures the security-only diff.createTypedHandler returns the handler result unchanged outside development. Explicitly project and map renderer-visible database columns before returning, especially for large or main-only fields such as aiMessagesJson.src/preload.ts (especially src/ipc/contracts/core.ts), run npm run build before E2E. The preload Vite target may not resolve @/... aliases from those shared modules; use relative imports for preload-reachable shared code when packaging reports Rollup failed to resolve import "@/...".app.on(...) or similar Electron API calls in modules that are imported broadly by tests. Many unit tests mock only the Electron APIs they touch, so prefer guarded calls like app?.on?.(...) or move event registration behind an explicit initialization function.BrowserWindow close
callbacks. Catch and log file writes before continuing in-memory registry,
focused-window, and delivery-target cleanup; otherwise a closed window can
remain the authoritative target.before-quit must finish asynchronous cleanup, call event.preventDefault() synchronously, wait with a hard timeout, then call app.quit() again behind a re-entry guard so cleanup cannot hang or recursively restart shutdown.src/ipc/services/*. Preserve any intentional module side effects in the extracted service, such as fixPath() for child process PATH setup.net.request() response typings do not expose every runtime stream event. If download code needs a close guard in addition to aborted/error, cast the response through EventEmitter instead of dropping the guard to appease npm run ts.AbortSignal.timeout() via AbortSignal.any(), do not identify every fetch cancellation by matching AbortError: Node propagates the timeout signal's TimeoutError reason. Check the original controller's signal.aborted and the timeout signal's aborted state separately so user cancellation and timeout keep their intended error classifications.AbortSignal to fs.promises.writeFile is not sufficient because cancellation is best-effort and may leave a partial file. Write to a same-directory temporary path, remove it on failure or abort, check cancellation before and after an atomic rename, and remove the finalized path if cancellation raced the rename.All React Query keys must be defined in src/lib/queryKeys.ts using the centralized factory pattern. This provides:
Usage:
import { queryKeys } from "@/lib/queryKeys";
import { appClient } from "@/ipc/types";
// In useQuery:
useQuery({
queryKey: queryKeys.apps.detail({ appId }),
queryFn: () => appClient.getApp({ appId }),
});
// Invalidating queries:
queryClient.invalidateQueries({ queryKey: queryKeys.apps.all });
Adding new keys: Add entries to the appropriate domain in queryKeys.ts. Follow the existing pattern with all for the base key and factory functions using object parameters for parameterized keys.
When an IPC event can fire at very high frequency (e.g., stdout/stderr from child processes), batch messages and flush on a timer instead of sending each message individually. This prevents IPC channel saturation, excessive array allocations in the renderer, and unnecessary React re-renders.
Pattern (see app_handlers.ts enqueueAppOutput/flushAllAppOutputs):
send() throw after a
liveness check, so catch per destination (and per payload for individual
delivery) to ensure one failed window cannot abort fanout to healthy peers or
escape from a timer callback.WebContents.isDestroyed() to observe terminal sends must
dynamically expose a non-integer producer ID once its real target is gone.
Otherwise high-volume routing can re-register the destroyed endpoint.setTimeout on first enqueue; flush all buffered messages as a single batch event (e.g., app:output-batch) when the timer fires (100ms default).input-requested) on an immediate, unbatched channel.setConsoleEntries(prev => [...prev, ...newEntries])) instead of one update per message.The chat:response:chunk event supports two modes:
messages field contains the complete messages array. Used for initial message load, post-compaction refresh, and lazy-edit completions.streamingMessageId + streamingPatch: { offset, content } fields. The renderer reconstructs the full content as current.slice(0, offset) + content. offset is the longest-common-prefix length between the previously sent content and the new full response (not simply the old length), because cleanFullResponse may retroactively rewrite bytes inside in-progress dyad-tag attribute values. Used for all normal high-frequency text-delta streaming. Implemented via computeStreamingPatch in src/ipc/utils/stream_text_utils.ts.When modifying ChatResponseChunkSchema or adding new safeSend("chat:response:chunk", ...) call sites, decide which mode is appropriate. All frontend consumers (useStreamChat, usePlanImplementation, useResolveMergeConflictsWithAI) must handle both modes.
Tail-diff baseline invariant: Never call safeSend("chat:response:chunk", { messages: ... }) directly in local_agent_handler.ts. Route all full-update sends through sendResponseChunk(..., true, lastSentRef) so lastSentRef stays in sync automatically. A bare safeSend bypasses the sync and leaves lastSentRef stale, causing the next patch to compute LCP against the wrong baseline and corrupting streamed output.
Peer-stream correlation: A multi-window passive stream consumer may project chunks classified as unsolicited because that renderer has no local invocation owner. It must not project chunks classified as stale: those belong to a superseded local invocation and retaining the old correlation rejection prevents late output from overwriting the current stream.
Zod schema contract changes: Making a field optional (e.g., messages → messages.optional()) causes TypeScript errors in all consumers that assume the field is always present. Search for all destructuring/usage sites and add guards before committing.
Renderer-visible fields must be in the output schema: createTypedHandler validates handler output through the contract's Zod schema. If the handler returns extra fields that are not declared in the output schema, renderer code cannot type-safely consume them and they may be stripped by parsing. Add any consumed fields (for example appId on ChatSchema) to the IPC output schema when relying on them in renderer code.
Model refusals are stream completions, not errors: AI SDK providers can normalize a successful safety refusal to finishReason: "content-filter" while preserving a provider-specific value such as rawFinishReason: "refusal". Route every stream-consumption path (including continuation/fix streams) through the shared refusal handling, treat refusal as terminal for follow-up generation, discard incomplete output from the refused attempt, and persist a renderer-visible warning in both renderer content and AI history instead of relying on onError or matching generated text.
When a main-process workflow needs to show a user-facing warning toast after a turn completes, thread it through every completion path, not just chat:response:end. Build-mode auto-approve and local-agent flows use ChatResponseEndSchema, while manual proposal approval uses ApproveProposalResultSchema; surface the warning in both useStreamChat and ChatInput so the behavior stays consistent.
When changing install-policy constants or helpers in src/ipc/utils/socket_firewall.ts, search all command builders before committing. The same policy can be consumed by add-dependency processing, app startup (src/ipc/services/app_runtime_service.ts), and cloud sandbox setup, so removing an export like NPM_INSTALL_POLICY_ARGS can leave stale imports that only npm run ts catches.
Do not treat "pnpm is available but older than the minimumReleaseAge-supporting version" the same as "pnpm is unavailable." PNPM_INSTALL_POLICY_ARGS currently use --config.* flags, which pnpm 10.15.0 and 9.0.0 accept on pnpm install; keep using pnpm with those flags when it is present, and only fall back to npm when the pnpm binary cannot be run.
When validating pnpm flag compatibility, test real subcommands such as pnpm install, pnpm run, and pnpm add, AND pnpm --version separately — the failure modes differ. Empirically (tested 8.15.9, 9.0.0, 9.15.4): older pnpm accepts arbitrary --config.* flags on real subcommands but rejects them on --version (ERROR Unknown option: 'version'). Keep availability probes flag-free (pnpm --version with getPackageManagerCommandEnv(), which delivers the same settings via npm_config_* env vars), or a working pnpm gets misreported as unavailable and Dyad silently falls back to npm.
When running Dyad-managed package-manager install/add/probe commands from inside an app directory, use getPackageManagerCommandEnv() so Corepack ignores stale project packageManager pins via COREPACK_ENABLE_PROJECT_SPEC=0. Apply this to pnpm --version probes and npx sfw ... wrappers too, since the wrapped package manager inherits the parent env; avoid forcing it onto user-authored custom commands unless intentionally changing their package-manager semantics.
When generating pnpm-workspace.yaml for install policy (allowBuilds, minimumReleaseAge), include a top-level packages: block such as packages: ["." ] if one does not already exist. pnpm 9 treats pnpm-workspace.yaml as a workspace manifest and fails with packages field missing or empty when the file only contains config keys.
Automated pnpm add commands that run in an app root with a generated pnpm-workspace.yaml must pass --ignore-workspace-root-check. Otherwise older pnpm versions can fail with ERR_PNPM_ADDING_TO_ROOT even though Dyad intentionally installs into that app root.
When creating hooks/components that call IPC handlers:
0 for a fresh cache), and dedupe
buffered live events against replay. Advancing directly to the bootstrap's
current epoch can acknowledge and discard an event received during startup.
Retry failed bootstrap attempts with bounded backoff, clearing pending data
that the next epoch replay will recover so a half-initialized listener cannot
grow an unbounded queue. Keep long-term gap-recovery history bounded by
compacting entity-specific scopes to family-root invalidations once precision
is no longer required; a bounded event journal alone does not bound a
lifetime recovery-scope map.createLoggedHandler/handle(...) must
publish after their authoritative mutation explicitly or migrate to a typed
contract. When the origin renderer installs only some mutation scopes
locally, carry the exact handled scopes with the invalidation event: peers
invalidate every scope, while the origin skips only equivalent local data
and still receives its unhandled scopes. Omitted origin-handled metadata must
default to no handled scopes; only declare originHandles when every caller
of that contract performs the matching local cache update/invalidation.useQuery, using keys from queryKeys factory (see above), async queryFn that calls the relevant domain client (e.g., appClient.getApp(...)) or unified ipc namespace, and conditionally use enabled/initialData/meta as needed.useMutation; validate inputs locally, call the domain client, and invalidate related queries on success. Use shared utilities (e.g., toast helpers) in onError.apps.detail(...) and apps.all (for example linking or unlinking a GitHub repository), invalidate both query families. Refreshing only the detail query can leave parent pages that derive conditional UI from the apps list stale.useEffect only if required.queryClient.getQueryData(...) as an optional cache peek. When a
mutation post-effect must inspect IPC-backed data to decide correctness-critical
work (such as restarting a runtime), use fetchQuery/ensureQueryData with
the canonical query key and query function so cache eviction cannot skip it.settings.hasRunBefore after startup. onFirstRunMaybe flips that setting before createWindow(), so expose the pre-write value through an IPC/query context instead.isProviderSetup() env-var detection only sees env vars whitelisted by the get-env-vars handler in src/ipc/handlers/app_handlers.ts, which returns one envVarName per provider. Providers needing extra env vars (e.g. Azure's AZURE_RESOURCE_NAME) must have those keys added to the handler explicitly, or the renderer reports the provider as not set up even though the main process can use it.src/testing/handler_test_harness.ts (setupHandlerTestHarness + harness.invokeHandler("channel", input)) gives you a real in-memory DB and works even for heavyweight modules: registerAppHandlers loads in vitest with just vi.mock("electron") plus module mocks for @/paths/paths (point getDyadAppPath at a temp dir), @/ipc/services/git_service, createFromTemplate, gitignoreUtils, and chat_mode_resolution.
await
can pass a Promise into a database binding and fail far from the changed
helper.createTypedHandler land in the harness registry. Handlers registered with createLoggedHandler/handle(...) (e.g. import_handlers.ts) must be captured through the mocked ipcMain.handle — and their return value is an IPC envelope shaped { ok, value, error } (NOT { success, data }), so unwrap accordingly.ipcMain.handle listener run through the production trust facade. Call configureTrustedRenderer(...) and pass an event whose senderFrame matches sender.mainFrame; an empty {} event now fails with Renderer trust policy is not configured before the tested handler runs.index.html URL into root-relative locations such as file:///chat (file:///C:/chat on Windows). IPC trust must require senderFrame === sender.mainFrame, file: with an empty host, the configured file-volume prefix, and an allowlisted renderer route; pinning only the built entry pathname breaks packaged IPC, while accepting arbitrary file paths is unsafe.setWindowOpenHandler details do not identify the initiating frame. When preview iframes need popups, fail closed on missing or privileged request details and construct allowed HTTP(S) popups yourself after removing inherited preload and forcing sandboxed, Node-disabled web preferences; about:blank cannot be safely overridden this way.BrowserWindow reference for every popup created through a custom createWindow callback until its closed event. A callback-local window can be garbage-collected and close an active OAuth or payment flow; remove the reference on close so the owner collection remains bounded.