diff --git a/src/commands/mine-local.ts b/src/commands/mine-local.ts index 5bc9c1bb..b1113bbd 100644 --- a/src/commands/mine-local.ts +++ b/src/commands/mine-local.ts @@ -121,6 +121,10 @@ function runGateViaStdin(opts: { const child = spawn(opts.bin, args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, + // CREATE_NO_WINDOW: this runs inside the detached, console-less mine-local + // worker, so without it Windows pops a visible console window for the + // summarizer CLI child. No-op on POSIX. + windowsHide: true, }); let stdout = ""; diff --git a/src/hooks/commit-kpi-extract.ts b/src/hooks/commit-kpi-extract.ts index a09199b5..8d81770b 100644 --- a/src/hooks/commit-kpi-extract.ts +++ b/src/hooks/commit-kpi-extract.ts @@ -122,6 +122,9 @@ export async function tryCommitKpiExtract( cwd, detached: true, stdio: "ignore", + // CREATE_NO_WINDOW: detached summarizer CLI spawn — without it Windows + // pops a visible console window for the claude/codex child. No-op on POSIX. + windowsHide: true, env: process.env, }); child.unref(); diff --git a/src/hooks/hermes/wiki-worker.ts b/src/hooks/hermes/wiki-worker.ts index d1d04d5c..2953b8bc 100644 --- a/src/hooks/hermes/wiki-worker.ts +++ b/src/hooks/hermes/wiki-worker.ts @@ -269,6 +269,9 @@ async function main(): Promise { "--ignore-user-config", ], { stdio: ["ignore", "pipe", "pipe"], + // Suppress the visible console window Windows would otherwise pop for + // a child of this console-less detached worker. No-op on POSIX. + windowsHide: true, timeout: 120_000, // hermes streams to stdout, which execFileSync buffers. The Node // default (1 MB) overflows to ENOBUFS on a verbose run, killing the diff --git a/src/hooks/wiki-worker-spawn.ts b/src/hooks/wiki-worker-spawn.ts index 4a62571c..7d5a6f20 100644 --- a/src/hooks/wiki-worker-spawn.ts +++ b/src/hooks/wiki-worker-spawn.ts @@ -35,13 +35,16 @@ export function buildClaudeInvocation(claudeBin: string, prompt: string): Claude return { file: claudeBin, args: ["-p", ...CLAUDE_FLAGS], - options: { input: prompt, stdio: ["pipe", "pipe", "pipe"], shell: true }, + // windowsHide: the wiki worker is a detached, console-less process, so + // without CREATE_NO_WINDOW Windows allocates a visible console window + // (titled after the CLI exe) for the child. No-op on POSIX. + options: { input: prompt, stdio: ["pipe", "pipe", "pipe"], shell: true, windowsHide: true }, }; } return { file: claudeBin, args: ["-p", prompt, ...CLAUDE_FLAGS], - options: { stdio: ["ignore", "pipe", "pipe"] }, + options: { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, }; } @@ -63,13 +66,15 @@ export function buildTrailingPromptInvocation(bin: string, flags: string[], prom return { file: bin, args: [...flags], - options: { input: prompt, stdio: ["pipe", "pipe", "pipe"], shell: true }, + // windowsHide: see buildClaudeInvocation — suppress the visible console + // window Windows would pop for a child of the console-less worker. + options: { input: prompt, stdio: ["pipe", "pipe", "pipe"], shell: true, windowsHide: true }, }; } return { file: bin, args: [...flags, prompt], - options: { stdio: ["ignore", "pipe", "pipe"] }, + options: { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, }; } @@ -87,6 +92,10 @@ export function buildStdinPromptInvocation(bin: string, flags: string[], prompt: options: { input: prompt, stdio: ["pipe", "pipe", "pipe"], + // windowsHide: see buildClaudeInvocation — the doc/wiki worker is a + // detached, console-less process, so without CREATE_NO_WINDOW Windows + // pops a visible console window for the summarizer CLI. No-op on POSIX. + windowsHide: true, ...(binNeedsShell(bin) ? { shell: true } : {}), }, }; diff --git a/src/skillify/advisor.ts b/src/skillify/advisor.ts index 942b2943..48057898 100644 --- a/src/skillify/advisor.ts +++ b/src/skillify/advisor.ts @@ -131,6 +131,10 @@ function runAdvisorGate(prompt: string, claudeBin: string): Promise { ], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, + // CREATE_NO_WINDOW: this runs inside the detached, console-less mine-local + // worker, so without it Windows pops a visible console window for the + // claude child. No-op on POSIX. + windowsHide: true, }); let stdout = ""; let stderr = ""; diff --git a/src/skillify/agent-model.ts b/src/skillify/agent-model.ts index ac6209d1..da640dd5 100644 --- a/src/skillify/agent-model.ts +++ b/src/skillify/agent-model.ts @@ -146,6 +146,7 @@ export function agentModel(opts: { const child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...env, HIVEMIND_CAPTURE: "false", HIVEMIND_WIKI_WORKER: "1" }, + windowsHide: true, }); let out = ""; let err = ""; diff --git a/src/skillify/claude-model.ts b/src/skillify/claude-model.ts index 34ea08e6..1cab05b3 100644 --- a/src/skillify/claude-model.ts +++ b/src/skillify/claude-model.ts @@ -36,6 +36,10 @@ export function claudeModel(model: string, opts: { timeoutMs?: number } = {}): M const child = spawn(findAgentBin("claude_code"), args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, HIVEMIND_CAPTURE: "false", HIVEMIND_WIKI_WORKER: "1" }, + // CREATE_NO_WINDOW: the skillopt engine's judge/proposer steps run inside + // the detached, console-less skillopt/mine-local worker, so without it + // Windows pops a visible console window for the claude child. No-op on POSIX. + windowsHide: true, }); let out = ""; let err = ""; diff --git a/src/skillify/gate-runner.ts b/src/skillify/gate-runner.ts index be2c462a..8c60726c 100644 --- a/src/skillify/gate-runner.ts +++ b/src/skillify/gate-runner.ts @@ -200,6 +200,9 @@ export function runGate(opts: GateRunOptions): GateRunResult { try { const result = runChildProcess(bin, args, { stdio: ["ignore", "pipe", "pipe"], + // Suppress the visible console window Windows would otherwise pop for + // a child of the console-less detached skillify worker. No-op on POSIX. + windowsHide: true, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 8 * 1024 * 1024, env: { ...inheritedEnv.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, diff --git a/src/skillify/stage-memory.ts b/src/skillify/stage-memory.ts index 7efd1c8b..5b3013de 100644 --- a/src/skillify/stage-memory.ts +++ b/src/skillify/stage-memory.ts @@ -95,6 +95,11 @@ export interface ClaudeSpawnPlan { shell: boolean; /** Prompt to write to stdin, or null when it's passed as a positional arg. */ stdinInput: string | null; + /** + * CREATE_NO_WINDOW: this worker is detached and console-less, so without it + * Windows pops a visible console window for the claude child. No-op on POSIX. + */ + windowsHide: boolean; } /** @@ -115,6 +120,7 @@ export function planClaudeSpawn(inv: ReturnType): stdio: [stdinInput !== null ? "pipe" : "ignore", "ignore", "ignore"], shell, stdinInput, + windowsHide: inv.options.windowsHide === true, }; } @@ -132,6 +138,7 @@ function runClaude(claudeBin: string, prompt: string, timeoutMs: number): Promis env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, timeout: timeoutMs, shell: plan.shell, + windowsHide: plan.windowsHide, }); child.on("error", () => resolve(false)); if (plan.stdinInput !== null) child.stdin?.end(plan.stdinInput); diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts new file mode 100644 index 00000000..3159c3c9 --- /dev/null +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Source-level guards for the inner summarizer-CLI spawns that do NOT go + * through the wiki-worker-spawn builders (those are unit-asserted in + * wiki-worker-windows.test.ts). Every one of these runs inside a detached, + * console-less worker, so without `windowsHide: true` Windows pops a visible + * console window for the claude/codex child. These call sites `spawn`/ + * `execFileSync` a real binary, so a unit test cannot observe the options + * object — hence the scoped source guard (same approach as the hermes + * wiki-worker and skillify gate-runner guards). + * + * Each regex ties `windowsHide: true` to the specific spawn/exec call + * ([^)]* = no closing paren between the call open and the option) so the guard + * fails if the option ever drifts out of that spawn. + */ +function src(rel: string): string { + return readFileSync(join(process.cwd(), rel), "utf-8"); +} + +describe("inner CLI spawn windowsHide — source guards", () => { + it("mine-local worker spawns the summarizer claude -p with windowsHide", () => { + expect(src("src/commands/mine-local.ts")).toMatch(/spawn\(\s*opts\.bin[^)]*windowsHide:\s*true/); + }); + + it("advisor gate spawns claude with windowsHide", () => { + expect(src("src/skillify/advisor.ts")).toMatch(/spawn\(\s*claudeBin[^)]*windowsHide:\s*true/); + }); + + it("claude-model judge/proposer spawn passes windowsHide", () => { + expect(src("src/skillify/claude-model.ts")).toMatch(/spawn\(\s*findAgentBin\([^;]*windowsHide:\s*true/); + }); + + it("commit-kpi-extract detached CLI spawn passes windowsHide", () => { + expect(src("src/hooks/commit-kpi-extract.ts")).toMatch(/spawn\(\s*cli\.bin[^)]*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... + expect(s).toMatch(/windowsHide:\s*inv\.options\.windowsHide\s*===\s*true/); + // ...and the spawn call applies it. + expect(s).toMatch(/spawn\(\s*plan\.file[^)]*windowsHide:\s*plan\.windowsHide/); + }); +}); diff --git a/tests/claude-code/skillify-gate-runner.test.ts b/tests/claude-code/skillify-gate-runner.test.ts index 9a477d85..e0b7984f 100644 --- a/tests/claude-code/skillify-gate-runner.test.ts +++ b/tests/claude-code/skillify-gate-runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runGate, buildArgs, findAgentBin, type Agent } from "../../src/skillify/gate-runner.js"; @@ -33,6 +33,19 @@ describe.skipIf(process.platform === "win32")("runGate spawn (POSIX)", () => { }); }); +describe("gate-runner spawn options", () => { + it("passes windowsHide so the gate CLI never pops a console window on Windows", () => { + // The skillify worker is detached and console-less; without windowsHide + // on the inner CLI spawn, Windows allocates a visible console window. + // Source-level guard because the dispatch tests exec a real /usr/bin/echo + // and can't observe the options object. + // Scoped to the runChildProcess call ([^)]* = no closing paren in + // between) so the guard fails if the option drifts out of the spawn. + const src = readFileSync(join(process.cwd(), "src/skillify/gate-runner.ts"), "utf-8"); + expect(src).toMatch(/runChildProcess\([^)]*windowsHide:\s*true/); + }); +}); + describe("findAgentBin", () => { it("returns a path for each known agent (PATH lookup or fallback)", () => { for (const agent of ["claude_code", "codex", "cursor", "hermes", "pi"] as Agent[]) { diff --git a/tests/claude-code/wiki-worker-windows.test.ts b/tests/claude-code/wiki-worker-windows.test.ts index e35ee39e..90219e53 100644 --- a/tests/claude-code/wiki-worker-windows.test.ts +++ b/tests/claude-code/wiki-worker-windows.test.ts @@ -184,3 +184,49 @@ describe("buildTrailingPromptInvocation (codex / cursor / pi)", () => { expect(inv.args).toEqual([...FLAGS, "PROMPT-TEXT"]); }); }); + +describe("windowsHide — no visible console window for the summarizer CLI", () => { + // The wiki worker is spawned detached and console-less (spawn-detached.ts + // sets windowsHide on the worker itself). Without CREATE_NO_WINDOW on the + // INNER spawn too, Windows allocates a fresh visible console window titled + // after the CLI exe (users reported a bare "claude.exe" window popping up). + it("buildClaudeInvocation sets windowsHide on every branch", () => { + setPlatform("win32"); + expect(buildClaudeInvocation("C:\\npm\\claude.cmd", "P").options.windowsHide).toBe(true); + expect(buildClaudeInvocation("C:\\pf\\claude.exe", "P").options.windowsHide).toBe(true); + setPlatform("linux"); + expect(buildClaudeInvocation("/usr/local/bin/claude", "P").options.windowsHide).toBe(true); + }); + + it("buildTrailingPromptInvocation sets windowsHide on every branch", () => { + const FLAGS = ["exec"]; + setPlatform("win32"); + expect(buildTrailingPromptInvocation("C:\\npm\\codex.cmd", FLAGS, "P").options.windowsHide).toBe(true); + expect(buildTrailingPromptInvocation("C:\\pf\\codex.exe", FLAGS, "P").options.windowsHide).toBe(true); + setPlatform("linux"); + expect(buildTrailingPromptInvocation("/usr/local/bin/codex", FLAGS, "P").options.windowsHide).toBe(true); + }); + + // buildStdinPromptInvocation feeds whole-file prompts over stdin for the doc + // REFRESH/GENERATE path (refresh-llm.ts runHostPrompt execFileSyncs it), so + // it is another inner summarizer-CLI spawn that must not pop a console window. + it("buildStdinPromptInvocation sets windowsHide on the .cmd (shell) branch", () => { + setPlatform("win32"); + expect(buildStdinPromptInvocation("C:\\npm\\codex.cmd", ["-"], "P").options.windowsHide).toBe(true); + expect(buildStdinPromptInvocation("C:\\npm\\codex.cmd", ["-"], "P").options.shell).toBe(true); + }); + + it("buildStdinPromptInvocation sets windowsHide on the .exe / Unix branch", () => { + setPlatform("win32"); + expect(buildStdinPromptInvocation("C:\\pf\\codex.exe", ["-"], "P").options.windowsHide).toBe(true); + setPlatform("linux"); + expect(buildStdinPromptInvocation("/usr/local/bin/codex", ["-"], "P").options.windowsHide).toBe(true); + }); + + it("buildClaudeStdinInvocation inherits windowsHide from the stdin builder", () => { + setPlatform("win32"); + expect(buildClaudeStdinInvocation("C:\\npm\\claude.cmd", "P").options.windowsHide).toBe(true); + setPlatform("linux"); + expect(buildClaudeStdinInvocation("/usr/local/bin/claude", "P").options.windowsHide).toBe(true); + }); +}); diff --git a/tests/hermes/hermes-wiki-worker-source.test.ts b/tests/hermes/hermes-wiki-worker-source.test.ts index c36b82f1..68151f30 100644 --- a/tests/hermes/hermes-wiki-worker-source.test.ts +++ b/tests/hermes/hermes-wiki-worker-source.test.ts @@ -26,6 +26,14 @@ describe("hermes wiki-worker source", () => { expect(WORKER_SRC).not.toMatch(/"--dangerously-bypass-approvals-and-sandbox"/); }); + it("hermes spawn passes windowsHide (no visible console window on Windows)", () => { + // hermes is the only worker that calls execFileSync directly instead of + // going through the wiki-worker-spawn builders, so it needs its own guard. + // Scoped to the hermesBin call ([^)]* = no closing paren in between) so + // the guard fails if the option drifts out of the spawn. + expect(WORKER_SRC).toMatch(/execFileSync\(\s*cfg\.hermesBin[^)]*windowsHide:\s*true/); + }); + it("config carries hermesBin + hermesProvider + hermesModel (not codexBin)", () => { expect(WORKER_SRC).toContain("hermesBin: string"); expect(WORKER_SRC).toContain("hermesProvider: string"); diff --git a/tests/shared/agent-model.test.ts b/tests/shared/agent-model.test.ts index 7995d2ea..5dc4409f 100644 --- a/tests/shared/agent-model.test.ts +++ b/tests/shared/agent-model.test.ts @@ -4,9 +4,9 @@ import { agentModel, detectScorerAgent } from "../../src/skillify/agent-model.js /** A fake child process that emits `stdout` then closes with `code`, and records argv. */ function fakeSpawn(stdout: string, code = 0) { - const calls: Array<{ bin: string; args: string[]; env: Record }> = []; + const calls: Array<{ bin: string; args: string[]; env: Record; opts: Record }> = []; const spawnImpl = (bin: string, args: string[], opts: Record) => { - calls.push({ bin, args, env: (opts.env as Record) ?? {} }); + calls.push({ bin, args, env: (opts.env as Record) ?? {}, opts }); const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void }; child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); @@ -32,6 +32,8 @@ describe("agentModel — per-agent no-tools dispatch", () => { // isolation env always set expect(calls[0].env.HIVEMIND_CAPTURE).toBe("false"); expect(calls[0].env.HIVEMIND_WIKI_WORKER).toBe("1"); + // no console window on Windows: the scorer runs inside a detached worker + expect(calls[0].opts.windowsHide).toBe(true); }); it("claude_code proposer defaults to a capable model (sonnet)", async () => {