diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index f24e77fa2..6b3225547 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -1,4 +1,5 @@ import { getConfigDir } from "./config-context" +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" const BUN_INSTALL_TIMEOUT_SECONDS = 60 const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 @@ -16,7 +17,7 @@ export async function runBunInstall(): Promise { export async function runBunInstallWithDetails(): Promise { try { - const proc = Bun.spawn(["bun", "install"], { + const proc = spawnWithWindowsHide(["bun", "install"], { cwd: getConfigDir(), stdout: "inherit", stderr: "inherit", diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index 6d889faee..6fb140403 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -1,4 +1,5 @@ import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types" +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" import { initConfigContext } from "./config-context" const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const @@ -11,7 +12,7 @@ interface OpenCodeBinaryResult { async function findOpenCodeBinaryWithVersion(): Promise { for (const binary of OPENCODE_BINARIES) { try { - const proc = Bun.spawn([binary, "--version"], { + const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe", }) diff --git a/src/cli/doctor/checks/dependencies.ts b/src/cli/doctor/checks/dependencies.ts index da22afcfb..f6f6ded01 100644 --- a/src/cli/doctor/checks/dependencies.ts +++ b/src/cli/doctor/checks/dependencies.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module" import { dirname, join } from "node:path" import type { DependencyInfo } from "../types" +import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide" async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { try { @@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat async function getBinaryVersion(binary: string): Promise { 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() await proc.exited if (proc.exitCode === 0) { @@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise { path: resolvedPath, } } - diff --git a/src/cli/doctor/checks/system-binary.ts b/src/cli/doctor/checks/system-binary.ts index 670d7ce1e..5a4d48126 100644 --- a/src/cli/doctor/checks/system-binary.ts +++ b/src/cli/doctor/checks/system-binary.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide" import { OPENCODE_BINARIES } from "../constants" @@ -110,7 +111,7 @@ export async function getOpenCodeVersion( ): Promise { try { 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() await processResult.exited diff --git a/src/cli/doctor/checks/tools-gh.ts b/src/cli/doctor/checks/tools-gh.ts index a9ac59a91..177b5c160 100644 --- a/src/cli/doctor/checks/tools-gh.ts +++ b/src/cli/doctor/checks/tools-gh.ts @@ -1,3 +1,5 @@ +import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide" + export interface GhCliInfo { installed: boolean version: string | null @@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat async function getGhVersion(): Promise { 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() await processResult.exited if (processResult.exitCode !== 0) return null @@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{ error: string | null }> { try { - const processResult = Bun.spawn(["gh", "auth", "status"], { + const processResult = spawnWithWindowsHide(["gh", "auth", "status"], { stdout: "pipe", stderr: "pipe", env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" }, diff --git a/src/cli/run/integration.test.ts b/src/cli/run/integration.test.ts index d0fc91cfb..6ac16c9f8 100644 --- a/src/cli/run/integration.test.ts +++ b/src/cli/run/integration.test.ts @@ -3,6 +3,7 @@ import type { RunResult } from "./types" import { createJsonOutputManager } from "./json-output" import { resolveSession } from "./session-resolver" import { executeOnCompleteHook } from "./on-complete-hook" +import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide" import type { OpencodeClient } from "./types" import * as originalSdk from "@opencode-ai/sdk" import * as originalPortUtils from "../../shared/port-utils" @@ -147,7 +148,7 @@ describe("integration: --session-id", () => { const result = resolveSession({ client: mockClient, sessionId, directory: "/test" }) // then - await expect(result).rejects.toThrow(`Session not found: ${sessionId}`) + expect(result).rejects.toThrow(`Session not found: ${sessionId}`) expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: sessionId }, query: { directory: "/test" }, @@ -161,10 +162,13 @@ describe("integration: --on-complete", () => { beforeEach(() => { spyOn(console, "error").mockImplementation(() => {}) - spawnSpy = spyOn(Bun, "spawn").mockReturnValue({ + spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({ exited: Promise.resolve(0), exitCode: 0, - } as unknown as ReturnType) + stdout: undefined, + stderr: undefined, + kill: () => {}, + } satisfies ReturnType) }) afterEach(() => { @@ -186,7 +190,7 @@ describe("integration: --on-complete", () => { // then expect(spawnSpy).toHaveBeenCalledTimes(1) - const [_, options] = spawnSpy.mock.calls[0] as Parameters + const [_, options] = spawnSpy.mock.calls[0] as Parameters expect(options?.env?.SESSION_ID).toBe("session-123") expect(options?.env?.EXIT_CODE).toBe("0") expect(options?.env?.DURATION_MS).toBe("5000") @@ -208,10 +212,13 @@ describe("integration: option combinations", () => { spyOn(console, "error").mockImplementation(() => {}) mockStdout = createMockWriteStream() mockStderr = createMockWriteStream() - spawnSpy = spyOn(Bun, "spawn").mockReturnValue({ + spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({ exited: Promise.resolve(0), exitCode: 0, - } as unknown as ReturnType) + stdout: undefined, + stderr: undefined, + kill: () => {}, + } satisfies ReturnType) }) afterEach(() => { @@ -249,9 +256,9 @@ describe("integration: option combinations", () => { const emitted = mockStdout.writes[0]! expect(() => JSON.parse(emitted)).not.toThrow() expect(spawnSpy).toHaveBeenCalledTimes(1) - const [args] = spawnSpy.mock.calls[0] as Parameters + const [args] = spawnSpy.mock.calls[0] as Parameters expect(args).toEqual(["sh", "-c", "echo done"]) - const [_, options] = spawnSpy.mock.calls[0] as Parameters + const [_, options] = spawnSpy.mock.calls[0] as Parameters expect(options?.env?.SESSION_ID).toBe("session-123") expect(options?.env?.EXIT_CODE).toBe("0") expect(options?.env?.DURATION_MS).toBe("5000") diff --git a/src/cli/run/on-complete-hook.test.ts b/src/cli/run/on-complete-hook.test.ts index e560cc10c..930651a2d 100644 --- a/src/cli/run/on-complete-hook.test.ts +++ b/src/cli/run/on-complete-hook.test.ts @@ -1,4 +1,5 @@ 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" describe("executeOnCompleteHook", () => { @@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => { return { exited: Promise.resolve(exitCode), exitCode, - } as unknown as ReturnType + stdout: undefined, + stderr: undefined, + kill: () => {}, + } satisfies ReturnType } let consoleErrorSpy: ReturnType> @@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => { it("executes command with correct env vars", async () => { // given - const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) try { // when @@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => { // then expect(spawnSpy).toHaveBeenCalledTimes(1) - const [args, options] = spawnSpy.mock.calls[0] as Parameters + const [args, options] = spawnSpy.mock.calls[0] as Parameters expect(args).toEqual(["sh", "-c", "echo test"]) expect(options?.env?.SESSION_ID).toBe("session-123") @@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => { it("env var values are strings", async () => { // given - const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) try { // when @@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => { }) // then - const [_, options] = spawnSpy.mock.calls[0] as Parameters + const [_, options] = spawnSpy.mock.calls[0] as Parameters expect(options?.env?.EXIT_CODE).toBe("1") expect(options?.env?.EXIT_CODE).toBeTypeOf("string") @@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => { it("empty command string is no-op", async () => { // given - const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) try { // when @@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => { it("whitespace-only command is no-op", async () => { // given - const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0)) + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) try { // when @@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => { it("command failure logs warning but does not throw", async () => { // given - const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1)) + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1)) try { // when - await expect( + expect( executeOnCompleteHook({ command: "false", sessionId: "session-123", @@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => { it("spawn error logs warning but does not throw", async () => { // given const spawnError = new Error("Command not found") - const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => { throw spawnError }) try { // when - await expect( + expect( executeOnCompleteHook({ command: "nonexistent-command", sessionId: "session-123", diff --git a/src/cli/run/on-complete-hook.ts b/src/cli/run/on-complete-hook.ts index 30c585439..b266ca887 100644 --- a/src/cli/run/on-complete-hook.ts +++ b/src/cli/run/on-complete-hook.ts @@ -1,4 +1,5 @@ import pc from "picocolors" +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" export async function executeOnCompleteHook(options: { command: string @@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: { console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`)) try { - const proc = Bun.spawn(["sh", "-c", trimmedCommand], { + const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], { env: { ...process.env, SESSION_ID: sessionId, diff --git a/src/cli/run/opencode-binary-resolver.ts b/src/cli/run/opencode-binary-resolver.ts index a4bbc60c5..1f42486f7 100644 --- a/src/cli/run/opencode-binary-resolver.ts +++ b/src/cli/run/opencode-binary-resolver.ts @@ -1,4 +1,5 @@ import { delimiter, dirname, join } from "node:path" +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] 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 { try { - const proc = Bun.spawn([binaryPath, "--version"], { + const proc = spawnWithWindowsHide([binaryPath, "--version"], { stdout: "pipe", stderr: "pipe", }) diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts index 428d6bbaa..20db3906a 100644 --- a/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts +++ b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts @@ -6,6 +6,7 @@ import { import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; import type { InteractiveBashSessionState } from "./types"; import { subagentSessions } from "../../features/claude-code-session-state"; +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"; type AbortSession = (args: { path: { id: string } }) => Promise @@ -19,7 +20,7 @@ async function killAllTrackedSessions( ): Promise { for (const sessionName of state.tmuxSessions) { try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], { stdout: "ignore", stderr: "ignore", }) diff --git a/src/hooks/interactive-bash-session/state-manager.ts b/src/hooks/interactive-bash-session/state-manager.ts index e655bfafd..c3a286421 100644 --- a/src/hooks/interactive-bash-session/state-manager.ts +++ b/src/hooks/interactive-bash-session/state-manager.ts @@ -1,6 +1,7 @@ import type { InteractiveBashSessionState } from "./types"; import { loadInteractiveBashSessionState } from "./storage"; import { OMO_SESSION_PREFIX } from "./constants"; +import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"; export function getOrCreateState(sessionID: string, sessionStates: Map): InteractiveBashSessionState { if (!sessionStates.has(sessionID)) { @@ -24,7 +25,7 @@ export async function killAllTrackedSessions( ): Promise { for (const sessionName of state.tmuxSessions) { try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], { stdout: "ignore", stderr: "ignore", }); diff --git a/src/shared/spawn-with-windows-hide.ts b/src/shared/spawn-with-windows-hide.ts new file mode 100644 index 000000000..7da9ed086 --- /dev/null +++ b/src/shared/spawn-with-windows-hide.ts @@ -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 + stdin?: "pipe" | "inherit" | "ignore" + stdout?: "pipe" | "inherit" | "ignore" + stderr?: "pipe" | "inherit" | "ignore" +} + +export interface SpawnedProcess { + readonly exitCode: number | null + readonly exited: Promise + readonly stdout: ReadableStream | undefined + readonly stderr: ReadableStream | undefined + kill(signal?: NodeJS.Signals): void +} + +function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream | undefined { + if (!stream) { + return undefined + } + + return Readable.toWeb(stream as Readable) as ReadableStream +} + +function wrapNodeProcess(proc: ChildProcess): SpawnedProcess { + let resolveExited: (exitCode: number) => void + let exitCode: number | null = null + + const exited = new Promise((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) +} diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index dac46bd60..a0795ee36 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -1,4 +1,5 @@ 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 { 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.` } - const proc = Bun.spawn([tmuxPath, ...parts], { + const proc = spawnWithWindowsHide([tmuxPath, ...parts], { stdout: "pipe", stderr: "pipe", })