Skip to content

fix(cli): attach windowless console to detached Windows service - #60471

Open
Hona wants to merge 3 commits into
devfrom
hide-windows-console
Open

Hona wants to merge 3 commits into
devfrom
hide-windows-console

Conversation

@Hona

@Hona Hona commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Fixes #52266
Closes #59598
Refs #56704

  • Attach a windowless console to serve --service on Windows 11 24H2+ (packages/cli/src/services/hidden-console.bun.ts, packages/cli/src/server-process.ts): spawnServiceContender launches opencode serve --service with detached: true (DETACHED_PROCESS), leaving the service with no attached console (AttachConsole fails with ERROR_INVALID_HANDLE). While Core's CrossSpawnSpawner sets windowsHide: true (CREATE_NO_WINDOW), in-process server plugins and package helpers calling child_process.execSync, Bun.spawn, or Bun.$ (which exposes no windowsHide option) trigger a visible CASCADIA_HOSTING_WINDOW_CLASS / ConsoleWindowClass window and EVENT_SYSTEM_FOREGROUND focus steal per spawn under opencode-cli.exe's PID. Calling AllocConsoleWithOptions(ALLOC_CONSOLE_MODE_NO_WINDOW) after incumbent discovery gives the service a windowless console (GetConsoleWindow() === 0) that unhidden children inherit silently.
  • Preserve attached terminals, standard handles, and fallback scope (packages/cli/src/services/hidden-console.bun.ts, packages/cli/src/services/hidden-console.node.ts): Skip allocation when GetConsoleProcessList reports an already-attached console (such as interactive opencode serve --service in a terminal), save and restore STD_INPUT_HANDLE / STD_OUTPUT_HANDLE / STD_ERROR_HANDLE around allocation so Service.ensure's startup error pipe remains intact, wrap attachment in Effect.try so FFI failures never abort service startup, and keep Windows builds < 26100 (where AllocConsoleWithOptions is absent) and the Node SEA build on existing console-less behavior.
  • Follow bun:ffi pointer guidance (packages/cli/src/services/hidden-console.bun.ts): Declare the ALLOC_CONSOLE_OPTIONS and process-list out-params as "buffer" and pass the TypedArray itself, so the engine reads the address off the live view at call time instead of a ptr() number that can dangle when JSC relocates a small typed array's storage (Make bun:ffi ptr function safer oven-sh/bun#9094, Typed-array reads after a bun:ffi call return stale values once the calling function is JIT-compiled (DFG load elimination across native calls) oven-sh/bun#32054). HANDLE values travel as u64, per the Bun docs' Windows note.
flowchart LR
  Ensure["Service.ensure<br/>DETACHED_PROCESS (no console)"] --> Incumbent{"Incumbent<br/>running?"}
  Incumbent -- "yes" --> Exit["Exit contender"]
  Incumbent -- "no (Win11 26100+)" --> Alloc["AllocConsoleWithOptions<br/>ALLOC_CONSOLE_MODE_NO_WINDOW<br/>+ restore STD_*_HANDLE"]
  Alloc --> Core["CrossSpawnSpawner<br/>CREATE_NO_WINDOW → HWND 0x0"]
  Alloc --> Plugin["In-process Plugin / Bun.$ / execSync<br/>Inherits service console → HWND 0x0"]
