From efe942a23677c82537bdc6822a047010df52025c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=A7=A0=F0=9F=8C=B8On=20Gaia?= Date: Tue, 8 Sep 2026 16:15:44 -0400 Subject: [PATCH 1/2] fix(agent-hooks): keep a dev run's endpoint file out of the installed profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getStateDir()` in persistence.ts and `getKeybindingsDir()` in register.ts carry the same rule, copied twice: take `userData`, append `-dev` when the build is not packaged, so `npm run dev` does not read or write an installed build's data. `agent-hooks/runtime.ts` joins raw `userData` and gets no such separation. That directory holds `endpoint.env` — the loopback port and the bearer token the generated hook script posts with. Both instances write it, and it is written only inside `startAgentHookServer`, so: - Whichever instance started last owns the file. Every Claude agent launched outside Docker re-sources it on every hook event, so agents belonging to the *other* instance post their status there. The token comes from the same file, so the request authenticates and succeeds — an instance reports status for agents it never launched, with nothing logged on either side. - When that instance quits, the file still names its dead port. Nothing rewrites it until an app starts again, so the surviving instance's agents post into nothing and fall back to PTY heuristics until that instance is itself restarted. Rather than add a third copy of the suffix rule, extract it: `electron/user-data-dir.ts` exports `resolveUserDataDir(userDataPath, isPackaged)` — pure, so the rule is testable without an Electron runtime — and `getUserDataDir()` for callers. persistence.ts (state.json and custom themes), the keybindings handlers and agent-hooks now all go through it, and reaching past it is the thing to look for in review. Packaged builds resolve exactly as before: `resolveUserDataDir` returns its input unchanged when `isPackaged`. 3 unit tests on the rule. `npm run compile`, `npm run check:static` and the full suite pass. --- electron/agent-hooks/runtime.ts | 9 +++++++-- electron/ipc/persistence.ts | 16 +++------------- electron/ipc/register.ts | 14 +++----------- electron/user-data-dir.test.ts | 23 +++++++++++++++++++++++ electron/user-data-dir.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 electron/user-data-dir.test.ts create mode 100644 electron/user-data-dir.ts diff --git a/electron/agent-hooks/runtime.ts b/electron/agent-hooks/runtime.ts index 065763dc2..8239d78f0 100644 --- a/electron/agent-hooks/runtime.ts +++ b/electron/agent-hooks/runtime.ts @@ -1,6 +1,7 @@ -import { app, type BrowserWindow } from 'electron'; +import { type BrowserWindow } from 'electron'; import path from 'path'; import { IPC } from '../ipc/channels.js'; +import { getUserDataDir } from '../user-data-dir.js'; import { setAgentHookRuntime } from '../ipc/pty.js'; import { error as logError, info as logInfo } from '../log.js'; import { emitAgentHookEvent } from './events.js'; @@ -15,7 +16,11 @@ let server: AgentHookServer | null = null; * Failure is logged and otherwise ignored: the PTY heuristics keep working. */ export async function startAgentHookRuntime(getWindow: () => BrowserWindow | null): Promise { - const dir = path.join(app.getPath('userData'), 'agent-hooks'); + // Per-instance, not raw userData: this directory holds endpoint.env (loopback + // port + bearer token), written only at startup, so sharing it between a dev + // run and an installed build hands every agent's hook events to whichever + // instance started last. + const dir = path.join(getUserDataDir(), 'agent-hooks'); // Resolved per event: the server starts before the window exists so that no // Claude launch can race it, and the window may be recreated later. const forward = (event: AgentHookEventPayload): void => { diff --git a/electron/ipc/persistence.ts b/electron/ipc/persistence.ts index df1d68be2..803304f45 100644 --- a/electron/ipc/persistence.ts +++ b/electron/ipc/persistence.ts @@ -1,19 +1,9 @@ -import { app } from 'electron'; import fs from 'fs'; import path from 'path'; - -function getStateDir(): string { - let dir = app.getPath('userData'); - // Use separate dir for dev mode - if (!app.isPackaged) { - const base = path.basename(dir); - dir = path.join(path.dirname(dir), `${base}-dev`); - } - return dir; -} +import { getUserDataDir } from '../user-data-dir.js'; function getStatePath(): string { - return path.join(getStateDir(), 'state.json'); + return path.join(getUserDataDir(), 'state.json'); } export function saveAppState(json: string): void { @@ -52,7 +42,7 @@ export function saveAppState(json: string): void { } function getThemesDir(): string { - return path.join(getStateDir(), 'themes'); + return path.join(getUserDataDir(), 'themes'); } const VALID_THEME_ID = /^[a-zA-Z0-9_-]+$/; diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index aadab018a..b2e075578 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -43,6 +43,7 @@ import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './ver import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; import type { RemoteAttentionState } from '../remote/protocol.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; +import { getUserDataDir } from '../user-data-dir.js'; import { buildMcpLaunchArgs } from '../mcp/agent-args.js'; import { getSymlinkCandidates, @@ -766,22 +767,13 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Keybindings --- - function getKeybindingsDir(): string { - let dir = app.getPath('userData'); - if (!app.isPackaged) { - const base = path.basename(dir); - dir = path.join(path.dirname(dir), `${base}-dev`); - } - return dir; - } - ipcMain.handle(IPC.LoadKeybindings, () => { - return loadKeybindings(getKeybindingsDir()); + return loadKeybindings(getUserDataDir()); }); ipcMain.handle(IPC.SaveKeybindings, (_e, args) => { assertString(args?.json, 'json'); - saveKeybindings(getKeybindingsDir(), args.json); + saveKeybindings(getUserDataDir(), args.json); }); // --- Arena persistence --- diff --git a/electron/user-data-dir.test.ts b/electron/user-data-dir.test.ts new file mode 100644 index 000000000..191d17d09 --- /dev/null +++ b/electron/user-data-dir.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import path from 'path'; +import { resolveUserDataDir } from './user-data-dir.js'; + +describe('resolveUserDataDir', () => { + const userData = path.join('/home', 'someone', '.config', 'parallel-code'); + + it('uses the userData path as-is in a packaged build', () => { + expect(resolveUserDataDir(userData, true)).toBe(userData); + }); + + it('appends -dev to the last segment in a dev run', () => { + expect(resolveUserDataDir(userData, false)).toBe( + path.join('/home', 'someone', '.config', 'parallel-code-dev'), + ); + }); + + // Suffixing the whole path instead of the last segment would put dev's data + // in a sibling of the config root rather than beside the packaged profile. + it('keeps the parent directory', () => { + expect(path.dirname(resolveUserDataDir(userData, false))).toBe(path.dirname(userData)); + }); +}); diff --git a/electron/user-data-dir.ts b/electron/user-data-dir.ts new file mode 100644 index 000000000..69391eadf --- /dev/null +++ b/electron/user-data-dir.ts @@ -0,0 +1,27 @@ +import { app } from 'electron'; +import path from 'path'; + +/** + * Per-instance data directory, given Electron's `userData` path. + * + * A dev run gets its own `-dev` directory so `npm run dev` does not read + * or write the installed build's data. Pure and parameterised so the rule can be + * tested without an Electron runtime, and so there is exactly one copy of it. + */ +export function resolveUserDataDir(userDataPath: string, isPackaged: boolean): string { + if (isPackaged) return userDataPath; + const base = path.basename(userDataPath); + return path.join(path.dirname(userDataPath), `${base}-dev`); +} + +/** + * The per-instance data directory for this process. + * + * Every file the app keeps under `userData` goes here rather than under + * `app.getPath('userData')` directly. The suffix is what keeps a dev run and an + * installed build from writing over each other, so a caller that reaches past + * this helper silently opts one of its files out of that separation. + */ +export function getUserDataDir(): string { + return resolveUserDataDir(app.getPath('userData'), app.isPackaged); +} From 61328004e10237e1058e8afbd0935213106a7042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=A7=A0=F0=9F=8C=B8On=20Gaia?= Date: Tue, 8 Sep 2026 16:15:44 -0400 Subject: [PATCH 2/2] fix(arena): store arena data per instance too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class as the agent-hooks fix, found while making that one: SaveArenaData and LoadArenaData join `app.getPath('userData')` directly, so a dev run reads and writes the installed build's `arena-*.json`. No silent misrouting here the way the hook endpoint had — it is the plainer version of the same slip. Worth knowing before merging: a dev run picks this change up as an empty arena, since `arena-presets.json` and `arena-history.json` stay with the installed build. Separate commit because it is not the bug that prompted the change; drop it if you would rather keep this PR to agent-hooks. --- electron/ipc/register.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index b2e075578..3d01257e6 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -780,7 +780,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.SaveArenaData, (_e, args) => { assertString(args.filename, 'filename'); assertString(args.json, 'json'); - const filePath = path.join(app.getPath('userData'), args.filename); + const filePath = path.join(getUserDataDir(), args.filename); const basename = path.basename(filePath); if (basename !== args.filename) throw new Error('Invalid filename'); if (!basename.startsWith('arena-') || !basename.endsWith('.json')) @@ -792,7 +792,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.LoadArenaData, (_e, args) => { assertString(args.filename, 'filename'); - const filePath = path.join(app.getPath('userData'), args.filename); + const filePath = path.join(getUserDataDir(), args.filename); const basename = path.basename(filePath); if (basename !== args.filename) throw new Error('Invalid filename'); if (!basename.startsWith('arena-') || !basename.endsWith('.json'))