From b635f4cc8aff85665a24a061b2da3152cb507ea9 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 18:50:23 +0000 Subject: [PATCH 1/8] fix(windows): pass windowsHide to the where.exe helper lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #256 covered the summarizer-CLI spawns but not the binary lookups that precede them. Both run `where.exe` on Windows from inside detached workers, which have no console to inherit, so each lookup allocates its own visible window — the same flash, one layer earlier. resolveCliBin is reached from the backfill path via stage-memory; spawn-mine-local-worker's is the fallback when the bundled cli.js is missing. Found by codex review of #256. --- src/skillify/spawn-mine-local-worker.ts | 3 +++ src/utils/resolve-cli-bin.ts | 8 +++++++- .../inner-cli-spawn-windowshide-source.test.ts | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/skillify/spawn-mine-local-worker.ts b/src/skillify/spawn-mine-local-worker.ts index 7ecf796e8..6b3850e77 100644 --- a/src/skillify/spawn-mine-local-worker.ts +++ b/src/skillify/spawn-mine-local-worker.ts @@ -85,6 +85,9 @@ export function findHivemindLauncher(): HivemindLauncher | null { const out = execFileSync(lookup, ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], + // CREATE_NO_WINDOW: same reason as resolveCliBin — this runs from a + // detached worker with no console to inherit. No-op on POSIX. + windowsHide: true, }); const bin = out.trim(); return bin ? { kind: "bin", path: bin } : null; diff --git a/src/utils/resolve-cli-bin.ts b/src/utils/resolve-cli-bin.ts index 16c6fea02..bdc239ee2 100644 --- a/src/utils/resolve-cli-bin.ts +++ b/src/utils/resolve-cli-bin.ts @@ -29,7 +29,13 @@ import { join } from "node:path"; export function resolveCliBin(cli: string, fallback?: string): string { const isWin = process.platform === "win32"; try { - const out = execFileSync(isWin ? "where" : "which", [cli], { encoding: "utf-8" }); + const out = execFileSync(isWin ? "where" : "which", [cli], { + encoding: "utf-8", + // CREATE_NO_WINDOW. Reached from detached background workers, which + // have no console to inherit, so where.exe would otherwise allocate a + // visible one. No-op on POSIX. + windowsHide: true, + }); const matches = out .split(/\r?\n/) .map((line) => line.trim()) diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index 3159c3c9e..73e063c50 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -37,6 +37,22 @@ describe("inner CLI spawn windowsHide — source guards", () => { expect(src("src/hooks/commit-kpi-extract.ts")).toMatch(/spawn\(\s*cli\.bin[^)]*windowsHide:\s*true/); }); + // The helper LOOKUPS, not the CLI spawns. These run `where.exe` on Windows + // from inside detached workers that have no console to inherit, so each one + // allocates its own visible window without CREATE_NO_WINDOW — the same flash + // the CLI spawns produced, one layer earlier. + it("resolveCliBin's where/which lookup passes windowsHide", () => { + expect(src("src/utils/resolve-cli-bin.ts")).toMatch( + /execFileSync\(isWin \? "where" : "which"[^)]*windowsHide:\s*true/, + ); + }); + + it("the mine-local worker's hivemind lookup passes windowsHide", () => { + expect(src("src/skillify/spawn-mine-local-worker.ts")).toMatch( + /execFileSync\(lookup[^)]*windowsHide:\s*true/, + ); + }); + it("stage-memory threads windowsHide from the invocation into the spawn plan and spawn call", () => { const s = src("src/skillify/stage-memory.ts"); // plan carries it through from the builder's options... From 2228c92826eee8e6159147a651103bbde2356feb Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 18:53:49 +0000 Subject: [PATCH 2/8] test(windows): update resolveCliBin option assertions for windowsHide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wiki-worker-windows.test.ts asserts the exact options object passed to execFileSync, so adding windowsHide to the where/which lookup broke both the win32 and the posix case. Exact-match is the right assertion here — it's what would catch the option silently disappearing — so update the expectations rather than loosen them. --- tests/claude-code/wiki-worker-windows.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/claude-code/wiki-worker-windows.test.ts b/tests/claude-code/wiki-worker-windows.test.ts index 90219e53c..9ac21b80b 100644 --- a/tests/claude-code/wiki-worker-windows.test.ts +++ b/tests/claude-code/wiki-worker-windows.test.ts @@ -54,7 +54,7 @@ describe("resolveCliBin — Windows", () => { setPlatform("win32"); execFileSyncMock.mockReturnValue("C:\\npm\\claude.cmd\r\n"); resolveCliBin("claude"); - expect(execFileSyncMock).toHaveBeenCalledWith("where", ["claude"], { encoding: "utf-8" }); + expect(execFileSyncMock).toHaveBeenCalledWith("where", ["claude"], { encoding: "utf-8", windowsHide: true }); }); it("prefers a .exe over a .cmd shim when both are on PATH", () => { @@ -97,7 +97,8 @@ describe("resolveCliBin — Unix (unchanged behavior)", () => { setPlatform("linux"); execFileSyncMock.mockReturnValue("/usr/local/bin/claude\n"); expect(resolveCliBin("claude")).toBe("/usr/local/bin/claude"); - expect(execFileSyncMock).toHaveBeenCalledWith("which", ["claude"], { encoding: "utf-8" }); + // windowsHide is a no-op on POSIX but the option object is platform-agnostic. + expect(execFileSyncMock).toHaveBeenCalledWith("which", ["claude"], { encoding: "utf-8", windowsHide: true }); }); it("falls back to an extensionless ~/.claude/local/ when not found", () => { From ec2c94e00cfcf5f585d2cdf813c5304baf6028e8 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 18:58:41 +0000 Subject: [PATCH 3/8] test(windows): update the per-agent bin resolver option assertions too spawn-wiki-worker.test.ts asserts the same exact options object as wiki-worker-windows.test.ts, for the codex/cursor/hermes resolvers. Both files had to move together; I only ran the files I touched, so CI caught the second one. --- tests/claude-code/spawn-wiki-worker.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/claude-code/spawn-wiki-worker.test.ts b/tests/claude-code/spawn-wiki-worker.test.ts index 63e2282be..7857c0d5f 100644 --- a/tests/claude-code/spawn-wiki-worker.test.ts +++ b/tests/claude-code/spawn-wiki-worker.test.ts @@ -325,13 +325,13 @@ describe("per-agent bin resolvers", () => { it.each(RESOLVERS)("find%sBin returns the resolved path when the lookup succeeds", (_n, fn, _fallback, cli) => { vi.mocked(execFileSync).mockReturnValueOnce("/usr/local/bin/the-cli\n"); expect(fn()).toBe("/usr/local/bin/the-cli"); - expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8" }); + expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8", windowsHide: true }); }); it.each(RESOLVERS)("find%sBin falls back to the literal name when the lookup fails", (_n, fn, fallback, cli) => { vi.mocked(execFileSync).mockImplementationOnce(() => { throw new Error("not found"); }); expect(fn()).toBe(fallback); - expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8" }); + expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8", windowsHide: true }); }); it.each(RESOLVERS)( From df97f2465ddfe62a0660082f0431476050bf7710 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 19:13:02 +0000 Subject: [PATCH 4/8] fix(windows): pass windowsHide to the remaining detached worker launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit fixed two binary lookups but not the worker launches they feed, so the console still appeared one step later. Review found five more sites: - spawn-mine-local-worker.ts and spawn-backfill-memory-worker.ts launch their workers detached - client.ts auto-spawns the embedding daemon, reached from the backfill path - pi's auto-mine and openclaw's skillify launcher each pair a lookup with a detached spawn detached maps to DETACHED_PROCESS, which makes Windows ignore CREATE_NO_WINDOW — but libuv sets SW_HIDE from windowsHide as well, and that still applies, which is why spawn-detached.ts has always paired the two. These launches were simply missing it. Also corrects a comment: the mine-local lookup runs before its detached worker, not inside it. pi and openclaw hardcode Unix `which` for those lookups, so they cannot succeed on Windows at all. That is a separate bug — a feature silently absent rather than a visible console — and fixing it would enable code paths that never run there today, so it is marked with a NOTE and left for its own change. Found by codex review. --- harnesses/openclaw/src/index.ts | 6 ++- harnesses/pi/extension-source/hivemind.ts | 7 +++- src/embeddings/client.ts | 4 ++ src/skillify/spawn-backfill-memory-worker.ts | 3 ++ src/skillify/spawn-mine-local-worker.ts | 3 ++ ...inner-cli-spawn-windowshide-source.test.ts | 40 +++++++++++++++++-- 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/harnesses/openclaw/src/index.ts b/harnesses/openclaw/src/index.ts index fca649ab6..54a84f6b5 100644 --- a/harnesses/openclaw/src/index.ts +++ b/harnesses/openclaw/src/index.ts @@ -591,7 +591,9 @@ function detectOpenclawGateAgent(): GateAgent | null { ]; for (const [agent, bin] of candidates) { try { - realExecFileSync("which", [bin], { stdio: ["ignore", "pipe", "ignore"] }); + // NOTE: "which" is hardcoded here too — same Windows gap as pi's + // launcher lookup, tracked separately. + realExecFileSync("which", [bin], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); return agent; } catch { /* not on PATH, try next */ } } @@ -674,6 +676,8 @@ function spawnOpenclawSkillifyWorker(a: OpenclawSpawnArgs): boolean { realSpawn(process.execPath, [OPENCLAW_SKILLIFY_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...inheritedEnv.env, HIVEMIND_SKILLIFY_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); return true; diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index e1e6dd316..3d8f80771 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -1126,7 +1126,10 @@ function piMaybeAutoMineLocal(): boolean { } catch { /* fall through to which */ } if (!launcher) { try { - const out = execFileSync("which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }); + // NOTE: "which" is hardcoded, so this lookup cannot succeed on + // Windows at all — tracked separately; windowsHide keeps it from + // flashing wherever it does run. + const out = execFileSync("which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); const bin = String(out).trim(); if (bin) launcher = { kind: "bin", path: bin }; } catch { return false; } @@ -1150,6 +1153,8 @@ function piMaybeAutoMineLocal(): boolean { const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: process.env, }); closeSync(out); diff --git a/src/embeddings/client.ts b/src/embeddings/client.ts index 1e690c91f..3764af6da 100644 --- a/src/embeddings/client.ts +++ b/src/embeddings/client.ts @@ -371,6 +371,10 @@ export class EmbedClient { const child = spawn(process.execPath, [this.daemonEntry], { detached: true, stdio: "ignore", + // SW_HIDE: the daemon is auto-spawned from the backfill path, which + // itself runs detached — without this it flashes a console. No-op on + // POSIX. + windowsHide: true, env: process.env, }); child.unref(); diff --git a/src/skillify/spawn-backfill-memory-worker.ts b/src/skillify/spawn-backfill-memory-worker.ts index 0c3c6582c..a709f799b 100644 --- a/src/skillify/spawn-backfill-memory-worker.ts +++ b/src/skillify/spawn-backfill-memory-worker.ts @@ -50,6 +50,9 @@ function realSpawn(): boolean { const child = spawn(cmd, cmdArgs as string[], { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv still applies it alongside detached, so the backfill + // worker never flashes a console. No-op on POSIX. + windowsHide: true, // Mark the spawned process as the lock owner so it (and only it) releases // the lock on exit — a manual `hivemind memory backfill` won't clear it. env: { ...process.env, HIVEMIND_BACKFILL_LOCK_OWNED: "1" }, diff --git a/src/skillify/spawn-mine-local-worker.ts b/src/skillify/spawn-mine-local-worker.ts index 6b3850e77..a9a9419f1 100644 --- a/src/skillify/spawn-mine-local-worker.ts +++ b/src/skillify/spawn-mine-local-worker.ts @@ -195,6 +195,9 @@ export function maybeAutoMineLocal(opts: AutoMineOptions = {}): AutoMineGuardRep const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv still applies it alongside detached, so the mining + // worker never flashes a console. No-op on POSIX. + windowsHide: true, env: process.env, }); closeSync(out); diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index 73e063c50..33f7e0743 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -38,9 +38,10 @@ describe("inner CLI spawn windowsHide — source guards", () => { }); // The helper LOOKUPS, not the CLI spawns. These run `where.exe` on Windows - // from inside detached workers that have no console to inherit, so each one - // allocates its own visible window without CREATE_NO_WINDOW — the same flash - // the CLI spawns produced, one layer earlier. + // on the way to launching a detached worker, so without CREATE_NO_WINDOW + // each one allocates its own visible window — the same flash the CLI spawns + // produced, one layer earlier. (resolveCliBin is also called from inside + // already-detached workers, which have no console to inherit at all.) it("resolveCliBin's where/which lookup passes windowsHide", () => { expect(src("src/utils/resolve-cli-bin.ts")).toMatch( /execFileSync\(isWin \? "where" : "which"[^)]*windowsHide:\s*true/, @@ -61,3 +62,36 @@ describe("inner CLI spawn windowsHide — source guards", () => { expect(s).toMatch(/spawn\(\s*plan\.file[^)]*windowsHide:\s*plan\.windowsHide/); }); }); + +/** + * The detached WORKER launches themselves. `detached: true` maps to + * DETACHED_PROCESS, which makes Windows ignore CREATE_NO_WINDOW — but libuv + * sets SW_HIDE from `windowsHide` as well, and that still applies, so the + * option is not a no-op on these. `spawn-detached.ts` has always paired the + * two; these are the launches that were missing it. + */ +describe("detached worker spawn windowsHide — source guards", () => { + it("mine-local worker launch passes windowsHide", () => { + expect(src("src/skillify/spawn-mine-local-worker.ts")).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); + }); + + it("backfill-memory worker launch passes windowsHide", () => { + expect(src("src/skillify/spawn-backfill-memory-worker.ts")).toMatch(/spawn\(cmd,\s*cmdArgs[^)]*windowsHide:\s*true/); + }); + + it("the auto-spawned embedding daemon passes windowsHide", () => { + expect(src("src/embeddings/client.ts")).toMatch(/spawn\(process\.execPath,\s*\[this\.daemonEntry\][^)]*windowsHide:\s*true/); + }); + + it("pi's auto-mine launcher lookup and worker launch both pass windowsHide", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + expect(pi).toMatch(/execFileSync\("which",\s*\["hivemind"\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); + }); + + it("openclaw's agent lookup and skillify worker launch both pass windowsHide", () => { + const oc = src("harnesses/openclaw/src/index.ts"); + expect(oc).toMatch(/realExecFileSync\("which",\s*\[bin\][^)]*windowsHide:\s*true/); + expect(oc).toMatch(/realSpawn\(process\.execPath,\s*\[OPENCLAW_SKILLIFY_WORKER_PATH[^)]*windowsHide:\s*true/); + }); +}); From b7f0f2d5c84730af958a12aef623f56bf06328e7 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 19:24:39 +0000 Subject: [PATCH 5/8] fix(windows): windowsHide on hook-triggered launches; fix which/where lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten more detached launches that fire during an ordinary session, so a missing SW_HIDE is the same visible flash #331 reported from a different worker: pi's embedding daemon, skillopt, wiki and skillify workers; the shared skillopt worker and standalone embedding daemon; codex's session-start setup; shared autoupdate; openclaw's graph build and pull. Also fixes the hardcoded Unix `which` rather than documenting it. The previous commit added windowsHide to those two lookups and left a NOTE saying they cannot succeed on Windows — which made the option a no-op there. Both now select where/which by platform, and pi takes the first non-empty line because `where` prints one match per line. Not included: git and provisioning calls made from inside workers that are already launched (graph-deps, graph-on-stop, deeplake-pull, docs candidates). Same mechanism, but this change already spans five subsystems; that tail belongs in its own sweep. Found by codex review. --- harnesses/openclaw/src/graph-lifecycle.ts | 4 ++ harnesses/pi/extension-source/hivemind.ts | 18 ++++-- src/embeddings/standalone-embed-client.ts | 2 + src/hooks/codex/session-start.ts | 2 + src/hooks/shared/autoupdate.ts | 2 + src/skillify/skillopt-trigger.ts | 2 + ...inner-cli-spawn-windowshide-source.test.ts | 60 ++++++++++++++++++- 7 files changed, 83 insertions(+), 7 deletions(-) diff --git a/harnesses/openclaw/src/graph-lifecycle.ts b/harnesses/openclaw/src/graph-lifecycle.ts index c2836791c..dce91948f 100644 --- a/harnesses/openclaw/src/graph-lifecycle.ts +++ b/harnesses/openclaw/src/graph-lifecycle.ts @@ -57,6 +57,8 @@ export function spawnOpenclawGraphOnStop( const child = sp(process.execPath, [workerPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, cwd, }); child.on("error", () => { /* best-effort */ }); @@ -80,6 +82,8 @@ export function spawnOpenclawGraphPullWorker( const child = sp("nohup", ["node", workerPath, "--cwd", cwd], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.on("error", () => { graphPullSpawned = false; }); child.unref(); diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index 3d8f80771..fbaa18092 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -398,6 +398,8 @@ function trySpawnDaemonInline(): boolean { const child = spawn(process.execPath, [EMBED_DAEMON_ENTRY], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); logHm(`embed: spawned daemon pid=${child.pid}`); @@ -633,6 +635,8 @@ function skilloptReact(sessionId: string, reaction: string): void { const child = spawn(process.execPath, [PI_SKILLOPT_WORKER_PATH], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_SKILLOPT_WORKER: "1", // recursion guard (worker won't re-fire the trigger) @@ -838,6 +842,8 @@ function spawnWikiWorker( spawn(process.execPath, [PI_WIKI_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); } catch (e: any) { @@ -928,6 +934,8 @@ function spawnPiSkillifyWorker(creds: Creds, sessionId: string, cwd: string): vo spawn(process.execPath, [PI_SKILLIFY_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_SKILLIFY_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); } catch (e: any) { @@ -1126,11 +1134,11 @@ function piMaybeAutoMineLocal(): boolean { } catch { /* fall through to which */ } if (!launcher) { try { - // NOTE: "which" is hardcoded, so this lookup cannot succeed on - // Windows at all — tracked separately; windowsHide keeps it from - // flashing wherever it does run. - const out = execFileSync("which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); - const bin = String(out).trim(); + // `which` is Unix-only; Windows needs `where`, which also prints one + // match per line — take the first non-empty one. + const lookup = process.platform === "win32" ? "where" : "which"; + const out = execFileSync(lookup, ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); + const bin = String(out).split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? ""; if (bin) launcher = { kind: "bin", path: bin }; } catch { return false; } } diff --git a/src/embeddings/standalone-embed-client.ts b/src/embeddings/standalone-embed-client.ts index 5b328dd75..f0979040e 100644 --- a/src/embeddings/standalone-embed-client.ts +++ b/src/embeddings/standalone-embed-client.ts @@ -219,6 +219,8 @@ function trySpawnDaemon(daemonEntry: string, pidPath: string): boolean { const child = _spawn(process.execPath, [daemonEntry], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); return true; diff --git a/src/hooks/codex/session-start.ts b/src/hooks/codex/session-start.ts index 5cbdbe1e3..0d0f3d273 100644 --- a/src/hooks/codex/session-start.ts +++ b/src/hooks/codex/session-start.ts @@ -72,6 +72,8 @@ async function main(): Promise { const child = spawn("node", [setupScript], { detached: true, stdio: ["pipe", "ignore", "ignore"], + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env }, }); // Feed the same stdin input to the setup process diff --git a/src/hooks/shared/autoupdate.ts b/src/hooks/shared/autoupdate.ts index 4ba5ff5d2..292a03e2f 100644 --- a/src/hooks/shared/autoupdate.ts +++ b/src/hooks/shared/autoupdate.ts @@ -128,6 +128,8 @@ const defaultSpawn = (cmd: string, args: string[]): { pid?: number } => { const child = spawn(cmd, args, { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); // Swallow the unhandled 'error' event that fires synchronously when diff --git a/src/skillify/skillopt-trigger.ts b/src/skillify/skillopt-trigger.ts index 16525cc90..e1e16022b 100644 --- a/src/skillify/skillopt-trigger.ts +++ b/src/skillify/skillopt-trigger.ts @@ -155,6 +155,8 @@ function spawnWorker(sessionId: string, skill: string, reaction: string, toolUse const child = spawn(process.execPath, [entry], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, [SKILLOPT_ENV.WORKER]: "1", diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index 33f7e0743..c5a98e457 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -85,13 +85,69 @@ describe("detached worker spawn windowsHide — source guards", () => { it("pi's auto-mine launcher lookup and worker launch both pass windowsHide", () => { const pi = src("harnesses/pi/extension-source/hivemind.ts"); - expect(pi).toMatch(/execFileSync\("which",\s*\["hivemind"\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/execFileSync\(lookup,\s*\["hivemind"\][^)]*windowsHide:\s*true/); expect(pi).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); }); it("openclaw's agent lookup and skillify worker launch both pass windowsHide", () => { const oc = src("harnesses/openclaw/src/index.ts"); - expect(oc).toMatch(/realExecFileSync\("which",\s*\[bin\][^)]*windowsHide:\s*true/); + expect(oc).toMatch(/realExecFileSync\(lookup,\s*\[bin\][^)]*windowsHide:\s*true/); expect(oc).toMatch(/realSpawn\(process\.execPath,\s*\[OPENCLAW_SKILLIFY_WORKER_PATH[^)]*windowsHide:\s*true/); }); }); + +/** + * Hook-triggered detached launches. These fire during an ordinary session + * (session start, stop, skill reactions, embedding warm-up), so a missing + * SW_HIDE here is the same user-visible flash #331 reported, from a + * different worker. + */ +describe("hook-triggered detached launch windowsHide — source guards", () => { + const CASES: Array<[string, string, RegExp]> = [ + ["shared skillopt worker", "src/skillify/skillopt-trigger.ts", /spawn\(process\.execPath,\s*\[entry\][^)]*windowsHide:\s*true/], + ["standalone embedding daemon", "src/embeddings/standalone-embed-client.ts", /_spawn\(process\.execPath,\s*\[daemonEntry\][^)]*windowsHide:\s*true/], + ["codex session-start setup", "src/hooks/codex/session-start.ts", /spawn\("node",\s*\[setupScript\][^)]*windowsHide:\s*true/], + ["shared autoupdate", "src/hooks/shared/autoupdate.ts", /spawn\(cmd,\s*args[^)]*windowsHide:\s*true/], + ]; + for (const [name, rel, re] of CASES) { + it(`${name} passes windowsHide`, () => { + expect(src(rel)).toMatch(re); + }); + } + + it("openclaw's graph build and pull workers both pass windowsHide", () => { + const oc = src("harnesses/openclaw/src/graph-lifecycle.ts"); + expect(oc).toMatch(/sp\(process\.execPath,\s*\[workerPath\][^)]*windowsHide:\s*true/); + expect(oc).toMatch(/sp\("nohup",\s*\["node",\s*workerPath[^)]*windowsHide:\s*true/); + }); + + it("pi's four detached launches all pass windowsHide", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + // embedding daemon, skillopt worker, wiki worker, skillify worker + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[EMBED_DAEMON_ENTRY\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_SKILLOPT_WORKER_PATH\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_WIKI_WORKER_PATH,\s*configPath\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_SKILLIFY_WORKER_PATH,\s*configPath\][^)]*windowsHide:\s*true/); + }); +}); + +/** + * The launcher lookups hardcoded Unix `which`, so on Windows they threw on + * every call — pi's auto-mine fallback and openclaw's agent detection were + * not merely noisy there, they never resolved a binary at all. + */ +describe("platform-correct binary lookups — source guards", () => { + it("pi selects where/which by platform and takes the first non-empty match", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + expect(pi).toContain('const lookup = process.platform === "win32" ? "where" : "which";'); + expect(pi).toMatch(/execFileSync\(lookup,\s*\["hivemind"\]/); + // `where` prints one match per line; a raw .trim() would return all of them + expect(pi).toMatch(/split\(\/\\r\?\\n\/\)[\s\S]{0,60}find\(Boolean\)/); + }); + + it("openclaw selects where/which by platform", () => { + const oc = src("harnesses/openclaw/src/index.ts"); + expect(oc).toContain('const lookup = process.platform === "win32" ? "where" : "which";'); + expect(oc).toMatch(/realExecFileSync\(lookup,\s*\[bin\]/); + }); +}); From 0d6fccfa02eeb0a2223af9bc72961fea3c7d6195 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 19:28:10 +0000 Subject: [PATCH 6/8] fix(openclaw): commit the where/which lookup fix Omitted from the previous commit's pathspec, so the source still had the hardcoded "which" while its guard asserted the platform-selected form. Local runs passed on the working tree; CI tested the committed tree and caught it. --- harnesses/openclaw/src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/harnesses/openclaw/src/index.ts b/harnesses/openclaw/src/index.ts index 54a84f6b5..78cb7976f 100644 --- a/harnesses/openclaw/src/index.ts +++ b/harnesses/openclaw/src/index.ts @@ -591,9 +591,10 @@ function detectOpenclawGateAgent(): GateAgent | null { ]; for (const [agent, bin] of candidates) { try { - // NOTE: "which" is hardcoded here too — same Windows gap as pi's - // launcher lookup, tracked separately. - realExecFileSync("which", [bin], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); + // `which` is Unix-only; Windows needs `where`. Without this the gate + // detection throws on every candidate and reports "no agent found". + const lookup = process.platform === "win32" ? "where" : "which"; + realExecFileSync(lookup, [bin], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); return agent; } catch { /* not on PATH, try next */ } } From fd44a2e70f4cf30e1b5fd2be983604c772bd91c3 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Wed, 29 Jul 2026 19:36:20 +0000 Subject: [PATCH 7/8] fix(windows): drop POSIX-only nohup, mirror resolve-cli-bin shim selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two launches that windowsHide could not have helped, because neither could execute on Windows in the first place. openclaw's graph pull spawned `nohup node ...`. nohup is POSIX-only, so that ENOENT'd on Windows and the pull worker never ran there. Spawn process.execPath directly, as graph-on-stop already does — detached + unref is what provides survival. pi's launcher lookup took the first non-empty `where` line, but that can be an extensionless shim (not runnable) or a .cmd (needs a shell), and pi spawned either directly. Mirror src/utils/resolve-cli-bin.ts: prefer .exe, then .cmd/.bat, else first match; route a .cmd/.bat through a shell with only the fixed subcommand on the command line. Found by codex review. --- harnesses/openclaw/src/graph-lifecycle.ts | 6 ++++- harnesses/pi/extension-source/hivemind.ts | 21 ++++++++++++---- ...inner-cli-spawn-windowshide-source.test.ts | 24 +++++++++++++------ tests/openclaw/graph-lifecycle.test.ts | 9 ++++--- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/harnesses/openclaw/src/graph-lifecycle.ts b/harnesses/openclaw/src/graph-lifecycle.ts index dce91948f..16e171dd6 100644 --- a/harnesses/openclaw/src/graph-lifecycle.ts +++ b/harnesses/openclaw/src/graph-lifecycle.ts @@ -79,7 +79,11 @@ export function spawnOpenclawGraphPullWorker( if (!existsFn(workerPath)) return; try { const sp = deps.spawn ?? realSpawn; - const child = sp("nohup", ["node", workerPath, "--cwd", cwd], { + // `nohup` is POSIX-only — on Windows this spawn ENOENT'd, so the pull + // worker never ran there at all and windowsHide could not help. detached + // + unref already gives the survival nohup was there for, and it matches + // what graph-on-stop does. + const child = sp(process.execPath, [workerPath, "--cwd", cwd], { detached: true, stdio: "ignore", // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index fbaa18092..9a449f663 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -1134,11 +1134,18 @@ function piMaybeAutoMineLocal(): boolean { } catch { /* fall through to which */ } if (!launcher) { try { - // `which` is Unix-only; Windows needs `where`, which also prints one - // match per line — take the first non-empty one. - const lookup = process.platform === "win32" ? "where" : "which"; - const out = execFileSync(lookup, ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); - const bin = String(out).split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? ""; + // `which` is Unix-only; Windows needs `where`, which prints one match + // per line. Mirror src/utils/resolve-cli-bin.ts: prefer a real .exe, + // then a .cmd/.bat shim, else the first match — an extensionless shim + // is not directly runnable on Windows. + const isWin = process.platform === "win32"; + const out = execFileSync(isWin ? "where" : "which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); + const matches = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const bin = !isWin + ? (matches[0] ?? "") + : (matches.find((m) => m.toLowerCase().endsWith(".exe")) + ?? matches.find((m) => /\.(cmd|bat)$/i.test(m)) + ?? matches[0] ?? ""); if (bin) launcher = { kind: "bin", path: bin }; } catch { return false; } } @@ -1158,11 +1165,15 @@ function piMaybeAutoMineLocal(): boolean { const [cmd, args]: [string, string[]] = launcher.kind === "node-script" ? [process.execPath, [launcher.path, "skillify", "mine-local"]] : [launcher.path, ["skillify", "mine-local"]]; + // A .cmd/.bat shim is not directly executable — it needs a shell. Only + // the fixed subcommand rides the command line, never user text. + const needsShell = /\.(cmd|bat)$/i.test(cmd); const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. windowsHide: true, + ...(needsShell ? { shell: true } : {}), env: process.env, }); closeSync(out); diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index c5a98e457..cc0007883 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -85,7 +85,7 @@ describe("detached worker spawn windowsHide — source guards", () => { it("pi's auto-mine launcher lookup and worker launch both pass windowsHide", () => { const pi = src("harnesses/pi/extension-source/hivemind.ts"); - expect(pi).toMatch(/execFileSync\(lookup,\s*\["hivemind"\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/execFileSync\(isWin \? "where" : "which",\s*\["hivemind"\][^)]*windowsHide:\s*true/); expect(pi).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); }); @@ -118,7 +118,10 @@ describe("hook-triggered detached launch windowsHide — source guards", () => { it("openclaw's graph build and pull workers both pass windowsHide", () => { const oc = src("harnesses/openclaw/src/graph-lifecycle.ts"); expect(oc).toMatch(/sp\(process\.execPath,\s*\[workerPath\][^)]*windowsHide:\s*true/); - expect(oc).toMatch(/sp\("nohup",\s*\["node",\s*workerPath[^)]*windowsHide:\s*true/); + // nohup is POSIX-only: on Windows it ENOENT'd, so the pull worker never + // ran there and windowsHide was moot. Must spawn node directly. + expect(oc).not.toContain('"nohup"'); + expect(oc).toMatch(/sp\(process\.execPath,\s*\[workerPath,\s*"--cwd",\s*cwd\][^)]*windowsHide:\s*true/); }); it("pi's four detached launches all pass windowsHide", () => { @@ -137,12 +140,19 @@ describe("hook-triggered detached launch windowsHide — source guards", () => { * not merely noisy there, they never resolved a binary at all. */ describe("platform-correct binary lookups — source guards", () => { - it("pi selects where/which by platform and takes the first non-empty match", () => { + it("pi mirrors resolve-cli-bin's .exe -> .cmd/.bat selection on Windows", () => { const pi = src("harnesses/pi/extension-source/hivemind.ts"); - expect(pi).toContain('const lookup = process.platform === "win32" ? "where" : "which";'); - expect(pi).toMatch(/execFileSync\(lookup,\s*\["hivemind"\]/); - // `where` prints one match per line; a raw .trim() would return all of them - expect(pi).toMatch(/split\(\/\\r\?\\n\/\)[\s\S]{0,60}find\(Boolean\)/); + expect(pi).toContain('const isWin = process.platform === "win32";'); + expect(pi).toMatch(/execFileSync\(isWin \? "where" : "which",\s*\["hivemind"\]/); + // an extensionless shim is not runnable on Windows, so .exe wins, then .cmd/.bat + expect(pi).toMatch(/find\(\(m\) => m\.toLowerCase\(\)\.endsWith\("\.exe"\)\)/); + expect(pi).toMatch(/find\(\(m\) => \/\\\.\(cmd\|bat\)\$\/i\.test\(m\)\)/); + }); + + it("pi runs a .cmd/.bat launcher through a shell", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + expect(pi).toMatch(/const needsShell = \/\\\.\(cmd\|bat\)\$\/i\.test\(cmd\)/); + expect(pi).toMatch(/\.\.\.\(needsShell \? \{ shell: true \} : \{\}\)/); }); it("openclaw selects where/which by platform", () => { diff --git a/tests/openclaw/graph-lifecycle.test.ts b/tests/openclaw/graph-lifecycle.test.ts index 926747f29..db6b3c572 100644 --- a/tests/openclaw/graph-lifecycle.test.ts +++ b/tests/openclaw/graph-lifecycle.test.ts @@ -55,10 +55,13 @@ describe("openclaw graph-lifecycle", () => { spawnOpenclawGraphPullWorker("/dist/graph-pull-worker.js", "/my/repo", { spawn, exists }); spawnOpenclawGraphPullWorker("/dist/graph-pull-worker.js", "/my/repo", { spawn, exists }); expect(spawn).toHaveBeenCalledTimes(1); + // node directly, not `nohup` — nohup is POSIX-only and ENOENT'd on + // Windows, so the pull worker never ran there. detached + unref is what + // provides survival, matching graph-on-stop. expect(spawn).toHaveBeenCalledWith( - "nohup", - ["node", "/dist/graph-pull-worker.js", "--cwd", "/my/repo"], - expect.objectContaining({ detached: true }), + process.execPath, + ["/dist/graph-pull-worker.js", "--cwd", "/my/repo"], + expect.objectContaining({ detached: true, windowsHide: true }), ); }); From 788496eaffee12e0db7e2181956d3ff90bb01190 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Thu, 30 Jul 2026 18:26:40 +0000 Subject: [PATCH 8/8] fix(windows): quote shim paths under shell:true (spaces break the spawn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `shell: true` Node concatenates file + args into one command string with no escaping, so an unquoted path containing a space is parsed as two tokens and the spawn fails. This is not hypothetical: npm ships claude.cmd with no .exe, so every Windows npm user takes the shell branch, and the default global bin for an account with a space in its name is C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd. Those users' summary runs fail — which is exactly what drives the respawn loop fixed in #332. Adds shellFile() beside binNeedsShell in resolve-cli-bin.ts and applies it to all three invocation builders. pi's launcher gets the same treatment, and its predicate is now win32-gated to match binNeedsShell — a POSIX file merely named *.cmd must still spawn directly. Behavioral tests cover a spaced .cmd path through every builder, plus a negative case: the non-shell argv path must NOT be quoted, since there a quoted path is a literally wrong filename. Found by codex review. --- harnesses/pi/extension-source/hivemind.ts | 16 +++-- src/hooks/wiki-worker-spawn.ts | 8 +-- src/utils/resolve-cli-bin.ts | 14 ++++ ...inner-cli-spawn-windowshide-source.test.ts | 11 +++- tests/claude-code/wiki-worker-windows.test.ts | 64 ++++++++++++++++++- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index 9a449f663..610e26af9 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -1165,10 +1165,18 @@ function piMaybeAutoMineLocal(): boolean { const [cmd, args]: [string, string[]] = launcher.kind === "node-script" ? [process.execPath, [launcher.path, "skillify", "mine-local"]] : [launcher.path, ["skillify", "mine-local"]]; - // A .cmd/.bat shim is not directly executable — it needs a shell. Only - // the fixed subcommand rides the command line, never user text. - const needsShell = /\.(cmd|bat)$/i.test(cmd); - const child = spawn(cmd, args, { + // A Windows .cmd/.bat shim is not directly executable — it needs a + // shell. Mirror of binNeedsShell in src/utils/resolve-cli-bin.ts, + // including the win32 gate: on POSIX a file merely named *.cmd must + // still spawn directly. + const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(cmd); + // Under `shell: true` Node concatenates file + args into one command + // string with no escaping, so an unquoted install path containing a + // space (C:\\Users\\Jane Doe\\...) is parsed as two tokens. Quote the + // executable; only the fixed subcommand rides the command line, never + // user text. + const shellCmd = needsShell ? `"${cmd}"` : cmd; + const child = spawn(shellCmd, args, { detached: true, stdio: ["ignore", out, out], // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. diff --git a/src/hooks/wiki-worker-spawn.ts b/src/hooks/wiki-worker-spawn.ts index 7d5a6f201..79f374cea 100644 --- a/src/hooks/wiki-worker-spawn.ts +++ b/src/hooks/wiki-worker-spawn.ts @@ -1,5 +1,5 @@ import type { ExecFileSyncOptions } from "node:child_process"; -import { binNeedsShell } from "../utils/resolve-cli-bin.js"; +import { binNeedsShell, shellFile } from "../utils/resolve-cli-bin.js"; /** Fixed flags for the summary-generation `claude -p` call (no user input). */ const CLAUDE_FLAGS = [ @@ -33,7 +33,7 @@ export interface ClaudeInvocation { export function buildClaudeInvocation(claudeBin: string, prompt: string): ClaudeInvocation { if (binNeedsShell(claudeBin)) { return { - file: claudeBin, + file: shellFile(claudeBin), args: ["-p", ...CLAUDE_FLAGS], // windowsHide: the wiki worker is a detached, console-less process, so // without CREATE_NO_WINDOW Windows allocates a visible console window @@ -64,7 +64,7 @@ export function buildClaudeInvocation(claudeBin: string, prompt: string): Claude export function buildTrailingPromptInvocation(bin: string, flags: string[], prompt: string): ClaudeInvocation { if (binNeedsShell(bin)) { return { - file: bin, + file: shellFile(bin), args: [...flags], // windowsHide: see buildClaudeInvocation — suppress the visible console // window Windows would pop for a child of the console-less worker. @@ -87,7 +87,7 @@ export function buildTrailingPromptInvocation(bin: string, flags: string[], prom */ export function buildStdinPromptInvocation(bin: string, flags: string[], prompt: string): ClaudeInvocation { return { - file: bin, + file: shellFile(bin), args: [...flags], options: { input: prompt, diff --git a/src/utils/resolve-cli-bin.ts b/src/utils/resolve-cli-bin.ts index bdc239ee2..29d73ac66 100644 --- a/src/utils/resolve-cli-bin.ts +++ b/src/utils/resolve-cli-bin.ts @@ -65,3 +65,17 @@ export function resolveCliBin(cli: string, fallback?: string): string { export function binNeedsShell(bin: string): boolean { return process.platform === "win32" && /\.(cmd|bat)$/i.test(bin); } + +/** + * The `file` to hand a shell-mode spawn. + * + * Under `shell: true` Node concatenates file + args into a single command + * string with no escaping, so an unquoted path containing a space — + * `C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd`, the default npm + * global bin for any Windows account with a space in its name — is parsed as + * two tokens and the spawn fails. Quote it. Non-shell spawns pass argv + * directly and must NOT be quoted. + */ +export function shellFile(bin: string): string { + return binNeedsShell(bin) ? `"${bin}"` : bin; +} diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index cc0007883..b226238b2 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -86,7 +86,7 @@ describe("detached worker spawn windowsHide — source guards", () => { it("pi's auto-mine launcher lookup and worker launch both pass windowsHide", () => { const pi = src("harnesses/pi/extension-source/hivemind.ts"); expect(pi).toMatch(/execFileSync\(isWin \? "where" : "which",\s*\["hivemind"\][^)]*windowsHide:\s*true/); - expect(pi).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(shellCmd,\s*args[^)]*windowsHide:\s*true/); }); it("openclaw's agent lookup and skillify worker launch both pass windowsHide", () => { @@ -149,10 +149,15 @@ describe("platform-correct binary lookups — source guards", () => { expect(pi).toMatch(/find\(\(m\) => \/\\\.\(cmd\|bat\)\$\/i\.test\(m\)\)/); }); - it("pi runs a .cmd/.bat launcher through a shell", () => { + it("pi shells a .cmd/.bat launcher, win32-gated and quoted", () => { const pi = src("harnesses/pi/extension-source/hivemind.ts"); - expect(pi).toMatch(/const needsShell = \/\\\.\(cmd\|bat\)\$\/i\.test\(cmd\)/); + // win32-gated, mirroring binNeedsShell: a POSIX file merely named *.cmd + // must still spawn directly + expect(pi).toMatch(/const needsShell = process\.platform === "win32" && \/\\\.\(cmd\|bat\)\$\/i\.test\(cmd\)/); expect(pi).toMatch(/\.\.\.\(needsShell \? \{ shell: true \} : \{\}\)/); + // quoted: shell:true concatenates without escaping, so a path containing + // a space would otherwise be parsed as two tokens + expect(pi).toMatch(/const shellCmd = needsShell \? `"\$\{cmd\}"` : cmd;/); }); it("openclaw selects where/which by platform", () => { diff --git a/tests/claude-code/wiki-worker-windows.test.ts b/tests/claude-code/wiki-worker-windows.test.ts index 9ac21b80b..a6d297e90 100644 --- a/tests/claude-code/wiki-worker-windows.test.ts +++ b/tests/claude-code/wiki-worker-windows.test.ts @@ -28,7 +28,7 @@ vi.mock("node:os", async () => { return { ...actual, homedir: () => "/home/tester" }; }); -import { resolveCliBin, binNeedsShell } from "../../src/utils/resolve-cli-bin.js"; +import { resolveCliBin, binNeedsShell, shellFile } from "../../src/utils/resolve-cli-bin.js"; import { buildClaudeInvocation, buildTrailingPromptInvocation, buildStdinPromptInvocation, buildClaudeStdinInvocation } from "../../src/hooks/wiki-worker-spawn.js"; const realPlatform = process.platform; @@ -140,7 +140,9 @@ describe("buildClaudeInvocation", () => { it("Windows .cmd: spawns through a shell with the prompt over stdin, never on the command line", () => { setPlatform("win32"); const inv = buildClaudeInvocation("C:\\npm\\claude.cmd", "PROMPT-TEXT"); - expect(inv.file).toBe("C:\\npm\\claude.cmd"); + // quoted: under shell:true Node concatenates without escaping, so the + // path must carry its own quotes (see the spaced-path describe below) + expect(inv.file).toBe('"C:\\npm\\claude.cmd"'); expect(inv.options.shell).toBe(true); expect(inv.options.input).toBe("PROMPT-TEXT"); expect(inv.args).toEqual(["-p", ...CLAUDE_FLAGS]); @@ -170,7 +172,7 @@ describe("buildTrailingPromptInvocation (codex / cursor / pi)", () => { it("Windows .cmd: shell + prompt over stdin; flags only on the command line", () => { setPlatform("win32"); const inv = buildTrailingPromptInvocation("C:\\npm\\codex.cmd", FLAGS, "PROMPT-TEXT"); - expect(inv.file).toBe("C:\\npm\\codex.cmd"); + expect(inv.file).toBe('"C:\\npm\\codex.cmd"'); expect(inv.options.shell).toBe(true); expect(inv.options.input).toBe("PROMPT-TEXT"); expect(inv.args).toEqual(FLAGS); @@ -231,3 +233,59 @@ describe("windowsHide — no visible console window for the summarizer CLI", () expect(buildClaudeStdinInvocation("/usr/local/bin/claude", "P").options.windowsHide).toBe(true); }); }); + +/** + * Behavioral cover for the spaced-path bug. `shell: true` makes Node + * concatenate file + args into one command string with NO escaping, so an + * unquoted path with a space is parsed as two tokens and the spawn fails. + * + * This is the default npm layout for any Windows account whose name contains + * a space — `C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd` — and npm + * ships no .exe, so those users always take the shell branch. A failing + * summary run is what drives the #331 respawn loop, so this path matters. + */ +describe("shell-mode spawns quote a shim path containing spaces", () => { + const SPACED = "C:\\Users\\Jane Doe\\AppData\\Roaming\\npm\\claude.cmd"; + + it("shellFile quotes a Windows shim and leaves everything else alone", () => { + setPlatform("win32"); + expect(shellFile(SPACED)).toBe(`"${SPACED}"`); + expect(shellFile("C:\\x\\claude.exe")).toBe("C:\\x\\claude.exe"); + setPlatform("linux"); + // a POSIX file merely named *.cmd is spawned directly — quoting it would + // make the path itself wrong + expect(shellFile("/usr/bin/weird.cmd")).toBe("/usr/bin/weird.cmd"); + }); + + it("buildClaudeInvocation quotes the spaced shim and keeps the prompt off argv", () => { + setPlatform("win32"); + const inv = buildClaudeInvocation(SPACED, "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + expect(inv.options.input).toBe("PROMPT"); + expect(inv.args).not.toContain("PROMPT"); + }); + + it("buildTrailingPromptInvocation quotes the spaced shim", () => { + setPlatform("win32"); + const inv = buildTrailingPromptInvocation(SPACED, ["exec"], "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + }); + + it("buildStdinPromptInvocation quotes the spaced shim", () => { + setPlatform("win32"); + const inv = buildStdinPromptInvocation(SPACED, ["-p"], "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + }); + + it("does NOT quote on the non-shell path, where argv is passed directly", () => { + setPlatform("win32"); + const exe = "C:\\Program Files\\claude\\claude.exe"; + const inv = buildClaudeInvocation(exe, "PROMPT"); + // no shell -> argv, so a quoted path would be a literally wrong filename + expect(inv.file).toBe(exe); + expect(inv.options.shell).toBeUndefined(); + }); +});