Merge pull request #2145 from code-yeongyu/fix/issue-1915-windows-spawn-hide

fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
This commit is contained in:
YeonGyu-Kim 2026-02-26 23:12:57 +09:00 committed by GitHub
commit decff3152a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 136 additions and 31 deletions

View File

@ -1,4 +1,5 @@
import { getConfigDir } from "./config-context" import { getConfigDir } from "./config-context"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60 const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> { export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try { try {
const proc = Bun.spawn(["bun", "install"], { const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(), cwd: getConfigDir(),
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",

View File

@ -1,4 +1,5 @@
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types" import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { initConfigContext } from "./config-context" import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> { async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) { for (const binary of OPENCODE_BINARIES) {
try { try {
const proc = Bun.spawn([binary, "--version"], { const proc = spawnWithWindowsHide([binary, "--version"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })

View File

@ -3,6 +3,7 @@ import { createRequire } from "node:module"
import { dirname, join } from "node:path" import { dirname, join } from "node:path"
import type { DependencyInfo } from "../types" import type { DependencyInfo } from "../types"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try { try {
@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getBinaryVersion(binary: string): Promise<string | null> { async function getBinaryVersion(binary: string): Promise<string | null> {
try { try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" }) const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text() const output = await new Response(proc.stdout).text()
await proc.exited await proc.exited
if (proc.exitCode === 0) { if (proc.exitCode === 0) {
@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
path: resolvedPath, path: resolvedPath,
} }
} }

View File

@ -1,6 +1,7 @@
import { existsSync } from "node:fs" import { existsSync } from "node:fs"
import { homedir } from "node:os" import { homedir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
import { OPENCODE_BINARIES } from "../constants" import { OPENCODE_BINARIES } from "../constants"
@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
): Promise<string | null> { ): Promise<string | null> {
try { try {
const command = buildVersionCommand(binaryPath, platform) const command = buildVersionCommand(binaryPath, platform)
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" }) const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text() const output = await new Response(processResult.stdout).text()
await processResult.exited await processResult.exited

View File

@ -1,3 +1,5 @@
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
export interface GhCliInfo { export interface GhCliInfo {
installed: boolean installed: boolean
version: string | null version: string | null
@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getGhVersion(): Promise<string | null> { async function getGhVersion(): Promise<string | null> {
try { try {
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" }) const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text() const output = await new Response(processResult.stdout).text()
await processResult.exited await processResult.exited
if (processResult.exitCode !== 0) return null if (processResult.exitCode !== 0) return null
@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
error: string | null error: string | null
}> { }> {
try { try {
const processResult = Bun.spawn(["gh", "auth", "status"], { const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" }, env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },

View File

@ -3,6 +3,7 @@ import type { RunResult } from "./types"
import { createJsonOutputManager } from "./json-output" import { createJsonOutputManager } from "./json-output"
import { resolveSession } from "./session-resolver" import { resolveSession } from "./session-resolver"
import { executeOnCompleteHook } from "./on-complete-hook" import { executeOnCompleteHook } from "./on-complete-hook"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import type { OpencodeClient } from "./types" import type { OpencodeClient } from "./types"
import * as originalSdk from "@opencode-ai/sdk" import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils" import * as originalPortUtils from "../../shared/port-utils"
@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" }) const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
// then // then
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`) expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(mockClient.session.get).toHaveBeenCalledWith({ expect(mockClient.session.get).toHaveBeenCalledWith({
path: { id: sessionId }, path: { id: sessionId },
query: { directory: "/test" }, query: { directory: "/test" },
@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
beforeEach(() => { beforeEach(() => {
spyOn(console, "error").mockImplementation(() => {}) spyOn(console, "error").mockImplementation(() => {})
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({ spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0), exited: Promise.resolve(0),
exitCode: 0, exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>) stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
}) })
afterEach(() => { afterEach(() => {
@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
// then // then
expect(spawnSpy).toHaveBeenCalledTimes(1) expect(spawnSpy).toHaveBeenCalledTimes(1)
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn> const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123") expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0") expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000") expect(options?.env?.DURATION_MS).toBe("5000")
@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
spyOn(console, "error").mockImplementation(() => {}) spyOn(console, "error").mockImplementation(() => {})
mockStdout = createMockWriteStream() mockStdout = createMockWriteStream()
mockStderr = createMockWriteStream() mockStderr = createMockWriteStream()
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({ spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0), exited: Promise.resolve(0),
exitCode: 0, exitCode: 0,
} as unknown as ReturnType<typeof Bun.spawn>) stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
}) })
afterEach(() => { afterEach(() => {
@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
const emitted = mockStdout.writes[0]! const emitted = mockStdout.writes[0]!
expect(() => JSON.parse(emitted)).not.toThrow() expect(() => JSON.parse(emitted)).not.toThrow()
expect(spawnSpy).toHaveBeenCalledTimes(1) expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn> const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo done"]) expect(args).toEqual(["sh", "-c", "echo done"])
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn> const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.SESSION_ID).toBe("session-123") expect(options?.env?.SESSION_ID).toBe("session-123")
expect(options?.env?.EXIT_CODE).toBe("0") expect(options?.env?.EXIT_CODE).toBe("0")
expect(options?.env?.DURATION_MS).toBe("5000") expect(options?.env?.DURATION_MS).toBe("5000")

View File

@ -1,4 +1,5 @@
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test" import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
import { executeOnCompleteHook } from "./on-complete-hook" import { executeOnCompleteHook } from "./on-complete-hook"
describe("executeOnCompleteHook", () => { describe("executeOnCompleteHook", () => {
@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
return { return {
exited: Promise.resolve(exitCode), exited: Promise.resolve(exitCode),
exitCode, exitCode,
} as unknown as ReturnType<typeof Bun.spawn> stdout: undefined,
stderr: undefined,
kill: () => {},
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
} }
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">> let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
it("executes command with correct env vars", async () => { it("executes command with correct env vars", async () => {
// given // given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try { try {
// when // when
@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
// then // then
expect(spawnSpy).toHaveBeenCalledTimes(1) expect(spawnSpy).toHaveBeenCalledTimes(1)
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn> const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(args).toEqual(["sh", "-c", "echo test"]) expect(args).toEqual(["sh", "-c", "echo test"])
expect(options?.env?.SESSION_ID).toBe("session-123") expect(options?.env?.SESSION_ID).toBe("session-123")
@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
it("env var values are strings", async () => { it("env var values are strings", async () => {
// given // given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try { try {
// when // when
@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
}) })
// then // then
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn> const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
expect(options?.env?.EXIT_CODE).toBe("1") expect(options?.env?.EXIT_CODE).toBe("1")
expect(options?.env?.EXIT_CODE).toBeTypeOf("string") expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
it("empty command string is no-op", async () => { it("empty command string is no-op", async () => {
// given // given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try { try {
// when // when
@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
it("whitespace-only command is no-op", async () => { it("whitespace-only command is no-op", async () => {
// given // given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
try { try {
// when // when
@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
it("command failure logs warning but does not throw", async () => { it("command failure logs warning but does not throw", async () => {
// given // given
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1)) const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
try { try {
// when // when
await expect( expect(
executeOnCompleteHook({ executeOnCompleteHook({
command: "false", command: "false",
sessionId: "session-123", sessionId: "session-123",
@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
it("spawn error logs warning but does not throw", async () => { it("spawn error logs warning but does not throw", async () => {
// given // given
const spawnError = new Error("Command not found") const spawnError = new Error("Command not found")
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
throw spawnError throw spawnError
}) })
try { try {
// when // when
await expect( expect(
executeOnCompleteHook({ executeOnCompleteHook({
command: "nonexistent-command", command: "nonexistent-command",
sessionId: "session-123", sessionId: "session-123",

View File

@ -1,4 +1,5 @@
import pc from "picocolors" import pc from "picocolors"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
export async function executeOnCompleteHook(options: { export async function executeOnCompleteHook(options: {
command: string command: string
@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`)) console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
try { try {
const proc = Bun.spawn(["sh", "-c", trimmedCommand], { const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
env: { env: {
...process.env, ...process.env,
SESSION_ID: sessionId, SESSION_ID: sessionId,

View File

@ -1,4 +1,5 @@
import { delimiter, dirname, join } from "node:path" import { delimiter, dirname, join } from "node:path"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
export async function canExecuteBinary(binaryPath: string): Promise<boolean> { export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
try { try {
const proc = Bun.spawn([binaryPath, "--version"], { const proc = spawnWithWindowsHide([binaryPath, "--version"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })

View File

@ -6,6 +6,7 @@ import {
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
import type { InteractiveBashSessionState } from "./types"; import type { InteractiveBashSessionState } from "./types";
import { subagentSessions } from "../../features/claude-code-session-state"; import { subagentSessions } from "../../features/claude-code-session-state";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
type AbortSession = (args: { path: { id: string } }) => Promise<unknown> type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
@ -19,7 +20,7 @@ async function killAllTrackedSessions(
): Promise<void> { ): Promise<void> {
for (const sessionName of state.tmuxSessions) { for (const sessionName of state.tmuxSessions) {
try { try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })

View File

@ -1,6 +1,7 @@
import type { InteractiveBashSessionState } from "./types"; import type { InteractiveBashSessionState } from "./types";
import { loadInteractiveBashSessionState } from "./storage"; import { loadInteractiveBashSessionState } from "./storage";
import { OMO_SESSION_PREFIX } from "./constants"; import { OMO_SESSION_PREFIX } from "./constants";
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState { export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
if (!sessionStates.has(sessionID)) { if (!sessionStates.has(sessionID)) {
@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
): Promise<void> { ): Promise<void> {
for (const sessionName of state.tmuxSessions) { for (const sessionName of state.tmuxSessions) {
try { try {
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}); });

View File

@ -0,0 +1,84 @@
import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { Readable } from "node:stream"
export interface SpawnOptions {
cwd?: string
env?: Record<string, string | undefined>
stdin?: "pipe" | "inherit" | "ignore"
stdout?: "pipe" | "inherit" | "ignore"
stderr?: "pipe" | "inherit" | "ignore"
}
export interface SpawnedProcess {
readonly exitCode: number | null
readonly exited: Promise<number>
readonly stdout: ReadableStream<Uint8Array> | undefined
readonly stderr: ReadableStream<Uint8Array> | undefined
kill(signal?: NodeJS.Signals): void
}
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
if (!stream) {
return undefined
}
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
}
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
let resolveExited: (exitCode: number) => void
let exitCode: number | null = null
const exited = new Promise<number>((resolve) => {
resolveExited = resolve
})
proc.on("exit", (code) => {
exitCode = code ?? 1
resolveExited(exitCode)
})
proc.on("error", () => {
if (exitCode === null) {
exitCode = 1
resolveExited(1)
}
})
return {
get exitCode() {
return exitCode
},
exited,
stdout: toReadableStream(proc.stdout),
stderr: toReadableStream(proc.stderr),
kill(signal?: NodeJS.Signals): void {
try {
if (!signal) {
proc.kill()
return
}
proc.kill(signal)
} catch {}
},
}
}
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
if (process.platform !== "win32") {
return bunSpawn(command, options)
}
const [cmd, ...args] = command
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
windowsHide: true,
shell: true,
})
return wrapNodeProcess(proc)
}

View File

@ -1,4 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants" import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver" import { getCachedTmuxPath } from "./tmux-path-resolver"
@ -89,7 +90,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.` The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
} }
const proc = Bun.spawn([tmuxPath, ...parts], { const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })