Skip to content
Open
7 changes: 7 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ win:
extraResources:
- from: native/computer-use-helper/build/blitz-cu-helper.exe
to: blitz-cu-helper.exe
# The supermux ConPTY session-host: the Windows terminal backend that conpty-host.mjs drives over
# host_rpc. resolveSessionHostBin looks for it at process.resourcesPath/bin/session-host.exe, so it
# maps to bin/session-host.exe here. Built by dist-win.ps1 (cargo --release, windows-gnu) into
# vendor/bin/session-host.exe. Self-contained: ldd shows only system DLLs (ucrtbase etc.), no MinGW
# runtime, so it runs on a clean Win10 1809+ box with no extra install.
- from: vendor/bin/session-host.exe
to: bin/session-host.exe
# cross-platform analytics config; skipped when absent (local/fork builds)
- from: build-config/activity-logging.json
to: activity-logging.json
Expand Down
27 changes: 27 additions & 0 deletions scripts/dist-win.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ Write-Host "[dist-win] building native CU helper"
& (Join-Path $root 'native\computer-use-helper\build-win.ps1')
if ($LASTEXITCODE -ne 0) { throw "[dist-win] helper build failed ($LASTEXITCODE)" }

# The Windows terminal backend: the supermux ConPTY session-host (conpty-host.mjs drives it over host_rpc).
# Bundled via win.extraResources at vendor/bin/session-host.exe (resolveSessionHostBin reads it at
# process.resourcesPath/bin/session-host.exe). It is a VENDORED binary (like vendor/bin/tmux on mac): a
# checkout already carries it, so a plain build just uses it. To REFRESH from source, set $env:SUPERMUX_REPO
# to the supermux repo root and re-run. Self-contained (ldd: only system DLLs, no MinGW runtime).
$sessionHost = Join-Path $root 'vendor\bin\session-host.exe'
if ($env:SUPERMUX_REPO) {
Write-Host "[dist-win] building supermux session-host from $env:SUPERMUX_REPO"
Push-Location (Join-Path $env:SUPERMUX_REPO 'server')
cargo build --release --bin session-host --target x86_64-pc-windows-gnu
$built = Join-Path (Get-Location).Path 'target\x86_64-pc-windows-gnu\release\session-host.exe'
Pop-Location
if (-not (Test-Path $built)) { throw "[dist-win] session-host build produced no exe at $built" }
New-Item -ItemType Directory -Force (Split-Path $sessionHost) | Out-Null
Copy-Item $built $sessionHost -Force
Write-Host "[dist-win] vendored session-host.exe ($([math]::Round((Get-Item $sessionHost).Length/1MB,1)) MB)"
} elseif (-not (Test-Path $sessionHost)) {
throw "[dist-win] vendor\bin\session-host.exe missing and SUPERMUX_REPO not set. Set `$env:SUPERMUX_REPO to the supermux repo root to build it."
} else {
Write-Host "[dist-win] using vendored vendor\bin\session-host.exe (set `$env:SUPERMUX_REPO to refresh from source)"
}

# PREREQUISITE (Windows): Git for Windows must be installed on the END-USER machine. The agent runtime is
# POSIX (claude runs wait.sh / curl / tail / cat via its Bash tool), and Claude Code makes Git Bash optional
# (it falls back to the PowerShell tool, which cannot run that runtime). conpty-host pins the agent to Git
# Bash (CLAUDE_CODE_GIT_BASH_PATH); document Git for Windows in the install instructions.

