Skip to content

Windows support: build, install and auto-open from PowerShell - #6

Merged
simoncoombes merged 6 commits into
mainfrom
windows-support
Sep 15, 2026
Merged

simoncoombes merged 6 commits into
mainfrom
windows-support

Conversation

@simoncoombes

Copy link
Copy Markdown
Owner

agentpane did not compile for Windows, and would not have worked if it had. This makes agentpane install --autopane behave in PowerShell the way it does in iTerm2: starting a Claude session splits the pane and runs the TUI beside it.

Windows Terminal and WezTerm-on-Windows both get a backend. install.sh is untouched and stays the only thing that edits a settings file on macOS or Linux.

What had to change

The build. syscall.Kill does not exist on Windows, and the registry's liveness probe was the only thing in the package that touched syscall. Split per platform; Windows opens a handle and reads the exit code, which unlike a bare "did OpenProcess succeed" check gets a terminated-but-open process right.

The shell. Every backend handed its command to /bin/sh. Windows Terminal and WezTerm exec a program directly, and there is no shell worth going through — cmd.exe's quoting rules are not Go's, and they disagree precisely on the case that matters, a program path containing a space. A pane invocation now carries both spellings and each backend takes the one it wants. The unix argv is unchanged byte-for-byte, which pane_test.go asserts.

The backend. wt -w 0 split-pane -V, detected by $WT_SESSION, focus handed back with move-focus left. --size takes a fraction of the parent rather than a cell count, so the width divides against the console's own — read from CONOUT$, since a hook's stdout is a pipe and measures nothing.

The guards. Guard (e) asks whether a console is attached rather than a controlling tty. Guard (d) walks the process tree for a second claude rather than reading the outer process's environment, which Windows will not hand over cheaply.

The installer. The embedded script needs bash and python3. Rather than put a python3 install in front of every Windows user, the surgery is done natively there: hooks.go decides, ojson.go serialises, diff.go previews, installer_windows.go does the I/O. Same flags, same report, same exit codes.

Two bugs found while building it

Both would have shipped as "it just never opens a pane", with nothing printed:

  • syscall.ECONNREFUSED is a placeholder on Windows. A refused AF_UNIX connect answers WSAECONNREFUSED. Guard (f) would have read every leftover socket file as a live TUI and skipped auto-open for that session id permanently, including across --resume.
  • The recognizer could not see its own entries. ^(?:\S*/)?agentpane +hook matches none of C:\...\agentpane.exe hook. A missed entry does not merely go unseen: it appends a second one on every re-run and orphans them all on --uninstall. doctor reads through the same parser now, since a doctor that says "not installed" about a working install sends people to install on top of one.

Notes for review

  • Key order. encoding/json sorts map keys, so appending one hook would rewrite every line of a settings file that was not already alphabetical. ojson.go preserves order, reuses the file's indent unit, and is checked against json.dumps byte-for-byte; the diff is checked against difflib the same way. One deliberate disagreement, pinned as its own test: 1e3 stays 1e3 where Python reprints it 1000.0.
  • Untagged logic. The plan logic and the process walk compile everywhere despite only Windows calling them, so the parts that can be wrong in interesting ways are tested on the machines CI actually has.
  • golang.org/x/sys becomes a direct dependency. It was already in the module graph.

Verification

gofmt, go vet, GOOS=windows go vet ./... (tests included), GOOS=windows GOARCH=arm64 go build, the Go suite, tests/install_test.sh 37/37 and tests/get_test.sh 6/6 all clean. Each of the four commits builds and tests on its own, so the history bisects.

CI gains a windows-latest job — vet, build, test and a --dry-run install smoke test — and the unix job cross-vets GOOS=windows. No -race on Windows: the detector wants a C toolchain there, and the races worth catching are in the sources, which already run under -race on the other two runners. Releases ship windows amd64/arm64 as zips.

Not verified: the split has never run on a real Windows machine. The argv comes from wt's and WezTerm's CLI docs and is pinned by tests, which is the "argv only" status docs/TERMINALS.md already gives tmux, WezTerm and kitty. First real check is agentpane autopane --explain on a Windows box — it prints the exact command to run by hand.

The three codexsrc TestRealCodexSession* failures are pre-existing on main and untouched here.

The package could not be compiled for Windows at all. Stale PID files
outlive the CLI processes that wrote them, so every registry row is checked
against the OS before it reaches the UI, and the check was kill(pid, 0) —
a call that does not exist there.

Windows asks the same question the long way round: open a handle and read
the exit code. A live process reports STILL_ACTIVE; one that has already
terminated reports the code it exited with, which is the case a bare "did
OpenProcess succeed" check gets wrong. Access-denied counts as alive,
mirroring the unix EPERM reading — the process is real enough for the
kernel to refuse us, which is all the caller asked.

Nothing else in the package touches syscall, so this one function moving
out is what makes the rest of it portable.

TestNoProcessSignals follows the function to its new file. Its needle list
grows TerminateProcess, because §5.3 is a rule about what agentpane does to
other processes and not about which API spells it; OpenProcess and
GetExitCodeProcess only read, so the Windows half needs no exemption.
…ps(1)

