From 98b73f74cbce26a95836c428cba00d33addc0f3b Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 30 Jul 2026 13:09:59 +0100 Subject: [PATCH] Hide console windows for every background child the daemon spawns The earlier fix (linking the desktop-bundled daemon as a Windows GUI-subsystem binary) stopped Electron's spawn of relay.exe itself from flashing a console. It did not stop relay.exe's OWN child spawns from doing the same thing one level down, and made the risk worse in one sense: now that the daemon has no console of its own to inherit, any console-subsystem child it starts without CREATE_NO_WINDOW gets a fresh one auto-allocated by the Windows loader. Nothing in this codebase set SysProcAttr.HideWindow anywhere. Go's exec.Command never hides a child's window on its own; that field has to be set explicitly per spawn, and every spawn here was missing it. This affected every background child the daemon starts on its own, tied to real session activity rather than app launch, which is why it showed up later and only once at a time: running the provider CLI to do the actual task, every git snapshot taken during a session or handoff, the detection scan's process-listing probe, and verification commands after a task completes. internal/process already had the right shape for this: SetupChildProcess (Windows) configures SysProcAttr for the agent CLI child claude.go and stub.go spawn, so HideWindow was added there directly. For the simpler one-shot commands elsewhere (git in worktree.go/durability.go, the detection scan's powershell probe, verify.go's cmd /C branch) that don't need SetupChildProcess's process-group/Job Object machinery, a lighter internal/process.HideWindow(cmd) does the same for just that one spawn. Unix builds get a no-op, since only Windows auto-allocates a console for a spawned console-subsystem process with no console to inherit. Deliberately NOT touched: cmd/relay/providers.go's openInTerminal, which opens the account sign-in terminal on purpose and must stay visible, and cmd/relay/tui.go's spawns, which already inherit the TUI's own console in normal interactive use. Every changed call site captures output through Go-managed pipes (StdoutPipe/CombinedOutput/ Output), never console passthrough, so hiding the window cannot break output capture in either the desktop app or CLI/TUI daemon mode. Full daemon suite green (143 tests, 22 packages), go vet clean. --- .../internal/detect/process_windows.go | 3 +++ packages/daemon-go/internal/fsm/durability.go | 4 ++++ .../daemon-go/internal/process/job_unix.go | 6 +++++ .../daemon-go/internal/process/job_windows.go | 23 +++++++++++++++++-- packages/daemon-go/internal/verify/verify.go | 3 +++ .../daemon-go/internal/worktree/worktree.go | 4 ++++ 6 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/daemon-go/internal/detect/process_windows.go b/packages/daemon-go/internal/detect/process_windows.go index 1f75ad8..302f83b 100644 --- a/packages/daemon-go/internal/detect/process_windows.go +++ b/packages/daemon-go/internal/detect/process_windows.go @@ -11,6 +11,8 @@ import ( "encoding/json" "os/exec" "time" + + "github.com/dbisina/relay/internal/process" ) type procInfo struct { @@ -29,6 +31,7 @@ func listProcesses() ([]procInfo, error) { // Force array output so a single-process result still parses as JSON array. const ps = `@(Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine) | ConvertTo-Json -Compress -Depth 3` cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", ps) + process.HideWindow(cmd) out, err := cmd.Output() if err != nil { return nil, err diff --git a/packages/daemon-go/internal/fsm/durability.go b/packages/daemon-go/internal/fsm/durability.go index d698a41..f680ce6 100644 --- a/packages/daemon-go/internal/fsm/durability.go +++ b/packages/daemon-go/internal/fsm/durability.go @@ -20,6 +20,8 @@ import ( "strconv" "strings" "time" + + "github.com/dbisina/relay/internal/process" ) const ( @@ -258,6 +260,7 @@ func (d *DurabilityManager) checkDrift(files []string) error { func (d *DurabilityManager) git(args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = d.workDir + process.HideWindow(cmd) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, bytes.TrimSpace(out)) @@ -272,6 +275,7 @@ func (d *DurabilityManager) gitOutput(args ...string) (string, error) { func (d *DurabilityManager) gitOutputRaw(args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = d.workDir + process.HideWindow(cmd) out, err := cmd.Output() if err != nil { return "", err diff --git a/packages/daemon-go/internal/process/job_unix.go b/packages/daemon-go/internal/process/job_unix.go index 81422c8..f8bb63d 100644 --- a/packages/daemon-go/internal/process/job_unix.go +++ b/packages/daemon-go/internal/process/job_unix.go @@ -22,6 +22,12 @@ func SetupChildProcess(cmd *exec.Cmd) error { return nil } +// HideWindow is a no-op on Unix: only Windows auto-allocates a console +// window for a spawned console-subsystem process with no console to inherit. +func HideWindow(cmd *exec.Cmd) { + _ = cmd +} + // AssignToJobObject is a no-op on Unix. func AssignToJobObject(cmd *exec.Cmd) error { return nil diff --git a/packages/daemon-go/internal/process/job_windows.go b/packages/daemon-go/internal/process/job_windows.go index f3ffab9..c8b2fe4 100644 --- a/packages/daemon-go/internal/process/job_windows.go +++ b/packages/daemon-go/internal/process/job_windows.go @@ -49,17 +49,36 @@ func initGlobalJob() { } // SetupChildProcess configures SysProcAttr so the child starts in a new -// process group (prevents Ctrl-C propagation) and can be assigned to the -// Job Object after Start(). +// process group (prevents Ctrl-C propagation), never gets a console window +// of its own, and can be assigned to the Job Object after Start(). +// +// HideWindow matters beyond cosmetics: since the desktop app's bundled +// daemon is itself linked with no console (a Windows GUI-subsystem binary, +// so Electron spawning it headlessly can't flash one either), any +// console-subsystem child it starts, the provider CLI this configures, +// would otherwise get a fresh console window auto-allocated by the loader +// because there is none to inherit. CREATE_NO_WINDOW stops that. func SetupChildProcess(cmd *exec.Cmd) error { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &windows.SysProcAttr{} } // New process group: Ctrl-C in the terminal does not kill the agent. cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP + cmd.SysProcAttr.HideWindow = true return nil } +// HideWindow prevents a background one-shot command (a git snapshot, a +// detection-scan probe, a verification step) from getting a console window, +// for callers that only need that and not the rest of SetupChildProcess's +// process-group / Job Object machinery meant for a long-lived agent. +func HideWindow(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &windows.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} + // AssignToJobObject must be called immediately after cmd.Start(). // It attaches the new process to the global kill-on-close Job Object. func AssignToJobObject(cmd *exec.Cmd) error { diff --git a/packages/daemon-go/internal/verify/verify.go b/packages/daemon-go/internal/verify/verify.go index af4dde6..cab1276 100644 --- a/packages/daemon-go/internal/verify/verify.go +++ b/packages/daemon-go/internal/verify/verify.go @@ -15,6 +15,8 @@ import ( "runtime" "strings" "time" + + "github.com/dbisina/relay/internal/process" ) // Check is the result of running one acceptance command. @@ -78,6 +80,7 @@ func Run(ctx context.Context, workDir string, cmds []string) Result { c = exec.CommandContext(cctx, "sh", "-c", cmd) } c.Dir = workDir + process.HideWindow(c) var buf bytes.Buffer c.Stdout = &buf c.Stderr = &buf diff --git a/packages/daemon-go/internal/worktree/worktree.go b/packages/daemon-go/internal/worktree/worktree.go index 9908829..c6bcb83 100644 --- a/packages/daemon-go/internal/worktree/worktree.go +++ b/packages/daemon-go/internal/worktree/worktree.go @@ -16,6 +16,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/dbisina/relay/internal/process" ) // Manager — owns one worktree per session. @@ -176,6 +178,7 @@ func (m *Manager) ApplyDirtySnapshot(worktreePath string, snap *DirtySnapshot) e cmd := exec.Command("git", "-c", "core.autocrlf=false", "apply", "--whitespace=nowarn", "-") cmd.Dir = worktreePath cmd.Stdin = strings.NewReader(snap.Patch) + process.HideWindow(cmd) var out bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &out if err := cmd.Run(); err != nil { @@ -198,6 +201,7 @@ func (m *Manager) ApplyDirtySnapshot(worktreePath string, snap *DirtySnapshot) e func (m *Manager) git(args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = m.RepoDir + process.HideWindow(cmd) var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out