Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: Windows

# Fast Windows-only build + publish. The full Release workflow waits on every
# matrix leg (Linux/macOS), and the tty visual test can hang on Linux for the
# full 20m step timeout before pages can deploy. This workflow builds only the
# Windows binary and publishes it to the Pages site so a single platform fix
# can be live-tested in minutes.
#
# Trigger manually:
# gh workflow run windows.yml --ref main
# Or it fires on push to main alongside Release.
#
# The workflow file must exist on the branch you dispatch from (--ref main),
# so merge this to main before the first manual run.

on:
workflow_dispatch:
push:
branches: [main]
paths:
- '.github/workflows/windows.yml'
- 'src/**'
- 'tests/**'
- '*.nimble'
- 'config.nims'

jobs:
build:
runs-on: windows-latest

# git-bash on the Windows runner. The Windows default (pwsh) does not
# propagate non-zero exit codes from native exes like nimble, so a failed
# build/test is swallowed and the job runs on into packaging, which then
# fails for a misleading reason (missing artifact). Same workaround as
# release.yml.
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v4

- uses: jiro4989/setup-nim-action@v2
with:
nim-version: stable
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Install dependencies
run: |
nimble install -y --depsOnly
nimble setup -y
echo '--path:"src"' >> nimble.paths

- name: Compute build flags
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
echo "BUILD_FLAGS=-d:autoUpdate" >> "$GITHUB_ENV"
else
echo "BUILD_FLAGS=-d:versionSuffix=-main-${GITHUB_SHA:0:8}" >> "$GITHUB_ENV"
fi

- name: Build
run: nimble build -y -d:release ${{ env.BUILD_FLAGS }}

- name: Run tests
# The `when defined(windows):` suite in tests/test_streamexec.nim
# asserts the no-bundled-bash contract (resolveBash() == "" and
# runStreamingBash returns 127 on a clean runner, since no installer
# has staged MSYS2 into the bundle path). The POSIX suites guard the
# shared launcher logic both platforms use.
run: nimble test
timeout-minutes: 20

- name: Package (Windows) - bundle OpenSSL DLLs alongside .exe
shell: pwsh
run: |
New-Item -ItemType Directory -Path 3code-windows-amd64 | Out-Null
Copy-Item 3code.exe 3code-windows-amd64/
Copy-Item README.md, LICENSE 3code-windows-amd64/
# Stock Windows ships no OpenSSL. Nim's std/net dlopens libssl /
# libcrypto by hardcoded names; without these DLLs alongside
# 3code.exe, any TLS code path dies with
# could not load: (libcrypto-1_1-x64|libeay64).dll
# Use the canonical Nim Windows DLL bundle from nim-lang.org;
# it also ships a cacert.pem that we point SSL_CERT_FILE at.
Invoke-WebRequest -Uri https://nim-lang.org/download/dlls.zip -OutFile dlls.zip
Expand-Archive -Path dlls.zip -DestinationPath dlls
Copy-Item dlls/libssl-1_1-x64.dll, dlls/libcrypto-1_1-x64.dll, dlls/cacert.pem 3code-windows-amd64/
Compress-Archive -Path 3code-windows-amd64 -DestinationPath 3code-windows-amd64.zip

- uses: actions/upload-artifact@v4
with:
name: 3code-windows-amd64
path: 3code-windows-amd64.zip

pages:
# Deploy on a manual workflow_dispatch (fast Windows-only publish for
# live testing). On push, the full Release workflow owns the site.
needs: build
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}

steps:
- uses: actions/download-artifact@v4
with:
name: 3code-windows-amd64
path: dl

