Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/commands/mine-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/commit-kpi-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/hermes/wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ async function main(): Promise<void> {
"--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
Expand Down
17 changes: 13 additions & 4 deletions src/hooks/wiki-worker-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
}

Expand All @@ -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 },
};
}

Expand All @@ -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 } : {}),
},
};
Expand Down
4 changes: 4 additions & 0 deletions src/skillify/advisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ function runAdvisorGate(prompt: string, claudeBin: string): Promise<string> {
], {
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 = "";
Expand Down
1 change: 1 addition & 0 deletions src/skillify/agent-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
4 changes: 4 additions & 0 deletions src/skillify/claude-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
3 changes: 3 additions & 0 deletions src/skillify/gate-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
7 changes: 7 additions & 0 deletions src/skillify/stage-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -115,6 +120,7 @@ export function planClaudeSpawn(inv: ReturnType<typeof buildClaudeInvocation>):
stdio: [stdinInput !== null ? "pipe" : "ignore", "ignore", "ignore"],
shell,
stdinInput,
windowsHide: inv.options.windowsHide === true,
};
}

Expand All @@ -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);
Expand Down
47 changes: 47 additions & 0 deletions tests/claude-code/inner-cli-spawn-windowshide-source.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
15 changes: 14 additions & 1 deletion tests/claude-code/skillify-gate-runner.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[]) {
Expand Down
46 changes: 46 additions & 0 deletions tests/claude-code/wiki-worker-windows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 8 additions & 0 deletions tests/hermes/hermes-wiki-worker-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 4 additions & 2 deletions tests/shared/agent-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }> = [];
const calls: Array<{ bin: string; args: string[]; env: Record<string, unknown>; opts: Record<string, unknown> }> = [];
const spawnImpl = (bin: string, args: string[], opts: Record<string, unknown>) => {
calls.push({ bin, args, env: (opts.env as Record<string, unknown>) ?? {} });
calls.push({ bin, args, env: (opts.env as Record<string, unknown>) ?? {}, opts });
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
Expand All @@ -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 () => {
Expand Down