Write-Host "[dist-win] electron-vite build"
npm run build
if ($LASTEXITCODE -ne 0) { throw "[dist-win] electron-vite build failed ($LASTEXITCODE)" }
Expand Down
46 changes: 44 additions & 2 deletions src/main/agent-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,48 @@ export function buildAgentCommand({ runtime = AGENT_RUNTIME_CLAUDE, cmd, claudeS
return buildClaudeCommand({ cmd: cmd || 'claude', claudeSid, mode, bootstrapFile, effort, hooks })
}

/** Resolve a POSIX shell (Git Bash) on Windows. The agent command + runtime are POSIX-shaped, so on win32
* the ConPTY terminal host (which runs a program directly, no shell) must launch them under bash. Order:
* BLITZ_BASH_BIN, then the standard Git-for-Windows install paths. null = not found. Cached. */
let resolvedBash
function resolveBashBin() {
if (resolvedBash !== undefined) return resolvedBash
const cands = [
process.env.BLITZ_BASH_BIN,
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe'
].filter(Boolean)
resolvedBash = cands.find((p) => { try { return existsSync(p) } catch { return false } }) || null
return resolvedBash
}

/** Windows: wrap a POSIX agent command so the ConPTY terminal host can run it. The command uses
* "$(cat bootstrap)" substitution and the agent then runs bash/curl/tail/wait.sh, none of which the bare
* CreateProcessW path provides. Write the command to a per-session launcher script and return a
* `bash <script>` command (TWO clean tokens, so the host's whitespace/quote tokenizer can't mangle it);
* `exec` makes the agent the pane process. Validated on Windows: MSYS `cat` reads the backslash bootstrap
* path and claude.exe resolves under Git Bash. No-op off win32; if Git Bash is absent the command passes
* through unchanged (it fails visibly rather than being silently mangled). */
function wrapAgentCommandForPlatform(command, sessionsDir, id) {
if (process.platform !== 'win32') return command
const bash = resolveBashBin()
if (!bash) return command
// Git Bash treats backslashes as escapes in an unquoted token, so an absolute `exec C:\...\claude.exe`
// collapses to `C:...claude.exe: not found`. Convert the Windows paths in the command to forward slashes,
// which MSYS accepts everywhere (the exec target AND the $(cat <bootstrap>) path). The only backslashes
// in the command are Windows paths; the args (UUID, --settings JSON) have none.
const posix = command.replace(/\\/g, '/')
const launch = join(sessionDir(sessionsDir, id), 'launch.sh')
// Pin the agent's OWN Bash tool to Git Bash: claude makes Git for Windows optional (it falls back to
// the PowerShell tool, which can't run the POSIX agent runtime - wait.sh / bash / tail / cat). Exported
// here so the exec'd claude.exe inherits them. Single-quote the bash path so its backslashes survive.
// Source: code.claude.com/docs/en/setup (CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_POWERSHELL_TOOL).
const pin = `export CLAUDE_CODE_GIT_BASH_PATH='${bash}'\nexport CLAUDE_CODE_USE_POWERSHELL_TOOL=0\n`
try { writeFileSync(launch, `#!/usr/bin/env bash\n${pin}exec ${posix}\n`) } catch { return command }
return `"${bash}" "${launch.replace(/\\/g, '/')}"`
}

/** Has claude ALREADY created this conversation on disk? claude writes `<configDir>/projects/<encoded-cwd>/
* <session-id>.jsonl` (encoded-cwd = the workspace path with every `/` and `.` turned into `-`; we don't
* relocate CLAUDE_CONFIG_DIR, so configDir defaults to ~/.claude). The session-id is a UUID, so a hit is
Expand Down Expand Up @@ -283,14 +325,14 @@ export function prepareAgentLaunch({ sessionsDir, id, url, cmd, runtime = AGENT_
agentSessionId,
claudeSessionId: claudeState.claudeSessionId,
established: claudeState.established, // surfaced so the re-exec path persists the (possibly rotated) id + correct established flag
command: buildAgentCommand({
command: wrapAgentCommandForPlatform(buildAgentCommand({
runtime: agentRuntime,
cmd: cmd || (agentRuntime === AGENT_RUNTIME_CODEX_SERVERLESS ? 'codex' : 'claude'),
claudeSid: claudeState.claudeSessionId,
mode: claudeState.established ? 'resume' : 'create',
bootstrapFile: file,
effort
})
}), sessionsDir, id)
}
}

Expand Down
Loading