diff --git a/.changeset/fix-hooks-windows-console-popup.md b/.changeset/fix-hooks-windows-console-popup.md new file mode 100644 index 000000000..46f8b18c7 --- /dev/null +++ b/.changeset/fix-hooks-windows-console-popup.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix console windows flashing on Windows each time a hook runs. diff --git a/packages/agent-core/src/session/hooks/runner.ts b/packages/agent-core/src/session/hooks/runner.ts index 509c48f15..d31e180d8 100644 --- a/packages/agent-core/src/session/hooks/runner.ts +++ b/packages/agent-core/src/session/hooks/runner.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'node:child_process'; import { z } from 'zod'; @@ -11,6 +11,24 @@ export interface RunHookOptions { readonly signal?: AbortSignal; } +export function buildHookSpawnOptions(options: { + cwd?: string; + env?: Readonly>; +}): SpawnOptionsWithoutStdio { + return { + shell: true, + cwd: options.cwd, + stdio: 'pipe', + detached: process.platform !== 'win32', + // Hide the console Windows would otherwise allocate for the shell child. + // Without `windowsHide:true`, each hook flashes a visible console window — + // the same regression the Bash tool path already guards against in KAOS + // (see `buildLocalSpawnOptions`). Unconditional: it is a no-op on POSIX. + windowsHide: true, + env: options.env ? { ...process.env, ...options.env } : undefined, + }; +} + const DEFAULT_TIMEOUT_SECONDS = 30; const KILL_GRACE_MS = 100; const OptionalStringSchema = z.preprocess( @@ -46,13 +64,7 @@ export async function runHook( ): Promise { let child: ChildProcessWithoutNullStreams; try { - child = spawn(command, { - shell: true, - cwd: options.cwd, - stdio: 'pipe', - detached: process.platform !== 'win32', - env: options.env ? { ...process.env, ...options.env } : undefined, - }); + child = spawn(command, buildHookSpawnOptions({ cwd: options.cwd, env: options.env })); } catch (error) { return allowResult({ stderr: errorMessage(error) }); } diff --git a/packages/agent-core/test/hooks/runner.test.ts b/packages/agent-core/test/hooks/runner.test.ts index d2eda557c..ff3c33b97 100644 --- a/packages/agent-core/test/hooks/runner.test.ts +++ b/packages/agent-core/test/hooks/runner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { buildHookSpawnOptions } from '../../src/session/hooks/runner'; + const RUNNER_MODULE = '../../src/session/hooks/runner' as string; interface HookResult { @@ -98,3 +100,27 @@ describe('runHook process runner', () => { expect(result.stdout?.trim()).toBe('WriteFile'); }); }); + +// Regression coverage for the "every hook flashes an empty console window on +// Windows" bug. With `shell:true` and no `windowsHide`, Node allocates a +// visible console for each hook child process on Windows. The fix is to pass +// `windowsHide:true` (mirrors KAOS' `buildLocalSpawnOptions` and the runner's +// own taskkill spawn). The flag is only observable on Windows, so we assert +// the spawn options builder directly. +describe('buildHookSpawnOptions (Windows console-window regression)', () => { + it('sets windowsHide:true so hooks do not flash a console on Windows', () => { + expect(buildHookSpawnOptions({}).windowsHide).toBe(true); + }); + + it('runs through the shell with stdio piped', () => { + const options = buildHookSpawnOptions({}); + expect(options.shell).toBe(true); + expect(options.stdio).toBe('pipe'); + }); + + it('merges hook env onto process.env and forwards cwd', () => { + const options = buildHookSpawnOptions({ cwd: '/repo', env: { FOO: 'bar' } }); + expect(options.cwd).toBe('/repo'); + expect(options.env).toMatchObject({ FOO: 'bar' }); + }); +});