What the agent does
The agent clones the app into its own cloud sandbox and reads the main process before it changes startup. In an app built from electron-react-boilerplate/electron-react-boilerplate, src/main/main.ts is the whole main process: an ipcMain.on('ipc-example') handler, an isDebug flag from NODE_ENV or DEBUG_PROD, a createWindow that builds a 1024 by 728 BrowserWindow with the preload at ../preload/preload.js, shows it on ready-to-show unless START_MINIMIZED is set, wires MenuBuilder, sends external links to the browser through setWindowOpenHandler, and loads resolveHtmlPath('index.html'). Startup is app.whenReady().then(createWindow) followed by startAutoUpdates() and an activate handler for macOS, and window-all-closed quits everywhere but Darwin. Nothing calls app.requestSingleInstanceLock(), so a second launch opens a second window with its own electron-log file and its own updater check.
The repository already shows how it likes main-process concerns kept: src/main/updates.ts exports one function, startAutoUpdates, guards itself with a started flag, and is tested in src/__tests__/updates.test.ts under a /** @jest-environment node */ docblock with jest.mock('electron', ...), jest.mock('electron-updater', ...) and jest.resetModules() in beforeEach. The agent follows that shape with a src/main/single-instance.ts that exports acquireSingleInstanceLock(getWindow): it calls app.requestSingleInstanceLock(), and when the lock is held elsewhere it logs through electron-log and calls app.quit() and returns false; otherwise it registers app.on('second-instance', ...), which restores the window if isMinimized() and calls focus(), and returns true. In main.ts the call sits before app.whenReady(), so a losing instance never creates a window or starts the updater, and the handler reads mainWindow through a getter so it sees the window created later.
The test mirrors updates.test.ts: mock electron with an app whose requestSingleInstanceLock is a jest.fn() and whose on records listeners, mock electron-log, and assert three things: that a refused lock calls app.quit and returns false; that a granted lock registers a second-instance listener; and that firing that listener on a minimized window calls restore() then focus(). jest.config.js keeps jsdom as the default environment with check-build-exists.ts in setupFiles, so the docblock override to node is what makes the mocked electron import work, as it does for the updates test.
Verification follows .github/workflows/test.yml, which on Linux, macOS and Windows runs npm ci, npm run package, npm run lint, npm exec tsc -- --noEmit, npm test, and then xvfb-run --auto-servernum npm run test:smoke on Linux. .erb/scripts/smoke-test.cjs starts the app with npm start -- --remoteDebuggingPort 9335, waits up to 120 seconds for a page at http://127.0.0.1:9335/json/list, and fails if npm start exits first. The agent runs lint, the type check, the Jest suite and the smoke test under xvfb-run, then launches a second copy of the built app while the first is running and checks that the second process exits and the first window receives focus, which the transcript records.
Start it with one API call
POST https://api.prix.dev/o/{org}/sessions with this body:
{
"agent": "claude",
"input": "Make this Electron app single-instance. Read src/main/main.ts, src/main/updates.ts, src/__tests__/updates.test.ts, jest.config.js, electron.vite.config.ts, package.json and .github/workflows/test.yml. Add src/main/single-instance.ts exporting a function that calls app.requestSingleInstanceLock, quits and returns false when the lock is held by another process, and otherwise registers a second-instance handler that restores a minimized main window and focuses it. Call it in main.ts before app.whenReady so a losing instance creates no window and starts no updater. Write a Jest test in src/__tests__/ in the style of updates.test.ts, with the node environment docblock and mocked electron and electron-log modules, covering the refused lock, the registered handler and the restore-then-focus behaviour. Run npm run lint, npm exec tsc -- --noEmit, npm test, and xvfb-run npm run test:smoke; then start the app twice under xvfb and confirm the second process exits with the first still running. Open a pull request with the test and command output in its description.",
"resources": [
{
"url": "https://github.com/your-org/your-app"
}
]
}- Get an API key. In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
- Send the request. POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
- Read what came back. Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.
What comes back
What comes back is a pull request on its own branch adding src/main/single-instance.ts and src/__tests__/single-instance.test.ts and changing src/main/main.ts by a few lines at startup. The description shows the Jest summary, the lint and type-check results, the smoke test's output, and the lines from the second launch showing the second process exiting while the first stayed up.
The session read names the repository and the pull request, and the transcript at GET /o/{org}/sessions/{id}/messages keeps every command with its output, including the xvfb-run session and both launches. The workflow on the pull request repeats the checks on all three operating systems; a check that fails there ends the run needs_review rather than as a merged claim.
Before merging, a reviewer reads the order of calls in main.ts to confirm the lock is requested before whenReady, checks that the second-instance handler does nothing when no window exists yet, and decides whether a second launch with a file argument should also be forwarded to the first instance, which the handler's argv parameter makes possible in a follow-up on the same session.
Run it on every GitHub issue labelled agent
In the console, add an automation to the Project with this prompt and a github trigger on issues.labeled, filtered to the agent label. When a maintainer labels an issue such as "clicking the dock icon twice opens two windows", the trigger puts the issue after the prompt and the run reads it as the request; only labels applied by a repository owner, member or collaborator fire it, and the automation starts at most max_runs_per_hour runs in an hour.
From a terminal, the same body goes to POST /o/{org}/sessions with the repository in resources and a request_id made from the issue number, so a retried call returns the run already started. Give the key a webhook URL and Rush POSTs run.completed with data.session_url when the turn ends, which a handler can use to comment the pull request link on the issue.
The use case this job follows, with its automation and trigger: Hand off from your terminal.
Why run it on Rush?
A single-instance lock is a few lines that are easy to put in the wrong place: after whenReady, where the losing instance has already started an updater, or without a window getter, where the handler focuses nothing. The agent runs the repository's own lint, type check, Jest suite and smoke test in its sandbox, then actually launches the app twice under a virtual display, and keeps the output, so the pull request shows the behaviour rather than describing it.
You start it from a terminal and come back to a pull request. The sandbox parks when the turn ends and wakes for a follow-up, so forwarding a file argument from the second launch to the first window is one more message on the same session, with the same checkout and built release/app/dist.
Frequently asked questions
Why request the lock before app.whenReady?
Because the losing instance should do nothing: no window, no electron-log file, no checkForUpdatesAndNotify from startAutoUpdates. Requesting the lock first and returning early when it is refused keeps the rest of main.ts from running at all in the second process.
How is it tested without a real second process?
The same way updates.test.ts tests the updater: the electron module is mocked in a node Jest environment, requestSingleInstanceLock returns false or true per test, and the second-instance listener is captured from the mocked app.on and invoked with a fake minimized window. The two-launch check under xvfb-run is the real-process complement, recorded in the transcript.
What about macOS, where a second click goes to activate?
Reopening from the Dock fires activate, which main.ts already handles by creating a window only when mainWindow is null. The lock covers launches that start a new process, such as a second open -a or a packaged binary run twice, and the handler focuses the existing window in that case. The pull request notes both paths.
Does it change the smoke test?
No. .erb/scripts/smoke-test.cjs starts one instance and waits for the remote debugging endpoint, which still works. The agent runs it unchanged under xvfb-run and adds the second-launch check as a separate step whose output is in the transcript, not as a change to the script.