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() returns void, not a cleanup/unsubscribe function. You cannot capture a handle to abort or clean up an active stream from the caller side.Set (like pendingStreamChatIds in useStreamChat.ts) or a React state/ref-based lock, not the return value.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.
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.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):
Map<WebContents, Payload[]>.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 + streamingContent fields update only the actively streaming message's content. Used for high-frequency text-delta streaming to avoid serializing the full messages array on every chunk.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.
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.
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 creating hooks/components that call IPC handlers:
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.useEffect only if required.