Skip to content

Commit bf02fb2

Browse files
yyq1025claude
andcommitted
daemon: fix spawn ENOTDIR for bundled claude in packaged app
In the packaged .app the SDK resolves its `claude` SEA binary via createRequire relative to sdk.mjs — which lives INSIDE app.asar. The resolved path runs THROUGH app.asar (a file), so child_process.spawn of it throws `spawn ENOTDIR`. electron-builder asarUnpacks the binary to Resources/app.asar.unpacked/, but the SDK doesn't know to look there. Surfaced now because this was the first packaged build to exercise sendPrompt → spawn end-to-end (dev has no asar; node-datachannel dodges it because .node dlopen goes through Electron's patched fs). Fix keeps packaging knowledge in the Electron layer where it belongs: the menubar computes the asar.unpacked binary path (it owns the asarUnpack glob + knows app.isPackaged) and passes it via DaemonOptions.claudeExecutablePath → RouterDeps → SessionLoopOptions → pathToClaudeCodeExecutable. The daemon stays electron-agnostic and just forwards it; dev/tests pass undefined → the SDK resolves its platform package itself (no asar, real path). Needs a menubar .dmg re-release (daemon code, not OTA). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38cd148 commit bf02fb2

4 files changed

Lines changed: 68 additions & 2 deletions

File tree

packages/daemon/src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export interface DaemonOptions {
3535
/** Override signaling host (for `wrangler dev`). */
3636
signalingHost?: string;
3737
signalingScheme?: "ws" | "wss";
38+
/**
39+
* Absolute path to the bundled `claude` SEA binary to spawn. The Electron
40+
* host (menubar) computes this because it owns packaging: in the packaged
41+
* .app the binary is `asarUnpack`'d to a fixed `Resources/app.asar.unpacked/`
42+
* location, and the SDK's own resolution would otherwise point INSIDE
43+
* `app.asar` (a file) → `spawn ENOTDIR`. Omit in dev: the SDK resolves its
44+
* platform package from node_modules itself (no asar). The daemon stays
45+
* electron-agnostic and just forwards this into every claude spawn.
46+
*/
47+
claudeExecutablePath?: string;
3848
}
3949

