Skip to content

fix(cli): give the Windows service a hidden console - #52594

Draft
neriousy wants to merge 1 commit into
v2from
windows-hidden-console
Draft

neriousy wants to merge 1 commit into
v2from
windows-hidden-console

Conversation

@neriousy

@neriousy neriousy commented Oct 1, 2026

Copy link
Copy Markdown
Member

Issue for this PR

Closes #51887
Closes #50868
Refs #42440, #45259

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Problem. Clients start the shared service with detached: true (packages/client/src/service-contender.ts). On Windows that is DETACHED_PROCESS, so the service has no console. Every console program it starts without CREATE_NO_WINDOW (windowsHide) then gets a new visible console window (a conhost window or a Windows Terminal tab that steals focus).

Core's own spawns already pass windowsHide (cross-spawn-spawner.ts). The reports come from processes we don't control:

In v1 the server ran inside the TUI's console, so all of these were invisible. That's also why --standalone makes the flashing disappear. Adding windowsHide to the service spawn (#45259) doesn't work, because Windows ignores CREATE_NO_WINDOW when it's combined with DETACHED_PROCESS. Dropping detached doesn't work either: libuv puts non-detached children in a kill-on-close job, so the service would die with the CLI.

Fix. When running as serve --service on Windows, the service calls AllocConsoleWithOptions(ALLOC_CONSOLE_MODE_NO_WINDOW) at startup, before anything is spawned. The service now has a console with no window, and processes it starts share it instead of allocating their own. This covers plugins, hooks, MCP launch chains and npm's git calls without each call site having to set windowsHide. Desktop starts the service through serve --service too, so it's covered.

  • The standard handles are saved before the call and restored after it. That keeps the stderr pipe the client reads startup errors from.
  • Only on Windows 11 24H2 / Server 2025 (build 26100+), where AllocConsoleWithOptions exists. Older builds skip it and keep today's behavior. The older fallback (AllocConsole + hide) would likely flash once at startup, and with Windows Terminal as the default terminal it could leave a visible tab, so I left it out.
  • The Node build (opencode-node) gets a no-op through the #hidden-console import, the same way packages/core handles #pty.

How did you verify your code works?

  • bun typecheck in packages/cli
  • bun test test/service.test.ts test/import-boundaries.test.ts test/standalone.test.ts in packages/cli
  • Checked that Bun resolves #hidden-console to the Bun file and Node to the no-op file; on macOS the call is a no-op

Not yet verified on Windows. This is a draft until someone tests it on Windows 11 24H2+:

  1. opencode service stop
  2. Start the dev CLI so it starts its own service
  3. Configure a plugin that spawns without windowsHide (omo, rtk.ts) and/or an unpinned git plugin such as superpowers@git+https://github.com/obra/superpowers.git
  4. Run shell tool calls and start a local MCP server
  5. Expect no console windows, and no could not attach a hidden console warning in the service log

Screenshots / recordings

N/A

Checklist

  • I have tested my changes locally (Windows run still pending)
  • I have not included unrelated changes in this PR

@mlalpho

mlalpho commented Oct 2, 2026

Copy link
Copy Markdown

confirmed this is fixed by this PR on my windows 26100.8328

Cheers! 🍻

@neriousy

neriousy commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Hi @mlalpho - how did you test it? I have a hard time reproducing the windows popping up

@mlalpho

mlalpho commented Oct 2, 2026

Copy link
Copy Markdown

hi @neriousy , i was hitting pretty regularly so didn't have trouble.

watching conhost fires:

powershell -ExecutionPolicy Bypass -File watch-conhost.ps1 -Seconds 300
# Watch for new conhost.exe processes (visible console windows) while testing
# the OpenCode desktop app. Run this in a PowerShell window, then exercise the
# app (agent commands, Code Mode tool calls). New conhost entries with a real
# parent chain indicate a console window was allocated. ConPTY sessions also
# create conhost.exe entries; the parent/command line distinguishes them.
#
# Usage: powershell -ExecutionPolicy Bypass -File watch-conhost.ps1 -Seconds 180

param(
  [int]$Seconds = 180
)

$ErrorActionPreference = "SilentlyContinue"
$query = "SELECT * FROM __InstanceCreationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_Process' AND TargetInstance.Name LIKE 'conhost%'"

Write-Host "Watching for new conhost.exe processes for $Seconds seconds..."
Write-Host "Exercise the app now (run agent commands, Code Mode tool calls)."
Write-Host ""

$watcher = New-Object System.Management.ManagementEventWatcher($query)
$count = 0
$sw = [System.Diagnostics.Stopwatch]::StartNew()

while ($sw.Elapsed.TotalSeconds -lt $Seconds) {
  try {
    $event = $watcher.WaitForNextEvent()
    $p = $event.TargetInstance
    $count++
    $parentPid = $p.ParentProcessId
    $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$parentPid"
    $parentName = "<pid $parentPid exited>"
    if ($parent) { $parentName = $parent.Name + " (" + $parent.CommandLine + ")" }
    $cmd = $p.CommandLine
    if (-not $cmd) { $cmd = "" }
    if ($cmd.Length -gt 160) { $cmd = $cmd.Substring(0, 160) + "..." }
    Write-Host ("[{0}] conhost pid={1}" -f (Get-Date -Format "HH:mm:ss.fff"), $p.ProcessId)
    Write-Host ("        parent: {0}" -f $parentName)
    Write-Host ("        cmd:    {0}" -f $cmd)
  } catch [System.Management.ManagementException] {
    # query poll timeout; continue
  }
}

Write-Host ""
Write-Host ("Done. Total new conhost.exe processes observed: {0}" -f $count)
Write-Host "Notes:"
Write-Host " - conhost with parent opencode-cli.exe/opencode.exe + no '--headless' can be a visible flash (bug)"
Write-Host " - conhost parents under \WindowsApps or with ConPTY handles are usually hidden pseudoconsoles"

This is the prompt i sent to the session while running the opencode beta build i made from this pr's commit:

running prompt A/B (on production and beta and watching conhost log and checking for popups before and after:

Run this diagnostic sequence exactly, in order, and report the result of each step.
Do not skip or batch steps.

1. Run this shell command: git -C <folder> status --porcelain
2. Run this shell command: node --version
3. Run this shell command: curl --version
4. Using Code Mode execute the commands
   "git rev-parse HEAD", "git log --oneline -3", and "node -e \"console.log(2+2)\""
5. Read <folder>\README.md using <custom v2 tool>
6. Run an <custom tool> thru <opencode v2 plugin>
7. Run "git -C <folder> diff --stat" five separate times
8. Summarize each step's outcome in one line each

@neriousy

neriousy commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

But this doesn't help me. it doesnt pop up windows for me. Do you use any plugins?

@mlalpho

mlalpho commented Oct 2, 2026 •

Copy link
Copy Markdown

yes, in this test it loaded 3 plugins that work on v2. only two plugin tools 'custom file reading tool' and 'custom shell tool' were exercised by this prompt. the rest were via native tool calls or code mode.

i'm rerunning on project that doesn't load plugins locally.

@mlalpho

mlalpho commented Oct 2, 2026

Copy link
Copy Markdown

well dang, I can't reproduce without these plugins loaded (empty project folder) ... i can still confirm the fix works. it looks like i might be seeing a tool.execute.before hook that spawns a cmd.exe wrapper with node's execSync

@neriousy

neriousy commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

welcome to my world lol! Like I feel like I'm onto somethig but not quite

@mlalpho

mlalpho commented Oct 2, 2026

Copy link
Copy Markdown

good luck ! i'll let you if i find anything more concrete. looking forward to this fix lol

@neriousy

neriousy commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

you can try cleaning your .opencode/plugins directories maybe that will do smth

@mlalpho

mlalpho commented Oct 2, 2026 •

Copy link
Copy Markdown

I found the underlying script that the plugin was running. the call site for execSync needed an explicit 'windowsHide: true' .. which is what this PR fixes at a lower level. at the moment its up to plugin developers to play whack-a-mole with windowsHide params and exec*Sync calls (or similar spawn sites)

This particular script was called by the tool.execute.before hook so it looked like almost all tools were causing.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants