diff --git a/.changeset/fix-windows-console-flash.md b/.changeset/fix-windows-console-flash.md new file mode 100644 index 0000000000..add10cd490 --- /dev/null +++ b/.changeset/fix-windows-console-flash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix commands flashing an empty console window on Windows. diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index 3a4dbf7340..484f0b0905 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -1,4 +1,4 @@ -import type { ChildProcess } from 'node:child_process'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; import { appendFile, @@ -37,6 +37,20 @@ function cycleKey(s: { dev: number; ino: number }): string | null { return `${String(s.dev)}:${String(s.ino)}`; } +export function buildLocalSpawnOptions( + isWindows: boolean, + cwd: string, + env: Record | undefined, +): SpawnOptions { + return { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: !isWindows, + windowsHide: true, + }; +} + class LocalProcess implements KaosProcess { readonly stdin: Writable; readonly stdout: Readable; @@ -544,16 +558,11 @@ export class LocalKaos implements Kaos { throw new Error('LocalKaos.exec(): at least one argument (the command to run) is required.'); } const restArgs = args.slice(1); - const child = spawn(command, restArgs, { - cwd: this._cwd, - stdio: ['pipe', 'pipe', 'pipe'], - // POSIX `detached:true` makes the child a process-group leader so - // `LocalProcess.kill()` can signal the entire tree. No-op on Windows - // (`taskkill /T` handles the tree there). We do not call `child.unref()` - // because the parent still waits on the child's exit through `wait()`. - detached: !isWindows, - env: this._buildExecEnv(), - }); + const child = spawn( + command, + restArgs, + buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv()), + ); await waitForSpawn(child); return new LocalProcess(child); } @@ -566,12 +575,11 @@ export class LocalKaos implements Kaos { ); } const restArgs = args.slice(1); - const child = spawn(command, restArgs, { - cwd: this._cwd, - stdio: ['pipe', 'pipe', 'pipe'], - detached: !isWindows, - env: this._buildExecEnv(env), - }); + const child = spawn( + command, + restArgs, + buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv(env)), + ); await waitForSpawn(child); return new LocalProcess(child); } diff --git a/packages/kaos/test/spawn-options.test.ts b/packages/kaos/test/spawn-options.test.ts new file mode 100644 index 0000000000..3213722802 --- /dev/null +++ b/packages/kaos/test/spawn-options.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { buildLocalSpawnOptions } from '#/local'; + +// Regression coverage for the "every command pops an empty console window on +// Windows" bug. `child_process.spawn` defaults `windowsHide` to `false`; on +// Windows that makes Node allocate a *visible* console for each child process +// the agent spawns through `BashTool` → `LocalKaos.exec`/`execWithEnv`. The +// fix is to pass `windowsHide: true`. The flag is only observable on Windows, +// so we assert the spawn options builder directly. + +describe('buildLocalSpawnOptions (Windows console-window regression)', () => { + it('sets windowsHide:true on Windows so commands do not flash a console', () => { + const options = buildLocalSpawnOptions(true, 'C:\\repo', undefined); + expect(options.windowsHide).toBe(true); + }); + + it('sets windowsHide:true on POSIX too (it is ignored there, kept unconditional)', () => { + const options = buildLocalSpawnOptions(false, '/repo', undefined); + expect(options.windowsHide).toBe(true); + }); + + it('keeps detached platform-conditional (POSIX tree-kill vs Windows taskkill /T)', () => { + expect(buildLocalSpawnOptions(true, 'C:\\repo', undefined).detached).toBe(false); + expect(buildLocalSpawnOptions(false, '/repo', undefined).detached).toBe(true); + }); + + it('pipes stdin/stdout/stderr and forwards cwd + env', () => { + const env = { FOO: 'bar' }; + const options = buildLocalSpawnOptions(false, '/repo', env); + expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']); + expect(options.cwd).toBe('/repo'); + expect(options.env).toBe(env); + }); +});