Loading
const kernel32 = dlopen("kernel32.dll", {
  AllocConsoleWithOptions: { args: ["buffer", "ptr"], returns: "i32" },
  GetConsoleProcessList: { args: ["buffer", "u32"], returns: "u32" },
  GetStdHandle: { args: ["i32"], returns: "u64" },
  SetStdHandle: { args: ["i32", "u64"], returns: "i32" },
})
const attached = new Uint32Array(1)
if (kernel32.symbols.GetConsoleProcessList(attached, attached.length) > 0) return undefined
const handles = standardHandles.map((id) => [id, kernel32.symbols.GetStdHandle(id)] as const)
const options = new Int32Array([2, 0, 0])
const status = kernel32.symbols.AllocConsoleWithOptions(options, null)
if (status !== 0) return status
handles.forEach(([id, handle]) => {
  if (handle !== invalidHandle) kernel32.symbols.SetStdHandle(id, handle)
})
Caller inside opencode serve --service (Win11 10.0.26300) dev (SetWinEventHook events) PR (+ HiddenConsole)
Core CrossSpawnSpawner (git, rg, powershell, .cmd shims) 0 (HWND 0x0) 0 (HWND 0x0)
Local stdio MCP (.cmd / .exe, plus child execSync("git")) 0 (HWND 0x0) 0 (HWND 0x0)
Server plugin child_process.execSync("git ...") SHOW + FOREGROUND (CASCADIA_HOSTING_WINDOW_CLASS) 0 (HWND 0x0 inherited)
Server plugin Bun.spawn(["git", ...]) (default) SHOW (visible = 1) 0 (HWND 0x0 inherited)
Server plugin Bun.$`git ...` (no windowsHide API) CREATE (ConsoleWindowClass) 0 (HWND 0x0 inherited)
Before After
Before: unhidden plugin spawns inside console-less service flash Windows Terminal and steal foreground focus After: service attaches ALLOC_CONSOLE_MODE_NO_WINDOW so unhidden plugin and Bun.$ spawns inherit HWND 0x0 silently

Visual

<svg viewBox="0 0 240 72" width="240" height="72" fill="none">
  <style>
    .pkt1 { animation: flow 3.2s ease-in-out infinite; }
    .pkt2 { animation: flow 3.2s ease-in-out 1.1s infinite; }
    .shield { animation: pulse 3.2s ease-in-out infinite; }
    @keyframes flow {
      0%, 10% { transform: translateX(0); opacity: 0; }
      20% { opacity: 0.9; }
      75% { transform: translateX(74px); opacity: 0.9; }
      90%, 100% { transform: translateX(74px); opacity: 0; }
    }
    @keyframes pulse {
      0%, 100% { stroke-opacity: 0.45; }
      50% { stroke-opacity: 0.9; }
    }
  </style>
  <rect x="14" y="16" width="68" height="40" rx="4" fill="currentColor" fill-opacity="0.06" stroke="currentColor" stroke-opacity="0.5" />
  <rect x="22" y="25" width="38" height="3.5" rx="1.75" fill="currentColor" fill-opacity="0.85" />
  <rect x="22" y="33" width="50" height="3" rx="1.5" fill="currentColor" fill-opacity="0.4" />
  <rect x="22" y="41" width="30" height="6" rx="2" fill="currentColor" fill-opacity="0.14" stroke="currentColor" stroke-opacity="0.45" stroke-dasharray="2 2" />
  <line x1="82" y1="36" x2="156" y2="36" stroke="currentColor" stroke-opacity="0.25" stroke-width="1.25" stroke-dasharray="3 3" />
  <circle class="pkt1" cx="84" cy="36" r="2.5" fill="currentColor" />
  <circle class="pkt2" cx="84" cy="36" r="2.5" fill="currentColor" />
  <rect class="shield" x="156" y="16" width="70" height="40" rx="4" fill="currentColor" fill-opacity="0.05" stroke="currentColor" stroke-opacity="0.55" stroke-dasharray="3 2" />
  <rect x="165" y="25" width="44" height="3.5" rx="1.75" fill="currentColor" fill-opacity="0.8" />
  <rect x="165" y="33" width="52" height="3" rx="1.5" fill="currentColor" fill-opacity="0.4" />
  <rect x="165" y="41" width="34" height="3" rx="1.5" fill="currentColor" fill-opacity="0.3" />
</svg>

Added by the opencode bot (AI-generated).

Related: #56704