4050
export type {
@@ -205,7 +215,11 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
205215
// gets it via env. A creds failure surfaces as turn_failed (forwarded
206216
// to claude.ai by the bridge mirror) rather than a silent drop.
207217
const oauthToken = await oauth.ensureFresh();
208-
ensureSessionLoop(runtime, { mode: "resume", oauthToken });
218+
ensureSessionLoop(runtime, {
219+
mode: "resume",
220+
oauthToken,
221+
claudeExecutablePath: options.claudeExecutablePath,
222+
});
209223
pushPrompt(runtime, prompt.text, prompt.images, prompt.uuid);
210224
} catch (err) {
211225
runtime.addEvent({
@@ -344,6 +358,8 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
344358
// Per-spawn OAuth token for the bundled binary — same shared keychain
345359
// manager the CCR bridge uses (one keeper per process).
346360
ensureFreshToken: () => oauth.ensureFresh(),
361+
// Host-computed binary path (packaged app) — forwarded into every spawn.
362+
claudeExecutablePath: options.claudeExecutablePath,
347363
gitWatchers,
348364
epoch,
349365
});

packages/daemon/src/router.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export interface RouterDeps {
116116
* (`queryFactory` set), which spawns no real binary.
117117
*/
118118
ensureFreshToken?: () => Promise<string>;
119+
/**
120+
* Absolute path to the bundled `claude` binary to spawn, computed by the
121+
* Electron host (see SessionLoopOptions.claudeExecutablePath — needed so the
122+
* spawn survives asar packaging). Forwarded verbatim into each spawn.
123+
* Undefined in dev / tests → the SDK resolves its platform package itself.
124+
*/
125+
claudeExecutablePath?: string;
119126
/**
120127
* Per-daemon registry of `GitWatcher`s keyed by `cwd`. Shared across
121128
* connections so two iOS clients on the same project re-use one watch
@@ -785,6 +792,7 @@ export function createCommandHandler(deps: RouterDeps): CommandHandler {
785792
cwd: cmd.cwd,
786793
model: cmd.model,
787794
oauthToken,
795+
claudeExecutablePath: deps.claudeExecutablePath,
788796
queryFactory: deps.queryFactory,
789797
});
790798
// pushPrompt emits turn_started synchronously before the SDK

packages/daemon/src/runtime/run-query.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,17 @@ export interface SessionLoopOptions {
204204
* (`queryFactory` set), which never spawns a real binary.
205205
*/
206206
oauthToken?: string;
207+
/**
208+
* Absolute path to the bundled `claude` SEA binary to spawn
209+
* (`pathToClaudeCodeExecutable`). Computed by the Electron layer (menubar),
210+
* which owns packaging: inside the packaged .app the binary is `asarUnpack`'d
211+
* to a fixed `Resources/app.asar.unpacked/...` location, and the SDK's own
212+
* resolution would otherwise point INSIDE `app.asar` (a file) and spawn with
213+
* `ENOTDIR`. Undefined in dev / tests → the SDK resolves its platform package
214+
* from node_modules itself (no asar, so that path is real). The daemon stays
215+
* electron-agnostic: it just forwards whatever the host computed.
216+
*/
217+
claudeExecutablePath?: string;
207218
/** Test seam: override the SDK's `query()` factory. */
208219
queryFactory?: typeof query;
209220
}
@@ -254,6 +265,13 @@ export function ensureSessionLoop(
254265
: undefined;
255266
const envOption = spawnEnv ? { env: spawnEnv } : {};
256267

268+
// Spawn the host-supplied binary path when present (packaged app, where the
269+
// SDK's own resolution would hit `app.asar` → ENOTDIR). Absent in dev/tests,
270+
// where the SDK resolves its platform package itself. See SessionLoopOptions.
271+
const execOption = options.claudeExecutablePath
272+
? { pathToClaudeCodeExecutable: options.claudeExecutablePath }
273+
: {};
274+
257275
const channel = createAsyncMessageInput<SDKUserMessage>();
258276
runtime.inputChannel = channel;
259277

@@ -298,6 +316,7 @@ export function ensureSessionLoop(
298316
includePartialMessages: true as const,
299317
cwd: options.cwd,
300318
...envOption,
319+
...execOption,
301320
}
302321
: {
303322
...bypassFlags,
@@ -307,6 +326,7 @@ export function ensureSessionLoop(
307326
includePartialMessages: true as const,
308327
cwd: options.cwd,
309328
...envOption,
329+
...execOption,
310330
};
311331
const q: Query = factory({
312332
prompt: channel.iterable,

packages/menubar/electron/main.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ let isQuitting = false;
2929
let daemon: Daemon | null = null;
3030
let keepAwakeId: number | null = null;
3131

32+
// Absolute path to the SDK's bundled `claude` SEA binary, handed to the daemon
33+
// so its spawn survives asar packaging. This lives HERE (the Electron layer)
34+
// because packaging is our concern, not the daemon's: electron-builder.cjs
35+
// `asarUnpack`s the SDK platform package to `Resources/app.asar.unpacked/...`,
36+
// a fixed location we can name directly. If we let the SDK resolve the binary
37+
// itself, it would point INSIDE `app.asar` (sdk.mjs lives there) — and since
38+
// app.asar is a file, `child_process.spawn` of a path through it throws
39+
// `spawn ENOTDIR`. Dev (unpackaged) returns undefined → the SDK resolves its
40+
// platform package from node_modules itself (no asar, real path).
41+
//
42+
// Keep the `asarUnpack` glob in electron-builder.cjs and this layout in sync.
43+
function bundledClaudePath(): string | undefined {
44+
if (!app.isPackaged) return undefined;
45+
return path.join(
46+
process.resourcesPath,
47+
"app.asar.unpacked",
48+
"node_modules",
49+
`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`,
50+
"claude",
51+
);
52+
}
53+
3254
// --- Plan usage (daemon-fetched, menu-cached) ---
3355
//
3456
// Last fetch result, rendered by buildMenu. macOS doesn't repaint an open
@@ -323,7 +345,7 @@ app.whenReady().then(async () => {
323345
);
324346

325347
console.log("[main] starting daemon...");
326-
daemon = await startDaemon();
348+
daemon = await startDaemon({ claudeExecutablePath: bundledClaudePath() });
327349
console.log(
328350
`[main] daemon ready (fingerprint ${daemon.fingerprint}, ${daemon.pairedClientCount()} paired clients)`,
329351
);

0 commit comments

Comments
 (0)