diff --git a/packages/cli/package.json b/packages/cli/package.json index ddfcc949c928..11368ef7e526 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,13 @@ "./run": "./src/run/index.ts", "./server-process": "./src/server-process.ts" }, + "imports": { + "#hidden-console": { + "bun": "./src/services/hidden-console.bun.ts", + "node": "./src/services/hidden-console.node.ts", + "default": "./src/services/hidden-console.bun.ts" + } + }, "scripts": { "build": "bun run script/build.ts", "build:node": "bun run script/build-node.ts", diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index cce3a2c53b99..4b6e7686d9c0 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -12,6 +12,7 @@ import { PersistentPty } from "@opencode/schema/persistent-pty" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" +import { HiddenConsole } from "./services/hidden-console" import { RetainedImage } from "./services/retained-image" import { ServiceRegistration } from "./services/service-registration" import { WebUi } from "./services/web-ui" @@ -52,7 +53,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { Effect.mapError(() => new Error("Invalid PTY restart handoff")), ) const global = yield* Global.Service - if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home)) + if (options.mode === "service") { + yield* Effect.sync(() => process.chdir(global.home)) + const status = yield* Effect.sync(() => HiddenConsole.attach()) + if (status !== undefined) yield* Effect.logWarning("could not attach a hidden console", { status }) + } return yield* Effect.scoped( Effect.gen(function* () { const foreground = options.mode === "default" diff --git a/packages/cli/src/services/hidden-console.bun.ts b/packages/cli/src/services/hidden-console.bun.ts new file mode 100644 index 000000000000..2307d0809aa5 --- /dev/null +++ b/packages/cli/src/services/hidden-console.bun.ts @@ -0,0 +1,30 @@ +import { dlopen, ptr } from "bun:ffi" +import os from "node:os" + +// STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE +const standardHandles = [-10, -11, -12] + +/** + * A detached service starts without a console, so Windows opens a visible window for every + * console child spawned without CREATE_NO_WINDOW (plugin code, hooks, npm's git). Allocating a + * windowless console gives those children one to inherit instead. + * + * Returns the failing HRESULT, or undefined when the console was attached or is unsupported. + */ +export function attach() { + if (process.platform !== "win32") return + // AllocConsoleWithOptions requires Windows 11 24H2 / Server 2025 (build 26100). + if (Number(os.release().split(".")[2]) < 26100) return + const kernel32 = dlopen("kernel32.dll", { + AllocConsoleWithOptions: { args: ["ptr", "ptr"], returns: "i32" }, + GetStdHandle: { args: ["i32"], returns: "i64" }, + SetStdHandle: { args: ["i32", "i64"], returns: "i32" }, + }) + // Allocation may point the standard handles at the new console; keep the pipes the spawner reads. + const handles = standardHandles.map((id) => [id, kernel32.symbols.GetStdHandle(id)] as const) + // ALLOC_CONSOLE_OPTIONS { mode: ALLOC_CONSOLE_MODE_NO_WINDOW, useShowWindow: FALSE, showWindow: 0 } + const status = kernel32.symbols.AllocConsoleWithOptions(ptr(new Int32Array([2, 0, 0])), null) + handles.forEach(([id, handle]) => kernel32.symbols.SetStdHandle(id, handle)) + kernel32.close() + return status === 0 ? undefined : status +} diff --git a/packages/cli/src/services/hidden-console.node.ts b/packages/cli/src/services/hidden-console.node.ts new file mode 100644 index 000000000000..d50d4b257f0e --- /dev/null +++ b/packages/cli/src/services/hidden-console.node.ts @@ -0,0 +1,4 @@ +// The Node build has no console binding yet, so its service keeps the console-less behavior. +export function attach(): number | undefined { + return undefined +} diff --git a/packages/cli/src/services/hidden-console.ts b/packages/cli/src/services/hidden-console.ts new file mode 100644 index 000000000000..dc2e50b92c59 --- /dev/null +++ b/packages/cli/src/services/hidden-console.ts @@ -0,0 +1 @@ +export * as HiddenConsole from "#hidden-console"