Repository navigation
Windows support: build, install and auto-open from PowerShell - #6
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
agentpane did not compile for Windows, and would not have worked if it had. This makes
agentpane install --autopanebehave 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.shis untouched and stays the only thing that edits a settings file on macOS or Linux.What had to change
The build.
syscall.Killdoes 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, whichpane_test.goasserts.The backend.
wt -w 0 split-pane -V, detected by$WT_SESSION, focus handed back withmove-focus left.--sizetakes a fraction of the parent rather than a cell count, so the width divides against the console's own — read fromCONOUT$, 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
clauderather 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.godecides,ojson.goserialises,diff.gopreviews,installer_windows.godoes 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.ECONNREFUSEDis a placeholder on Windows. A refused AF_UNIX connect answersWSAECONNREFUSED. 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.^(?:\S*/)?agentpane +hookmatches none ofC:\...\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.doctorreads 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
encoding/jsonsorts map keys, so appending one hook would rewrite every line of a settings file that was not already alphabetical.ojson.gopreserves order, reuses the file's indent unit, and is checked againstjson.dumpsbyte-for-byte; the diff is checked againstdifflibthe same way. One deliberate disagreement, pinned as its own test:1e3stays1e3where Python reprints it1000.0.golang.org/x/sysbecomes 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.sh37/37 andtests/get_test.sh6/6 all clean. Each of the four commits builds and tests on its own, so the history bisects.CI gains a
windows-latestjob — vet, build, test and a--dry-runinstall smoke test — and the unix job cross-vetsGOOS=windows. No-raceon Windows: the detector wants a C toolchain there, and the races worth catching are in the sources, which already run under-raceon 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.mdalready gives tmux, WezTerm and kitty. First real check isagentpane autopane --explainon a Windows box — it prints the exact command to run by hand.The three
codexsrcTestRealCodexSession*failures are pre-existing onmainand untouched here.