Under the hood

Architecture: how Nodal is put together

Nodal is an Electron application written in TypeScript with a React interface. This page explains the process layout, the terminal stack, storage, the provider seam, the event model and how it is tested.

The decision: Electron and TypeScript#

The platform was chosen deliberately, with alternatives compared and a written record. The earlier ToolTab v1 was a Windows-only WPF and .NET application with native Win32 interop. For version 2 the candidates were WPF/.NET, WinUI 3, Avalonia and Tauri against Electron. Electron won for three reasons that all follow from the product’s goals.

  • Plugins. The candidate plugin kernel, Cordis, runs natively in-process on Node. Only Electron gives it a home without a sidecar process or a cross-language bridge.
  • One rendering technology. Host UI and plugin UI share the same Chromium, which is what a replaceable sidebar, plugin-opened windows and freeform plugin-drawn UI need.
  • Reach. The UI layer needs no per-operating-system rewrite, which gives the most direct path from Windows to Linux and macOS. Cursor’s Electron lineage is evidence that the category works at scale.

The cost was flagged too: Electron has the heaviest resource footprint of the compared options. The record says to revisit if memory or CPU becomes a problem once Tab Mode runs many terminals. Measurement later showed the app itself is a small part of what each terminal costs, as the Tab Mode page describes.

The toolchain#

The build uses electron-vite, React with TypeScript, Vitest for unit tests and Playwright with its Electron API for end-to-end tests, packaged with electron-builder as a per-user NSIS installer. The repository is a single app folder, with a monorepo split deferred until the shared-contract and plugin-SDK boundaries are proven. Node 24 is the development runtime.

The process model#

PartRole
src/mainThe Electron main process. Owns the provider adapters and manager, the terminal host, storage and migrations, artifacts, the file explorer and browser views, review (Git), plugin hosting and the close and shutdown lifecycle.
src/preloadThe narrow, named bridge between main and the sandboxed renderer. There is no generic file, shell, browser-evaluation or process method.
src/rendererThe React interface: Agent Mode, Tab Mode, the conversation, the side panel, Settings and the NodalBeeper window.
src/sharedContracts, records and event types used by every process.

The main process owns authority. The renderer can request an action but cannot decide that a path, URL, process or provider object is safe. The main process resolves and validates local paths, classifies file types from content and extension, reads previews within fixed limits, starts and owns panel terminals and browser views, reads Git changes, coordinates close and shutdown, and sends renderer-safe records through named preload methods. This is the rule that the sandbox, the plugin system and the credential boundary all rely on; see the security page.

The terminal stack#

Terminals use xterm.js for drawing and node-pty over ConPTY on Windows. Several design choices are worth knowing:

  • An independent PTY layer, not Codex’s process APIs, because those exist only for commands the agent decides to run.
  • A host-owned PTY service inside a versioned seam. The terminal UI runs in the sandboxed boundary and the host grants IPC capabilities only after a handshake.
  • Ownership by identity. Process ID plus start time plus executable path plus session ID, re-checked before any kill. Details are on the Tab Mode page.
  • One selected owner for terminal-tree termination, tested against the packaged native module, because competing termination mechanisms are not combined without evidence.
  • Packaged native loading: the node-pty binding is rebuilt for Electron and unpacked from the archive, since native addons cannot load from inside it.

Storage#

State lives in atomic local JSON files behind a repository abstraction. One writer queue owns every persisted mutation, so there is no lock-versus-queue ambiguity. Files carry schema envelopes and versions, replacement is atomic on the same volume, backup precedence is defined in advance, and corrupt or unsupported data is quarantined and fails closed instead of loading as valid state. Migrations are explicit: the migration that separated 210 terminal windows from 214 session records deleted nothing, with 214 records in and 214 out. Nodal persists projects, session associations, settings, notifications and audit metadata. It does not persist live process handles or full provider transcripts by default, and SQLite is deferred until data volume justifies it. Future API-key storage, if it ever exists, would use Electron’s safeStorage backed by Windows DPAPI.

The provider seam and the event model#

