fix(kaos): hide console window for spawned commands on Windows (#957)

- add windowsHide:true to spawn options so commands do not flash a console on Windows
- extract buildLocalSpawnOptions helper shared by exec and execWithEnv
- add regression coverage for the Windows console-window behavior
This commit is contained in:
Haozhe 2026-06-22 15:34:18 +08:00 committed by GitHub
parent 42237392dd
commit b57fc905fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 17 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix commands flashing an empty console window on Windows.

View file

@ -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<string, string> | 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);
}

View file

@ -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);
});
});