- name: Assemble site
run: |
mkdir -p site/main
cp dl/3code-windows-amd64.zip site/main/ 2>/dev/null || true
cat > site/index.html <<EOF
<!doctype html><meta charset=utf-8><title>3code main builds</title>
<h1>3code main builds</h1>
<p>Per-commit builds from <code>main</code> (commit ${GITHUB_SHA}).</p>
<ul>
EOF
for f in site/main/*; do
[ -f "$f" ] || continue
b=$(basename "$f")
echo " <li><a href=\"main/$b\">$b</a></li>" >> site/index.html
done
cat >> site/index.html <<EOF
</ul>
EOF

- uses: actions/configure-pages@v5

- uses: actions/upload-pages-artifact@v3
with:
path: site

- id: deploy
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions config.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ current = "baseten.glm5"
; SERP, so swap only for a Startpage-compatible mirror.
; search-url = "https://www.startpage.com/do/search?cat=web&q="
; notify = on ; fire a desktop notification when a turn ends (off by default)
; bash_path = "C:\\tools\\msys64\\usr\\bin\\bash.exe" ; Windows only: override bash detection

[provider]
name = "baseten"
Expand Down
137 changes: 137 additions & 0 deletions docs/gitcmd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
.. title:: 3code - bash and unix tools on Windows for coding agents

## bash + unix tools on Windows

This research note covers how a coding agent gets a real bash and the usual
unix toolset on Windows — the equivalent of 3code's
``irm https://3code.capocasa.dev/install.ps1 | iex`` formula, but for the
shell layer rather than the agent itself. Two questions answered: is there a
turnkey one-command install, and does any of this disturb WSL.

### Short answer

There is no single official ``irm ... | iex`` one-liner that drops a complete
bash+unix-tools bundle the way the 3code installer does. But there is a
de facto procedure the agent community has converged on, and the substrate
for it is a private, bundled MinGit/PortableGit. The community-built agents
that already do this well are Hermes (Nous Research) and, to a lesser
extent, mise — both pin their bash via an env var.

### The substrates

Three real options, ranked by suitability for a coding agent.

**MSYS2** — the most complete bundle: full bash, ``pacman``, ripgrep,
coreutils, git. This is the setup OpenAI's Codex Windows guide recommends
(`openai/codex#3580 <https://github.com/openai/codex/discussions/3580>`_).
Strictly better than Git Bash for agent use because the toolset is complete
and ``pacman``-upgradeable.

Not a piped one-liner, but the installer is fully scriptable. Closest
equivalent::

# GUI installer, silent CLI mode -> C:\msys64
.\msys2-x86_64-latest.exe in --confirm-command --accept-messages --root C:/msys64

# Or the self-extracting archive (no GUI integration, functionally identical):
.\msys2-base-x86_64-latest.sfx.exe -y -oC:\

Then ``pacman -Syu`` and ``pacman -S mingw-w64-ucrt-x86_64-ripgrep`` etc.
The sfx archive lives on the msys2-installer GitHub releases, so it's one
PowerShell line away from being a ``irm | iex`` — you'd just be the one
hosting it. There is no off-the-shelf equivalent to copy.

**Git for Windows / Git Bash** — one command via winget::

winget install --id Git.Git -e --source winget

Git Bash is a stripped-down MSYS2 fork: enough bash + git + a minimal set of
coreutils to work, but intentionally lean — no ``pacman``, manual installs
for things like ripgrep via scoop/choco separately. The Codex thread also
notes Git Bash has caused load failures in some IDE agent plugins without
clear errors, where MSYS2 hasn't. For a coding agent, MSYS2 beats Git Bash.

**Private bundled MinGit** — the actual pattern mature agents use. See
below.

### The procedure agents actually use

The pattern, documented explicitly by Hermes and noted as "the same strategy
Claude Code uses": **don't install the full Git-for-Windows or MSYS2 into the
system. Bundle a trimmed PortableGit/MinGit into a private directory and pin
it with an env var.**

Hermes's ``irm | iex`` installer does this, step by step:

1. Downloads the official **MinGit** zip (~45 MB, from git-for-windows
releases) into ``%LOCALAPPDATA%\hermes\git\PortableGit``. No admin, no
registry, no Windows installer.
2. Sets ``HERMES_GIT_BASH_PATH`` to ``...\git\usr\bin\bash.exe`` so fresh
shells find it deterministically.
3. Resolution order on the agent side: env var → its own bundled PortableGit
→ system Git for Windows → MSYS2/Cygwin → anything on PATH as last
resort.

That env var is the whole trick. It sidesteps the classic footgun where
``C:\Windows\System32\bash.exe`` (the WSL launcher) wins ``bash`` resolution
and the agent ends up running inside WSL by accident.

Two gotchas the Hermes docs call out, both worth baking into a custom
``install.ps1``:

- Use the **non-busybox** MinGit (``MinGit-*-64-bit.zip``, not
``*-busybox*``). The busybox build ships ``ash``, not ``bash``, and
coreutils are missing.
- In MinGit's layout, bash lives at ``usr\bin\bash.exe``, **not**
``bin\bash.exe``. Check both.

For the 3code formula this is the cleanest substrate: one PowerShell line,
~45 MB, self-contained, and the bash path is controlled via an env var
rather than fighting system PATH ordering. MSYS2 is more powerful (full
``pacman``, ripgrep, etc.) but heavier and has no turnkey ``irm | iex`` —
that bootstrap would have to be written by hand.

### Does it disturb WSL?

No. They coexist cleanly.

- Git for Windows / MSYS2 install into their own directories (``C:\msys64``,
``C:\Program Files\Git``, or a private ``%LOCALAPPDATA%\...\git``). They
do not touch the WSL distro, the WSL VM, or ``/mnt/c`` interop. Hermes
explicitly documents that native and WSL2 installs "coexist cleanly" with
data in separate roots.
- No admin rights, no registry mutation for the portable variant, no PATH
clobbering of the WSL launcher.

The one real interaction — not damage, just ambiguity — is ``bash``
resolution: ``C:\Windows\System32\bash.exe`` is the WSL launcher, and if an
agent naively calls ``bash`` it can land in WSL instead of the bundled Git
Bash. That is exactly what the ``*_GIT_BASH_PATH`` env-var pin solves. mise
documents the same problem and the same fix (``MISE_BASH_PATH``). So: install
is harmless to WSL; the only thing to get right is making sure the agent
resolves the *right* bash.

If hard isolation is ever wanted, disable WSL's PATH-interop in
``/etc/wsl.conf``::

[interop]
appendWindowsPath = false

That's only relevant if a tool (like mise's shim mode) actually leaks
Windows shims into WSL. For a Git Bash install, not needed.

### Sources

- `MSYS2 installer docs <https://www.msys2.org/docs/installer/>`_ — silent
CLI install forms, sfx archive.
- `openai/codex#3580 <https://github.com/openai/codex/discussions/3580>`_ —
the Codex Windows MSYS2 setup guide; also flags the Git Bash plugin-load
failures and the ``System32\bash.exe`` WSL collision.
- `Hermes Windows (Native) Guide
<https://hermes-agent.nousresearch.com/docs/user-guide/windows-native>`_ —
the private PortableGit + env-var-pin procedure, the MinGit layout and
busybox gotchas, the WSL coexistence statement.
- `mise troubleshooting
<https://mise.jdx.dev/troubleshooting.html>`_ — same ``bash``-resolution
footgun, ``MISE_BASH_PATH`` fix, the WSL interop ``appendWindowsPath``
lever.
16 changes: 16 additions & 0 deletions src/threecode.nim
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ when defined(posix):
import threecode/[types, util, prompts, shell, session, compact,
config, actions, api, display, ui, update, fatprompt,
toolstream, turns, transcript]
when defined(windows):
import threecode/streamexec # for resolveBash, used by ensureBash
import tinotify
import threecode/minline
export types, util, prompts, shell, session, compact,
Expand Down Expand Up @@ -70,6 +72,19 @@ proc refuseRoot() =
"Run as your normal user. (override: THREECODE_ALLOW_ROOT=1)"
quit ExitUsage

proc ensureBash() =
## Windows startup guard: 3code depends on bash, and the supported source
## is the MSYS2 tree the installer drops into the 3code app dir
## (`%LOCALAPPDATA%\3code\msys64`). Hard-fail if it is missing — the one
## fix is to (re)run the installer, which also bootstraps MSYS2. POSIX
## always has /bin/sh so this is a no-op there.
when defined(windows):
let b = resolveBash()
if b.len == 0:
stderr.writeLine "3code: bash not found. Re-run the installer to set it up:"
stderr.writeLine " irm https://3code.capocasa.dev/install.ps1 | iex"
quit ExitUsage

proc setupTlsEnv() =
## macOS: stock LibreSSL at `/usr/lib/libssl.dylib` fails handshakes
## against most modern endpoints, so we ship Homebrew OpenSSL 3 dylibs
Expand Down Expand Up @@ -177,6 +192,7 @@ proc main() =
setupTlsEnv()
cleanupStaleBinaries()
refuseRoot()
ensureBash()
# Internal flag for the detached background worker. Run silently and
# exit before any other startup work (skill extraction, config load).
let cl = commandLineParams()
Expand Down
7 changes: 7 additions & 0 deletions src/threecode/config.nim
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ var notifyEnabled*: bool = false
## Resolved at config load. Set true by `[settings]` `notify = on`.
## When true, a native desktop notification fires when a turn ends.

var bashPathOverride*: string
## Windows-only. `[settings]` `bash_path = "..."` overrides MSYS2
## detection in `streamexec.resolveBash` for users with bash at a
## non-standard location. Empty on POSIX (where /bin/sh is always used).

proc gateExperimental*(p: Profile): bool =
## True if the profile is allowed to run a turn under current policy:
## empty profile (caller handles that), known-good model, or the
Expand Down Expand Up @@ -228,6 +233,8 @@ proc parseConfigFile*(path: string): (string, string, seq[ProviderRec]) =
of "on", "true", "yes", "1": streamingEnabled = true
of "off", "false", "no", "0": streamingEnabled = false
else: discard
of "bash_path", "bash-path":
bashPathOverride = v
else: discard
of "provider":
case e.key
Expand Down
32 changes: 31 additions & 1 deletion src/threecode/streamexec.nim
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ else:
import std/streams
import types, util, shell

when defined(windows):
var cachedBash* {.threadvar.}: string

proc bundledMsys2Bash(): string =
## The installer drops an MSYS2 tree into the 3code app dir
## (`%LOCALAPPDATA%\3code\msys64`), so 3code owns its bash + unix
## toolset regardless of what else is on the system. No probing of
## system MSYS2 roots or PATH: a single deterministic location.
result = getEnv("LOCALAPPDATA") & r"\3code\msys64\usr\bin\bash.exe"

proc resolveBash*(): string =
## Windows bash resolution. Order: the 3code-owned bundled MSYS2
## (the supported, always-present source), then an explicit config
## override (`bash_path`) for hyper-users, then nothing (hard-fail
## at the startup guard). We never fall back to a system or PATH
## bash: the bundled toolset is the whole point.
if cachedBash.len > 0: return cachedBash
let bundled = bundledMsys2Bash()
if fileExists(bundled):
cachedBash = bundled
return bundled
when declared(bashPathOverride):
if bashPathOverride.len > 0 and fileExists(bashPathOverride):
cachedBash = bashPathOverride
return bashPathOverride
return ""

const PartialLineFlushMs = 700

proc emitCompleteLine(rawOut: var string; lineBuf: var string;
Expand Down Expand Up @@ -299,7 +326,10 @@ export DEBIAN_FRONTEND=noninteractive
startProcess("/bin/sh", args = ["-c", wrapped],
options = {poStdErrToStdOut, poUsePath})
else:
startProcess("/bin/sh", args = ["-c", wrapped],
let b = resolveBash()
if b == "":
return ("bash not found", 127, cap)
startProcess(b, args = ["-c", wrapped],
options = {poStdErrToStdOut, poUsePath})
startToolCancelWatcher(p.processID)
startToolTimeoutWatcher(cap)
Expand Down
Loading
Loading