From 55f08bd56280c1c227161939ce8be78300fbc3be Mon Sep 17 00:00:00 2001 From: Jlovec Date: Wed, 4 Mar 2026 20:47:16 +0800 Subject: [PATCH] fix(cli): hide windows console windows for runner/claude subprocesses (#242) --- cli/src/claude/sdk/query.ts | 4 +- cli/src/utils/spawnHappyCLI.test.ts | 91 +++++++++++++++++++++++++++++ cli/src/utils/spawnHappyCLI.ts | 8 ++- 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 cli/src/utils/spawnHappyCLI.test.ts diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 725cb3ba..41af155c 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -349,7 +349,9 @@ export function query(config: { env: spawnEnv, // Use shell: false with absolute path from getDefaultClaudeCodePath() // This avoids cmd.exe resolution issues on Windows - shell: false + shell: false, + // Hide transient console windows on Windows when spawning Claude Code + windowsHide: process.platform === 'win32' }) as ChildProcessWithoutNullStreams // Handle stdin diff --git a/cli/src/utils/spawnHappyCLI.test.ts b/cli/src/utils/spawnHappyCLI.test.ts new file mode 100644 index 00000000..584e3099 --- /dev/null +++ b/cli/src/utils/spawnHappyCLI.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SpawnOptions } from 'child_process'; + +const spawnMock = vi.fn((..._args: any[]) => ({ pid: 12345 } as any)); + +vi.mock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { + ...actual, + spawn: spawnMock + }; +}); + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + +function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { + value, + configurable: true + }); +} + +function getSpawnOptionsOrThrow(): SpawnOptions { + expect(spawnMock).toHaveBeenCalledTimes(1); + const firstCall = spawnMock.mock.calls[0] as unknown[] | undefined; + const options = firstCall?.[2] as SpawnOptions | undefined; + if (!options) { + throw new Error('Expected spawn options to be passed as third argument'); + } + return options; +} + +describe('spawnHappyCLI windowsHide behavior', () => { + beforeAll(() => { + if (!originalPlatformDescriptor?.configurable) { + throw new Error('process.platform is not configurable in this runtime'); + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + }); + + it('sets windowsHide=true when platform is win32 and detached=true', async () => { + setPlatform('win32'); + const { spawnHappyCLI } = await import('./spawnHappyCLI'); + + spawnHappyCLI(['runner', 'start-sync'], { + detached: true, + stdio: 'ignore' + }); + + const options = getSpawnOptionsOrThrow(); + expect(options.detached).toBe(true); + expect(options.windowsHide).toBe(true); + }); + + it('does not set windowsHide when platform is win32 but detached is false', async () => { + setPlatform('win32'); + const { spawnHappyCLI } = await import('./spawnHappyCLI'); + + spawnHappyCLI(['runner', 'start-sync'], { + detached: false, + stdio: 'ignore' + }); + + const options = getSpawnOptionsOrThrow(); + expect(options.detached).toBe(false); + expect('windowsHide' in options).toBe(false); + }); + + it('does not set windowsHide on non-win32 even when detached=true', async () => { + setPlatform('linux'); + const { spawnHappyCLI } = await import('./spawnHappyCLI'); + + spawnHappyCLI(['runner', 'start-sync'], { + detached: true, + stdio: 'ignore' + }); + + const options = getSpawnOptionsOrThrow(); + expect(options.detached).toBe(true); + expect('windowsHide' in options).toBe(false); + }); +}); diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 12557872..89b91817 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -105,5 +105,11 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child } } - return spawn(spawnCommand, spawnArgs, options); + // On Windows, detached processes allocate a new console window by default. + // windowsHide: true suppresses this to prevent cmd windows from accumulating. + const finalOptions: SpawnOptions = { ...options }; + if (process.platform === 'win32' && options.detached) { + finalOptions.windowsHide = true; + } + return spawn(spawnCommand, spawnArgs, finalOptions); }