Auto-open was a macOS-and-Linux feature by accident of its plumbing rather
than by decision. Three assumptions did it: every backend handed its
command to /bin/sh, both autopane guards shelled out to ps(1), and no
terminal Windows has was in the detection list.

The shell goes first, because it is the one that could not be papered over.
iTerm2 and tmux take a command LINE and run it through a shell themselves;
WezTerm and kitty exec a program, so the shell has to be named. Windows
Terminal and WezTerm-on-Windows also exec a program, but there is no shell
worth naming: cmd.exe's quoting rules are not Go's, and the two disagree
precisely on the case that matters, a program path containing a space. So a
pane invocation now travels as a paneRun carrying both spellings, and each
backend takes the one its terminal wants. The unix argv is unchanged
byte-for-byte; pane_test.go asserts that.

The new backend is Windows Terminal, detected by $WT_SESSION, splitting
with `wt -w 0 split-pane -V`. It is last in the detection order and that is
not just seniority: it is the only backend that cannot address the pane it
splits. iTerm2 matches a session uuid, tmux a %pane, WezTerm a --pane-id;
wt has no equivalent and splits whatever pane is ACTIVE in the most
recently used window. SessionStart fires within milliseconds of the session
starting so in practice that is the right pane, but anything that can aim
should win a tie. --size takes a fraction of the parent rather than a cell
count, so the width is divided against the console's own — read from
CONOUT$, because a hook's stdout is a pipe and reports nothing — and the
same floors applied that the iTerm2 AppleScript applies. A wt without
--size (pre-1.7) falls back to its own 50/50, the bargain the tmux chain
already strikes for -l.

Then the guards. Guard (e) asks about a controlling terminal, which Windows
does not have; it has a console, which a child inherits, so asking whether
WE have one answers the same question for every case the guard exists for —
Task Scheduler, a service, CI. Guard (d) asks whether the claude process's
environment carries CLAUDE_CODE_CHILD_SESSION, which Windows will not hand
over without a great deal of unsafe code, so it is answered structurally
instead: is there a second claude above this one in the process tree. That
misses a Claude Code installed through npm, which runs as node.exe — the
failure direction is one pane too many, never none, which is the bias the
unix probe already takes when it cannot read an environment. The walk is
pure and lives untagged in proctree.go, so cycles, recycled pids and the
depth bound are tested on every machine rather than only the one platform
that calls it.

One bug found on the way, and it is the one that would have been reported
as "it just never opens a pane". syscall.ECONNREFUSED exists on Windows but
is a synthetic APPLICATION_ERROR value the OS never returns; a refused
AF_UNIX connect answers WSAECONNREFUSED. Guard (f) would therefore have
read every leftover socket file as a live TUI and skipped auto-open for
that session id for good, including across --resume. "Refused" is asked
per-platform now.

The backends are argv-only on Windows, in the sense docs/TERMINALS.md
already uses for tmux, WezTerm and kitty: the exact command is pinned by
tests, but that these terminals accept it is a claim about their CLIs read
off their docs, not something a unit test can settle.
`agentpane install` ran the embedded script, and the script needs bash and
leans on python3 for all its JSON handling. Windows has neither. The choice
was between putting a python3 install in front of every Windows user and
writing the surgery a second time; this is the second time.

install.sh stays exactly as it is and remains the only thing that edits a
settings file on macOS or Linux, tests/install_test.sh included. What is new
compiles only under GOOS=windows: hooks.go decides what changes, ojson.go
writes the file back, diff.go renders the preview, installer_windows.go does
the I/O and the asking. Same flags, same [warn]/[info]/[plan] report, same
exit codes.

The decision logic lives untagged even though only Windows calls it, for the
same reason the process walk does: it is the part that can be wrong in
interesting ways, and a test for it should run on the machines CI actually
has.

Two things needed more care than a port suggests.

Key order. encoding/json round-trips an object through a Go map and sorts
the keys, so appending one hook to a settings file that was not already
alphabetical would rewrite every line of it. Python dictionaries keep
insertion order and the script has never done that. So ojson.go is JSON that
remembers the order its keys were written in, reuses the file's own indent
unit, and keeps a number's source text rather than round-tripping it through
float64. It is checked against json.dumps byte-for-byte, and the diff
against difflib the same way. The one deliberate disagreement is that
1e3 stays 1e3 where Python reprints it as 1000.0, which is closer to
"only the diff the change makes" and is pinned as its own test.

Recognizing our own entries. `^(?:\S*/)?agentpane +hook` cannot see a
Windows entry at all: it wants "/" separators, it wants the basename to be
exactly "agentpane" where Windows writes "agentpane.exe", and it is built
from \S* so it stops at the first space — while a quoted
`"C:\Program Files\...\agentpane.exe" hook` is a command line a user can
legitimately end up with. A recognizer that misses an existing entry does
not merely fail to see it: it appends a SECOND one on every re-run and
leaves all of them behind on --uninstall. So the command is parsed rather
than matched, and case is folded only for a path that LOOKS like a Windows
one, because /opt/AgentPane on a case-sensitive filesystem is a different
file from /opt/agentpane and claiming it would be wrong.

