Electron owns a sandboxed window onto the background Executor server that the CLI uses. The desktop starts that server, or joins the one already running for the CLI's data directory, and shows its dashboard. The window uses the same dashboard, HTTP API, pairing exchange, and account model as the browser app. No Node API or persistent API key is exposed to the renderer.
Release installers include the official Node distribution matching the CLI build
toolchain. The builder verifies its upstream checksum and retains its license
notices. The packaged app runs the background server with that executable;
development runs the CLI from source with the Node on PATH.
Use Node 22.23+. No configuration is needed: scripts/local-dev.ts supplies a
development key pair from the checkout's ignored .local/dev/. Variables that
you set, such as the local server configuration,
win over these defaults. Run from the repository root:
bun install
bun run desktop:devThis builds the Electron main entry, creates a locally signed macOS development
bundle, and launches the app. A background server it starts serves the dashboard
through Vite with hot reload. The background server outlives the app, so after
changes to server source run bun run executor service stop, then
bun run desktop:dev again. A server that bun run executor started serves the
built dashboard; stop it first to get hot reload. After changes to Electron
source, rerun bun run desktop:dev.
On macOS, reopen .local/desktop-runtime/Executor Dev.app from Finder. Its launcher
loads the same scripts/local-dev.ts defaults each time. The bundle remains attached to this
workspace, like T3 Code's development launcher. It is not a release installer.
Finder launches write diagnostics to .local/desktop.log.
To use the built dashboard instead of Vite:
bun run desktop:startA second launch focuses the existing app. Closing the last macOS window keeps the
app running; reopening from the Dock restores the dashboard. Quit Executor
and Ctrl+C in a terminal launch stop the app only: the background server keeps
running for the CLI and MCP clients until executor service stop.
Use File → Open in browser to connect your system browser to the same server. It opens an authenticated dashboard with a one-use link. No CLI is needed; MCP clients can then use that browser for local consent.
- The desktop shares the CLI's data directory and its one background server:
~/.executor/v2/cliwhen packaged, andbun run executor's.local/dev/localin development.EXECUTOR_DESKTOP_DATA_DIRchooses another directory. - Desktops before the background service kept their own data in
<user data>/data(.local/dev/desktopin development). The first launch moves it into the shared directory once, before any server uses it. The port follows the desktop's data: its saved port, or 4312 when it saved none. The CLI's otherservice.jsonsettings are carried over, except theenvvariables tied to the CLI's own data (EXECUTOR_PORT,EXECUTOR_KEY_STORAGE,EXECUTOR_API_KEYandEXECUTOR_ENCRYPTION_KEY) anddisabled: the desktop always runs the background server, whichexecutorwould otherwise find holding the data. They are read just before the copy moves into place, so a move resumed on a later launch uses the settings the CLI has then. Whatever was in the shared directory moves tobackups/cli-data-<time>beside it, with its originalservice.json. When that held the CLI's data, or the move changed the CLI's port or left settings out, a notice names the folder and those settings once the window first shows the dashboard or the recovery page. The shared directory ends with mode0700, whatever the old directory's was. Until it has been answered, the notice is kept with the move's record inmigration.json, so a launch that ends first shows it next time. Only an old directory with a database moves; one without, as after a first start that failed, is left where it is and the shared directory is not touched. Both directories stay locked, as a server locks them, from before the copy until the renames are done, so no server starts in either meanwhile. Each lock records the desktop as its owner, so servers treat it as held while the desktop runs and take it over at once if the desktop crashed. A lock lost before its directory moved stops the copy, no rename runs without its lock, and the move rolls back as after any failure. The window opens first and says the data is moving while it does; its menu then has none of the app's own actions, which need the data. Quitting meanwhile, or closing the window, ends the app once the move is done. A directory chosen withEXECUTOR_DESKTOP_DATA_DIRonly takes over an old one chosen withEXECUTOR_DESKTOP_LEGACY_DATA_DIR. If the move fails, nothing is deleted, no server starts and the recovery page explains what to fix. - The Electron user-data directory (
.local/desktop-shell/in development) holds Chromium's profile and, when packaged,runtime/<version>-<build>/: the bundled server runtime copied out of the app once per build. The server runs from that copy, so it keeps running while an installer replaces the app or an AppImage unmounts. A copy is used only once.staged.json, written last, names its build; a copy without it, as after an interrupted removal, is staged again. On Windows, renames and removals that a virus scanner briefly blocks are retried for a few seconds. Copies other versions no longer use are removed after the desktop's own version runs. Browser session cookies use an in-memory partition and are replaced by a new pairing on launch. - On launch the desktop replaces a background server of another version with its
own, as an interactive
executordoes. It then pairs the window:POST /auth/pairwith the server's registered password as the bearer, and loads the one-use link. A dashboard load that fails, or has not finished after 30 seconds, is tried again with a fresh link two seconds later; after three attempts in a row the window shows its own recovery page with the failure. The server still runs, so that page offers no reset: Restart joins the server and loads the dashboard again. A load that another navigation of the window replaced, such as an OAuth return, is left to it, also while a retry waits. - The desktop checks every second that the registered server still answers. When
another client replaces it, the desktop joins the new one and pairs the window
again. A server that stops answering while it is still registered, as after a
crash, counts as lost: the desktop joins whichever server runs then, starting
one if none does, and three losses within a minute show the recovery page
instead. A server stopped on purpose, as by
executor service stoporexecutor service set …, says it is stopping until it exits and removes its registration, however long that takes, and is not counted or started again: the window says The background Executor was stopped, and the desktop joins the next server that registers and answers, as afterexecutororexecutor service restart. Start there starts one. - The background server runs with the desktop's environment, without Electron's
own
ELECTRON_*variables. It keeps the rest, such as Windows'sSystemRoot, which Windows programs need. In an AppImage it also dropsAPPDIR,APPIMAGE,ARGV0andOWDand every entry inside the AppImage's mount fromPATH,LD_LIBRARY_PATH,XDG_DATA_DIRS,GSETTINGS_SCHEMA_DIRand any other variable: the mount is removed once the app quits, while the server keeps running, and another local user could then create that path. - The desktop starts or joins the server from the server starter, a short-lived
Electron utility process that runs the CLI's start logic, reports the server and
exits. The server outlives the app, so it must not keep the app's streams or
files open. Electron's main process keeps its standard streams inheritable, and
on Windows a child inherits every inheritable handle, so a server started there
would keep the app's stdout and stderr open after it quits: whatever waits for
them to close, such as a terminal or a launcher, would wait until the server
stopped. Chromium starts the utility process with only the handles it names, and
Node there makes its standard streams non-inheritable. One handle is still
inherited on Windows: the starter's end of its channel to the app, which
Chromium creates inheritable, so a server holds it for as long as it runs. That
keeps none of the app's streams or files open, and Electron still notices the
starter exiting, even when it is ended from outside. A starter that hangs
instead never reports, so the desktop gives the starter three minutes, beyond
the CLI's two-minute start deadline, before it fails the start and shows the
recovery page. On Linux the starter still holds descriptors Chromium gave it
without close-on-exec, such as Electron's V8 snapshot, which is inside an
AppImage's mount, so the server starts through Bash, which closes every
descriptor above stderr first. Without
/bin/bashthe server starts directly and keeps them, so an AppImage's mount stays in use while it runs. The starter's channel to the app is close-on-exec on Linux, so a server never holds it there. macOS starts each child with only the descriptors it names. The starter reports each start attempt it begins and when that attempt exits. If the starter does not exit when told to, the desktop first ends its attempts that still run, except the registered server, as the CLI's start logic would, and then kills it. It ends them only while the starter itself still runs, hung or stopped: once the starter has exited, even before the system has reaped it, an attempt's process ID may name another process, so the attempts of a starter that is gone are left to finish. - The server listens on
EXECUTOR_PORT, else theportin the data directory'sservice.json, else 4312. File → Server port… editsservice.json; anEXECUTOR_PORTlocks it. - OAuth consent opens in the system browser. Before it opens, the desktop tells the
server to expect the authorization's
state. The server keeps the provider's callback for an expected state, shows the browser a page asking to return to Executor, and hands the callback to the desktop, which waits for it with an authenticated long poll. The desktop loads it in the original window, keeping its session and return intent. At most 16 states and callbacks are kept, for 15 minutes each. Callbacks for other states, including every OAuth a browser starts, complete in that browser. - External HTTP(S) links open in the system browser. Other schemes and embedded Node access are disabled. App-origin launch links also open in the browser.
- Desktop warms the entry graph and keeps the renderer unthrottled during startup. HMR uses a separate ephemeral loopback listener and Vite cache, so it can run alongside the browser development server.
- Start and key-setup failures show the same script-free recovery page, with what
the server said. A server whose key setup refuses to start writes its reason as
one line on stderr before its message; the desktop reads it from the start
failure and chooses the page's actions from it. Its buttons navigate to
executor-recovery:start|restart|logs|reset|quit, which the main process intercepts; there is still no preload or IPC. Reset is never offered when the OS credential store is unavailable or denies access, whenEXECUTOR_KEY_STORAGEor supplied keys do not match the data, or when the data is locked by a process that is not its background server, including an Executor from before the background service that itsserver.jsonnames. A denied store offers Restart, which prompts again. - A crashed renderer reloads up to three times per minute. After that the app says the window kept crashing, names the diagnostics folder and quits. The native error box is otherwise used only when the window itself fails.
Help → Reset Executor data… and the recovery page's Reset data… ask for
confirmation (Cancel is the default). Reset then stops the background server, waits
for its telemetry collector to exit and takes the data directory's server and
bootstrap locks, so no other client starts a server meanwhile. It takes the
server lock as a server does, so a lock left by a server that crashed or was
killed is taken over, and records the desktop as its owner, so servers treat it
as held while the desktop runs. It refuses, moving nothing, while an Executor from before
the background service uses the data: that server holds no lock and the desktop
cannot stop it; key rotation refuses then too.
It renames the whole data directory, which the CLI shares, to
backups/data-<time>-<random> beside it (~/.executor/v2/backups/ when packaged);
on Windows, where a server that was just stopped or killed keeps its files open
for a moment, the rename is retried for a few seconds. It then writes executor-backup.json there: app version, time, source path,
installation ID, key source and, for the OS credential store, its service/account.
It never contains a key. Executor starts the server again with fresh data and
names the backup, with Show in folder.
Nothing is deleted. The old OS credential entry stays where it is, still paired
with the backup through its installation ID; a keys.json moves with the backup. To restore, quit Executor and move the
backup directory back to the data path. There is no restore UI or automatic cleanup.
If the rename fails, for example because another program holds a file open on
Windows, the data stays in place and Executor starts the server on it again. The
desktop log continues in a new executor-desktop.jsonl under the fresh data
directory.
Help → Show diagnostics folder opens the directory holding the desktop, server and collector logs.
e2e/tests/desktop-recovery.spec.ts drives the desktop against real background
servers: sharing one with the CLI, staying stopped after executor service stop
and joining the server the CLI starts next, replacing a crashed one, the recovery
page, reset and the data migration. Each scenario uses its own temporary data
directory and port, runs the CLI on the same directory, and stops every server it
left. A failed scenario keeps a screenshot of each desktop window and the
desktop's and its servers' logs in a failed-<time>-<pid> folder under
EXECUTOR_E2E_DESKTOP_SCREENSHOTS (.local/desktop-recovery by default). The
release workflow runs it against each packaged desktop, whose CLI is the
runtime it staged. Locally, build the entry and run it, under xvfb-run -a on
Linux; set EXECUTOR_E2E_DESKTOP_EXECUTABLE to test a packaged app:
bun run apps:build && bun run e2e:apps && bun run telemetry:build && bun run web:build
node apps/local/desktop/scripts/build.mjs
bunx vitest run --config e2e/desktop-recovery.config.tsFile → Server port… opens a script-free form in its own sandboxed window. Submitting it is a
navigation to executor-settings: that the parent intercepts; there is still no preload or IPC
bridge. The parent accepts ports 1024 to 65535, refuses one another program is listening on,
saves it as port in service.json, keeping its other settings, and restarts the background
server. A bind can still race another process after the check. An invalid service.json locks
the form and names the file.
File → Rotate local API key… confirms, stops the background server, runs executor rotate-key with the same runtime and starts the server again. That replaces the API key where the
data directory keeps it (OS credential store or keys.json) and retains the encryption key, so
saved accounts and MCP sign-ins still work. Browser sessions are unaffected; scripts and clients
that send the old key must be updated. Outstanding account-connection links are signed with the
old key and stop working. Supplied EXECUTOR_API_KEY/EXECUTOR_ENCRYPTION_KEY are never stored,
so the menu explains that they must be changed where they are set.
Help → Export diagnostics… writes executor-diagnostics-<UTC stamp>.zip to Downloads and
reveals it. It contains a manifest (versions, platform, origin, port source, data directory) and
the allowlisted files from diagnostics/: the rotating executor-*.jsonl logs and
collector.json, at most 50 MiB each and 14 days old. Keys, installation.json, databases,
retained builds, the browser profile and Motel's trace store are never read. Logs are already
redacted at the source; the export additionally masks bearer values, pair/token/code/state
URL parameters, 64-digit hex runs and credential-named JSON fields.
The background service follows OpenCode's desktop (packages/desktop/src/main/service/,
MIT): stage the bundled CLI outside the app, then start or join the service with the
bundled version winning on launch.
Reviewed pingdotgg/t3code at 93e04160 (2026-09-18). The ignored checkout is
.reference/t3code-desktop/. The main references are:
apps/desktop/src/window/DesktopWindow.ts: sandboxed window and native lifecycle.apps/desktop/vite.config.ts: bundled CommonJS main entry with Electron external.apps/web/vite.config.tsandapps/web/vite/tailwind.ts: bundled development, entry warmup, and Tailwind hooks for Rolldown.apps/desktop/scripts/electron-launcher.mjs: a branded development bundle with framework-relative symlinks preserved and local code signing.scripts/build-desktop-artifact.ts: reference for the later release pipeline.
Executor keeps its existing same-origin HTTP/cookie protocol. It does not need a preload or an IPC bridge for ordinary dashboard operations. Distribution builds, notarization, updates, and platform-specific release artifacts remain separate from this workspace launcher. Windows and Linux have not been verified.
The background server runs the Motel collector and persists traces and logs under the
data directory's diagnostics/. The Electron parent records startup, connections,
recovery and renderer messages in executor-desktop.jsonl beside them. See
telemetry
for retention, query URLs, and the JSONL files an agent can inspect.
Build the CLI runtime with bun run release:cli, then run
bun run release:desktop 0.0.0-preview.local. Add --dir to build an unpacked
application for inspection. Output lives in .local/releases/.
The preview is named Executor Preview, with a separate app ID and persistent
Electron user-data directory. It contains the server, dashboard, framework,
compiler, Git, SDK dependencies and collector, and desktop-runtime.json, which
names the build so each rebuild is staged again. It does not load .env files,
call 1Password, require a workspace or use the original Executor updater. Installing
an update quits the app only; the next launch replaces the running server with the
new version.
The server uses EXECUTOR_PORT, the service.json port or 4312, so MCP clients keep
the same URL across restarts. Use another fixed port when running a second data
directory.
Windows and Linux builds, and signed macOS builds, include the channel feed in
app-update.yml. Unsigned macOS builds have none, and their Updates menu
points to the download page. See desktop update channels.
On first launch the background server creates its keys in the OS credential store,
or keys.json where there is none. See
first launch.