Every provider is adapted into the same canonical vocabulary. The states are Unknown, Starting, Idle, Working, WaitingForUser, WaitingForApproval, Error and Exited. The events include SessionStarted, TaskStarted, OutputReceived, UserInputRequested, ApprovalRequested, TaskCompleted, TaskCancelled, SessionError and delivery results. Nodal adds its own envelope fields on top of the provider’s timestamp: a local sequence number, the Nodal session ID, the provider and provider session IDs, an ownership class and an evidence-quality grade.

That last field matters. Each mapping is graded Real (observed against a live provider), Protocol-shape-only or Simulated. A fake provider is never allowed to upgrade a grade, and a test asserts it. Where the real provider has no truthful signal, Nodal says Unknown rather than guessing, so a provider crash maps to Unknown and never to Exited. The event consumer silently ignores event types it does not recognize, because a short real turn emitted many that had no mapping.

Identity is split into separate fields so they cannot be confused: Nodal project and session IDs, the provider ID and provider session ID, a user nickname and a generated fallback label, host identity and process identity. One example shows why. A Codex thread carries a field named sessionId that belongs to Codex, not Nodal, and on a root thread holds the same value as the thread ID. The fake provider makes the two differ on purpose, so a mapper that read the wrong one is visible.

Notifications and the simulator#

The event stream feeds the status center (NodalBeeper), operating-system toasts and the tray badge. An in-process Agent Simulator speaks the same canonical contract with an injectable clock, scenario scripts and buttons for every state, so the whole pipeline can be exercised deterministically. The simulator is deliberately not used in the tests that measure the real Codex provider.

The right sidebar as one panel host#

The panel is a single host with a typed tab model rather than several sidebars side by side. Tabs are Files, Review, Browser, Terminal and, later, Side chat. Files and Review are single-instance per conversation, while Browser and Terminal can have several. Switching tabs hides a live body without destroying the resource, closing a tab asks the resource owner to close it, and a tab never starts a terminal, browser or provider session until you or an authorized agent opens it. Live resources are not restored after a restart; only layout is.

Shutdown and close#

All close sources route through one main-process coordinator with independent rules for the window X, system close and Alt+F4. During shutdown the main process stops requests, disconnects surfaces, closes plugins and the provider, ends owned terminals and drains storage while the window stays alive to show typed progress, instead of freezing. Terminals are children of Nodal and end with it; provider-side Codex sessions are resumable after relaunch.

Testing and evidence#

Local checks are the normal loop. A single command runs the type check, unit and main-process tests and the production build, and Playwright drives the packaged app against a purpose-built fake provider. At the beta release the local gate ran more than 1,600 unit tests with one intentional skip, and more than 150 Electron cases passed across the related, regression and broader groups. Hosted CI on the private repository is metered, so it is treated as a manual final gate run once when a branch is ready, never rerun until the cause of a failure is understood. Some terminal-lifetime specs start and stop real shells and run only on disposable CI machines. On Linux, 583 unit tests and 175 of 176 end-to-end tests passed, with the skip being an opt-in test, and an AppImage builds and runs.

Every delivered package carries dated evidence: the exact commands, results, screenshots and a hash manifest. The next page, security, covers the trust model, and how it’s built covers the humans and agents who produced all of this.

Design principles#

A few rules recur in every decision. Keep authority in the main process and let the renderer only ask. Make states explicit and honest, with an Unknown value for anything that cannot be known. Split identities so they cannot be confused. Fail closed on corrupt data, an unknown version or an uncertain process identity. Keep fake evidence apart from real evidence. And prefer the boring, measured option, such as a DOM renderer chosen by measurement or a cached process list replaced by a direct read, over the clever one.

Linux, in one paragraph#

Nodal runs on Windows and Linux from one codebase, a decision that has already paid for itself. Making Linux work exposed real bugs that had been invisible on Windows. A cached process list returned a snapshot from before a terminal’s own shell existed, giving it an empty start time and making the ownership check refuse to end a process Nodal actually owned. On Windows the WMI query took long enough that the cache had always expired. Linux now reads the process table directly, which was faster and more exact. Linux features may land a little after Windows, and macOS is planned, not started. The roadmap tracks both.