doctor reads through the same parser now. It has to: a doctor that reports
"not installed" about a working install sends people to install on top of
one they already have. The regexes it used stay where they are as the
recorded form of install.sh's matchers, which is all TestInstallShParity
ever wanted them for.

golang.org/x/sys becomes a direct dependency. It was already in the module
graph; the console and process-table calls are ones the standard library's
syscall package does not export.
TERMINALS.md gains the wt row and a Windows section, which is mostly there
to be honest about the two places Windows behaves differently rather than
to sell the feature: wt cannot address the pane it splits, and the headless
and nested-session guards ask neighbouring questions instead of the ones
they ask on unix. INSTALL.md gains the go install route, since get.sh is
POSIX sh and is not how anyone installs anything there.

CI gets its own windows-latest job rather than a third matrix entry,
because the steps in the existing one run install.sh, get.sh and the demo
scaffold — POSIX shell, and not what Windows installs with. What is left is
what Windows actually has to get right, plus a --dry-run smoke test, which
is the only thing a real runner can answer that a cross-compile cannot: the
binary starts, and the installer's preview path runs end to end against a
settings file without writing to it.

No -race there. The detector wants a C toolchain on Windows, and the races
worth catching are in the sources, which are the same code everywhere and
already run under -race on the other two runners.

The unix job cross-vets GOOS=windows so a break in the half that only
compiles there is found in seconds rather than on the Windows runner.

Releases ship windows amd64 and arm64 as zips holding agentpane.exe. A
tar.gz of a Unix-named binary is not something a Windows user can act on,
and get.sh, which unpacks the tarballs, does not run there anyway.
The windows job failed at its first step, and the failure named every .go
file in the repository including ones nothing in this branch touched. That
is the signature, not a formatting problem: Git for Windows defaults to
core.autocrlf=true, so checkout rewrote the tree to CRLF and gofmt reports a
CRLF file as unformatted.

.gitattributes settles it with `* text=auto eol=lf`. The index was already
LF everywhere — `git add --renormalize .` rewrites nothing — so this only
changes what a Windows checkout writes out, and unlike a core.autocrlf
setting in the workflow it also covers anyone who clones this on Windows to
work on it. Worth having beyond gofmt: install.sh and get.sh are not
runnable with CRLF line endings at all.

The step never got as far as the tests, which would have failed next. A few
of them observe a command chain by pointing it at a fake program — a shell
script standing in for osascript — and reading back what it was passed.
There is no Windows stand-in: a .bat equivalent would have to carry a
forty-line AppleScript through batch's quoting rules as a single argument,
which is a lot of machinery for testing the macOS path on a machine without
macOS. The fake now comes from a helper that is written per platform and
skips on Windows, so those tests say why they did not run instead of failing
at a missing /bin/sh. They still run on both unix runners, which is where
the terminal backends they cover actually exist.
Eleven failures on the windows runner, none of them a bug in what is being
tested. They divide into three kinds, and the common thread is that a test
which cannot redirect the developer's real home does not fail loudly — it
quietly reads it.

Redirection that only speaks unix. t.Setenv("HOME", …) does nothing on
Windows, where os.UserHomeDir reads %USERPROFILE%; the same goes for TMPDIR
against os.TempDir's %TMP%/%TEMP%. So doctor was inspecting the runner's own
~/.claude instead of the fixture, and TestAutopaneLockPathHostile was
comparing a lock path against a temp directory nothing had put it in. Both
spellings are set now, through setFakeHome/setFakeTemp, which is worth
having on unix too: reading the real home was always the wrong behaviour,
it just happened not to be reachable there.

Fixtures written with "/" by hand. A settings command built as
dirA+"/agentpane hook" does not match a path built with filepath.Join once
the separator is a backslash, and "/old/dead/agentpane" is not an absolute
path on Windows at all, so the staleness check correctly declined to resolve
it. Both are built the platform's way now.

Behaviour that is genuinely different, where the test asserted the mechanism
rather than the property. os.Chmod on Windows toggles the read-only
attribute and nothing else, so chmod(0o000) SUCCEEDS without making a
directory unreadable — the registry test now checks the effect rather than
the call, which is the better guard everywhere. The socket-permission test
skips: mode bits do not carry permissions there, and what restricts the
socket is the ACL on os.TempDir, which is %LOCALAPPDATA%\Temp — already
private to the user, unlike the shared /tmp that test exists because of.
Creating a symlink is a privilege on Windows, so the dangling-symlink test
skips when it cannot rather than failing.

And the two parity checks against python: print() translates \n to \r\n on
the way to stdout there. That is a property of print, not of either
serializer — install.sh writes LF on Windows too — so the comparison
normalizes rather than pretending the two disagree.
@simoncoombes
simoncoombes merged commit 21acf83 into main Sep 15, 2026
3 checks passed
@simoncoombes
simoncoombes deleted the windows-support branch September 23, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant