From 1dee6ece86a947cca43a5f4c12d083126ed0ae42 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 31 Mar 2026 17:43:13 -0700 Subject: [PATCH 1/3] Use focusMainWindow --- apps/code/src/main/services/notification/service.ts | 10 ++-------- apps/code/src/main/services/task-link/service.ts | 12 ++---------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/apps/code/src/main/services/notification/service.ts b/apps/code/src/main/services/notification/service.ts index 20fb903aa..7e9e6b18a 100644 --- a/apps/code/src/main/services/notification/service.ts +++ b/apps/code/src/main/services/notification/service.ts @@ -3,6 +3,7 @@ import { inject, injectable, postConstruct } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; +import { focusMainWindow } from "../../window"; import { TaskLinkEvent, type TaskLinkService } from "../task-link/service"; const log = logger.scope("notification"); @@ -31,14 +32,7 @@ export class NotificationService { const notification = new Notification({ title, body, silent }); notification.on("click", () => { - log.info("Notification clicked, focusing window", { title, taskId }); - const mainWindow = getMainWindow(); - if (mainWindow) { - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); - } + focusMainWindow("notification click"); if (taskId) { this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); diff --git a/apps/code/src/main/services/task-link/service.ts b/apps/code/src/main/services/task-link/service.ts index 7eb0481ab..a4f90206e 100644 --- a/apps/code/src/main/services/task-link/service.ts +++ b/apps/code/src/main/services/task-link/service.ts @@ -1,8 +1,8 @@ import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { getMainWindow } from "../../trpc/context"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { focusMainWindow } from "../../window"; import type { DeepLinkService } from "../deep-link/service"; const log = logger.scope("task-link-service"); @@ -69,15 +69,7 @@ export class TaskLinkService extends TypedEventEmitter { this.pendingDeepLink = { taskId, taskRunId }; } - // Focus the window - log.info("Deep link focusing window", { taskId, taskRunId }); - const mainWindow = getMainWindow(); - if (mainWindow) { - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); - } + focusMainWindow("task deep link"); return true; } From d3f4e932bbe93c639ee2f9f43a294fee45ddf6bc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 31 Mar 2026 17:58:16 -0700 Subject: [PATCH 2/3] replace mock node symlink with wrapper script --- apps/code/src/main/services/agent/service.ts | 25 +++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 933a815df..d69bd3b38 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1,4 +1,4 @@ -import fs, { mkdirSync, symlinkSync } from "node:fs"; +import fs, { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; import { @@ -1204,17 +1204,24 @@ For git operations while detached: try { mkdirSync(mockNodeDir, { recursive: true }); const nodeSymlinkPath = join(mockNodeDir, "node"); + const nodeWrapper = `#!/bin/sh +export ELECTRON_RUN_AS_NODE=1 +exec "${process.execPath}" "$@" +`; + try { - symlinkSync(process.execPath, nodeSymlinkPath); - } catch (err) { - if ( - !(err instanceof Error) || - !("code" in err) || - err.code !== "EEXIST" - ) { - throw err; + const existingNode = fs.existsSync(nodeSymlinkPath) + ? fs.lstatSync(nodeSymlinkPath) + : null; + if (existingNode?.isSymbolicLink()) { + rmSync(nodeSymlinkPath); } + } catch (err) { + log.warn("Failed to inspect existing mock node entry", err); } + + writeFileSync(nodeSymlinkPath, nodeWrapper); + chmodSync(nodeSymlinkPath, 0o755); this.mockNodeReady = true; } catch (err) { log.warn("Failed to setup mock node environment", err); From 78c126448929168f694b1f61687889df137668a7 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 31 Mar 2026 18:34:39 -0700 Subject: [PATCH 3/3] Bundle node runtime for agent sessions --- apps/code/forge.config.ts | 27 ++++- .../src/main/services/agent/service.test.ts | 70 +++++++++++- apps/code/src/main/services/agent/service.ts | 101 ++++++++++-------- .../src/adapters/claude/session/options.ts | 1 - 4 files changed, 147 insertions(+), 52 deletions(-) diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index 0506ebe5b..19a5ec6ce 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; import { execSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { MakerDMG } from "@electron-forge/maker-dmg"; import { MakerSquirrel } from "@electron-forge/maker-squirrel"; @@ -139,11 +139,33 @@ function copySync(dependency: string, destinationRoot: string, source: string) { ); } +function copyBundledNodeRuntime(destinationRoot: string): void { + const source = process.execPath; + const extension = process.platform === "win32" ? ".exe" : ""; + const binDir = path.join(destinationRoot, "bin"); + const destination = path.join(binDir, `node${extension}`); + + mkdirSync(binDir, { recursive: true }); + rmSync(destination, { force: true }); + cpSync(source, destination, { dereference: true }); + + if (process.platform !== "win32") { + chmodSync(destination, 0o755); + } + + console.log( + `[forge] Copied bundled Node runtime into ${path.relative( + process.cwd(), + destination, + )}`, + ); +} + const config: ForgeConfig = { packagerConfig: { asar: { unpack: - "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}", + "{**/*.node,**/spawn-helper,**/bin/node,**/bin/node.exe,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}", }, prune: false, name: "PostHog Code", @@ -226,6 +248,7 @@ const config: ForgeConfig = { electronChild = child; }, packageAfterCopy: async (_forgeConfig, buildPath) => { + copyBundledNodeRuntime(buildPath); copyNativeDependency("node-pty", buildPath); copyNativeDependency("node-addon-api", buildPath); copyNativeDependency("@parcel/watcher", buildPath); diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 8dd751391..003c2a64c 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -48,6 +48,8 @@ const mockAgentConstructor = vi.hoisted(() => ); const mockFetch = vi.hoisted(() => vi.fn()); +const mockExecFileSync = vi.hoisted(() => vi.fn(() => "/usr/local/bin/node\n")); +const mockExistsSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => false)); // --- Module mocks --- @@ -111,16 +113,28 @@ vi.mock("@shared/errors.js", () => ({ isAuthError: vi.fn(() => false), })); +vi.mock("node:child_process", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { + ...original, + execFileSync: mockExecFileSync, + }, + execFileSync: mockExecFileSync, + }; +}); + vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal(); return { ...original, default: { ...original, - existsSync: vi.fn(() => false), + existsSync: mockExistsSync, realpathSync: vi.fn((p: string) => p), }, - existsSync: vi.fn(() => false), + existsSync: mockExistsSync, mkdirSync: vi.fn(), symlinkSync: vi.fn(), realpathSync: vi.fn((p: string) => p), @@ -175,9 +189,19 @@ const baseSessionParams = { describe("AgentService", () => { let service: AgentService; + let originalPath: string | undefined; + let originalOverride: string | undefined; + let originalNodeBinary: string | undefined; beforeEach(() => { vi.clearAllMocks(); + originalPath = process.env.PATH; + originalOverride = process.env.POSTHOG_AGENT_NODE_PATH; + originalNodeBinary = process.env.NODE_BINARY; + delete process.env.POSTHOG_AGENT_NODE_PATH; + delete process.env.NODE_BINARY; + process.env.PATH = "/usr/bin:/bin"; + mockApp.isPackaged = false; // MCP installations endpoint returns empty mockFetch.mockResolvedValue({ @@ -204,6 +228,17 @@ describe("AgentService", () => { }); afterEach(() => { + process.env.PATH = originalPath; + if (originalOverride === undefined) { + delete process.env.POSTHOG_AGENT_NODE_PATH; + } else { + process.env.POSTHOG_AGENT_NODE_PATH = originalOverride; + } + if (originalNodeBinary === undefined) { + delete process.env.NODE_BINARY; + } else { + process.env.NODE_BINARY = originalNodeBinary; + } vi.restoreAllMocks(); }); @@ -304,6 +339,37 @@ describe("AgentService", () => { }); }); + describe("node runtime resolution", () => { + it("uses env override for node and prepends its bin dir to PATH", async () => { + process.env.POSTHOG_AGENT_NODE_PATH = "/custom/node/bin/node"; + + await service.startSession({ + ...baseSessionParams, + adapter: "claude", + }); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(process.env.PATH?.startsWith("/custom/node/bin:")).toBe(true); + }); + + it("prefers bundled node runtime when packaged", async () => { + mockApp.isPackaged = true; + mockExistsSync.mockImplementation( + (...args: unknown[]) => args[0] === "/mock/appPath.unpacked/bin/node", + ); + + await service.startSession({ + ...baseSessionParams, + adapter: "claude", + }); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(process.env.PATH?.startsWith("/mock/appPath.unpacked/bin:")).toBe( + true, + ); + }); + }); + describe("idle timeout", () => { function injectSession( svc: AgentService, diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index d69bd3b38..5999fa506 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1,6 +1,15 @@ -import fs, { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + delimiter, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { type Client, ClientSideConnection, @@ -53,13 +62,6 @@ export type { InterruptReason }; const log = logger.scope("agent-service"); -const MOCK_NODE_DIR_PREFIX = "agent-node"; - -function getMockNodeDir(): string { - const suffix = isDevBuild() ? "dev" : "prod"; - return join(tmpdir(), `${MOCK_NODE_DIR_PREFIX}-${suffix}`); -} - /** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */ type MessageCallback = (message: unknown) => void; @@ -239,6 +241,43 @@ function getCodexBinaryPath(): string { : join(appPath, ".vite/build/codex-acp/codex-acp"); } +function resolveNodeExecutablePath(): string { + const envOverride = + process.env.POSTHOG_AGENT_NODE_PATH || process.env.NODE_BINARY; + if (envOverride) { + return envOverride; + } + + if (app.isPackaged) { + const extension = process.platform === "win32" ? ".exe" : ""; + const bundledNodePath = join( + `${app.getAppPath()}.unpacked`, + "bin", + `node${extension}`, + ); + if (fs.existsSync(bundledNodePath)) { + return bundledNodePath; + } + + log.warn("Bundled node runtime not found in packaged app", { + bundledNodePath, + }); + } + + try { + const locator = process.platform === "win32" ? "where" : "which"; + return execFileSync(locator, ["node"], { + encoding: "utf8", + env: process.env, + }).trim(); + } catch (err) { + log.warn("Failed to resolve node executable from PATH", { + error: err instanceof Error ? err.message : String(err), + }); + return "node"; + } +} + interface PendingPermission { resolve: (response: RequestPermissionResponse) => void; reject: (error: Error) => void; @@ -253,7 +292,6 @@ export class AgentService extends TypedEventEmitter { private sessions = new Map(); private currentToken: string | null = null; private pendingPermissions = new Map(); - private mockNodeReady = false; private idleTimeouts = new Map< string, { handle: ReturnType; deadline: number } @@ -647,9 +685,9 @@ export class AgentService extends TypedEventEmitter { } const channel = `agent-event:${taskRunId}`; - const mockNodeDir = this.setupMockNodeEnvironment(); + const nodeExecutablePath = resolveNodeExecutablePath(); const proxyUrl = await this.ensureAuthProxy(credentials); - this.setupEnvironment(credentials, mockNodeDir, proxyUrl); + this.setupEnvironment(credentials, proxyUrl, nodeExecutablePath); const isPreview = taskId === "__preview__"; @@ -1170,13 +1208,14 @@ For git operations while detached: private setupEnvironment( credentials: Credentials, - mockNodeDir: string, proxyUrl: string, + nodeExecutablePath: string, ): void { const token = this.getToken(credentials.apiKey); const currentPath = process.env.PATH || ""; - if (!currentPath.split(delimiter).includes(mockNodeDir)) { - process.env.PATH = `${mockNodeDir}${delimiter}${currentPath}`; + const nodeBinDir = dirname(nodeExecutablePath); + if (!currentPath.split(delimiter).includes(nodeBinDir)) { + process.env.PATH = `${nodeBinDir}${delimiter}${currentPath}`; } process.env.POSTHOG_AUTH_HEADER = `Bearer ${token}`; process.env.ANTHROPIC_API_KEY = token; @@ -1198,38 +1237,6 @@ For git operations while detached: process.env.POSTHOG_PROJECT_ID = String(credentials.projectId); } - private setupMockNodeEnvironment(): string { - const mockNodeDir = getMockNodeDir(); - if (!this.mockNodeReady) { - try { - mkdirSync(mockNodeDir, { recursive: true }); - const nodeSymlinkPath = join(mockNodeDir, "node"); - const nodeWrapper = `#!/bin/sh -export ELECTRON_RUN_AS_NODE=1 -exec "${process.execPath}" "$@" -`; - - try { - const existingNode = fs.existsSync(nodeSymlinkPath) - ? fs.lstatSync(nodeSymlinkPath) - : null; - if (existingNode?.isSymbolicLink()) { - rmSync(nodeSymlinkPath); - } - } catch (err) { - log.warn("Failed to inspect existing mock node entry", err); - } - - writeFileSync(nodeSymlinkPath, nodeWrapper); - chmodSync(nodeSymlinkPath, 0o755); - this.mockNodeReady = true; - } catch (err) { - log.warn("Failed to setup mock node environment", err); - } - } - return mockNodeDir; - } - private cancelInFlightMcpToolCalls(session: ManagedSession): void { for (const [toolCallId, toolKey] of session.inFlightMcpToolCalls) { this.mcpAppsService.notifyToolCancelled(toolKey, toolCallId); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index e97e12656..80c30eb87 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -93,7 +93,6 @@ function buildMcpServers( function buildEnvironment(): Record { return { ...process.env, - ELECTRON_RUN_AS_NODE: "1", CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true", // Offload all MCP tools by default ENABLE_TOOL_SEARCH: "auto:0",