Copilot AI balanced review requested due to automatic review settings October 30, 2026 04:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach looks sound, but I could not verify it: the bug needs a detached Windows 11 24H2+ process, and the review sandbox runs Linux. I reviewed the code instead, ran test/hidden-console.test.ts (2 pass, 1 Windows-only test skipped) and ran tsc -b for packages/cli (passes).

The allocation only runs in service mode on win32, after the incumbent check. It does nothing when a console is already attached (an interactive terminal, or the Desktop sidecar spawned with CREATE_NO_WINDOW). Restoring the standard handles keeps Service.ensure's stderr pipe intact. #hidden-console follows the same Bun/Node import-map pattern core already uses for #pty.

Public API is unchanged; #hidden-console is an internal import map.

Scope to note: Windows 10 and Windows 11 before 24H2 (build < 26100) still get the flashes from unhidden plugin, Bun.$ and execSync spawns, and so does the Node SEA build. That's worth saying explicitly when closing #52266, because some reporters may be on older builds.

Tests: the two tests that always run only exercise early-return paths (the Node stub returning undefined, and attach() on a non-Windows or attached process). They can be dropped, leaving the Windows probe test.

Related: #56704

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit addresses my earlier point: the two always-on tests that only covered early returns are gone, and the Windows probe now also checks that calling attach() a second time is a no-op. I still couldn't verify the fix itself: it needs Windows 11 24H2+ (build 26100+), and the review sandbox runs Linux. test/hidden-console.test.ts gives 0 failures (its only test is Windows-only and was skipped here), and tsc -b for packages/cli passes.

The other changes look right. Effect.try with a warning log means a failed FFI call can no longer stop the service from starting. Returning before the handle restore when allocation fails is fine, since a failed call shouldn't have replaced the standard handles. An unparsable os.release() now skips allocation.

The public API is unchanged. No new concerns.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit looks correct and adds no new concerns. I couldn't verify it here: it needs Windows 11 24H2+, and the review sandbox runs Linux.

The out-params now use "buffer", so Bun reads the address when the call is made. The handles now travel as u64, and invalidHandle was changed to 0xffff_ffff_ffff_ffffn to match, so restoring the standard handles still skips INVALID_HANDLE_VALUE.

test/hidden-console.test.ts gives 0 failures (its one Windows-only test is skipped here), and tsc -b for packages/cli passes.

The public API is unchanged.

@Zh1uyan

Zh1uyan commented Nov 5, 2026

Copy link
Copy Markdown

Independent confirmation + reproduction data (opencode v2.2.5, Windows 11 build 22000 (21H2), x64).

We maintain oc-manager, a third-party Wails manager that launches opencode serve --service (detached) and connects to it. We instrumented the flashing-window problem with WMI __InstanceCreationEvent process-creation capture plus 60 ms polling of newly-visible top-level windows:

  • While a subagent task runs, pairs of visible ConsoleWindowClass windows (git.exe, python.exe) flash/exit roughly every 5 s; their parent resolves to the detached opencode serve --service process.
  • Observed commands: snapshot git.EXE --git-dir <agentdatas>/snapshot/... ls-files/diff, git status --porcelain, git branch --show-current, shell-tool python <script>.py and pwsh -NoProfile -Command ....
  • Focus steal measured: foreground-window sampling during a child's lifetime lands on the child PID, so each spawn is not merely visual — it activates and steals foreground focus.

This matches the root cause analysis in this PR exactly: oc-manager sets windowsHide/CREATE_NO_WINDOW on every spawn it controls and none of those flash; only opencode's own in-process spawns do. Giving the detached service a windowless console so unhidden children inherit it is the right fix.

One gap to flag: this PR is scoped to builds ≥ 26100 (AllocConsoleWithOptions), keeping current behavior on older builds. Windows 11 21H2 (build 22000) is still in support and reproduces this on every subagent turn. Could the < 26100 fallback allocate a plain AllocConsole() and hide the resulting console window (GetConsoleWindow() != 0 but ShowWindow(SW_HIDE)), so children still inherit a hidden console instead of creating a visible one? Happy to test a build on 22000.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants