Skip to content

feat(bridge): Windows support via platform-gated CLI transport#25

Closed
bwright2810 wants to merge 2 commits into
AltanS:mainfrom
bwright2810:feat/windows-cli-transport
Closed

feat(bridge): Windows support via platform-gated CLI transport#25
bwright2810 wants to merge 2 commits into
AltanS:mainfrom
bwright2810:feat/windows-cli-transport

Conversation

@bwright2810

Copy link
Copy Markdown

What

Adds Windows support to Collie by giving herdr-client.ts a second transport, selected by platform. mac/Linux are untouched.

Why the socket path doesn't work on Windows

On Windows, Herdr doesn't expose a filesystem AF_UNIX socket — it maps the socket path onto a Windows named pipe (\.\pipe\<path>) via the Rust interprocess crate, guarded by an in-crate handshake. Bun.connect({unix}) targets native AF_UNIX and can't reach a named pipe; even a pipe-aware raw client (Node net, .NET NamedPipeClientStream) gets the connection accepted and then immediately EOF'd because it doesn't speak the crate's handshake. The only reliable local client for that pipe is the same-version herdr binary itself — which exposes every method Collie needs as a CLI subcommand and emits identical JSON envelopes.

How

herdr-client.ts is refactored into a HerdrClient interface with two implementations, chosen by createHerdrClient():

  • SocketHerdrClient (mac/Linux) — the existing one-shot Unix-socket transport, moved verbatim. No behavior change on your platforms.
  • CliHerdrClient (Windows) — spawns herdr <subcommand> once per RPC (same one-request-per-invocation shape as the socket path), with a one-retry-on-transient-pipe-drop guard for idempotent reads.

events.subscribe has no CLI equivalent, so on Windows it reports "down" and the engine stays on the fast poll cadence. Since StateEngine already treats events purely as a poke (the snapshot poll is the source of truth), this costs poke latency, not correctness.

Supporting changes:

  • config.ts: herdrBin (HERDR_BIN_PATHCOLLIE_HERDR_BINherdr on PATH), consulted only on the Windows path.
  • herdr-plugin.toml: windows added to both platforms arrays.
  • README / ARCHITECTURE: Windows section documenting the poll-only degradation, the herdr-on-PATH requirement, and running collie-ctl.sh under Git Bash.
  • Version bumped 0.14.2 → 0.15.0 with a CHANGELOG entry (check-version.sh passes).

Honest caveats

  • The CLI-spawn transport is a pragmatic bridge, not the ideal. It depends on herdr.exe being same-version and on PATH. If Herdr later exposes the Windows pipe in a way a socket client can speak, a socket-based Windows transport would be cleaner and this becomes legacy.
  • Tested on Windows (Bun 1.3.10, Git Bash, no-systemd path): all bridge RPCs verified live against a running herd — snapshot, pane read, send-text/keys, tab/workspace create, close, rename. The mac/Linux socket path is unchanged from main, so it carries the same behavior it always had, but I have no Mac/Linux box to re-run it on — a second set of eyes there is welcome.
  • Bridge tsc --noEmit and web tsc --noEmit are clean. The bridge bun test suite has some pre-existing failures on Windows (Unix file-mode / /tmp-path assumptions in the tests themselves, not the transport) — identical pass/fail count to a clean main checkout on the same box.

Happy to split, reshape, or gate this differently if you'd prefer a different approach to platform selection.

Herdr's control socket on Windows is a named pipe, not a filesystem
AF_UNIX socket, so Bun.connect({unix}) can't reach it (a pipe-aware raw
client gets accepted then immediately EOF'd — the interprocess crate's
handshake is Herdr-internal). The only reliable local client for that
pipe is the same-version herdr binary itself.

herdr-client.ts is split into a HerdrClient interface with two
implementations chosen by createHerdrClient():
  - SocketHerdrClient (mac/Linux) — the existing Unix-socket transport,
    unchanged.
  - CliHerdrClient (Windows) — spawns the herdr CLI once per RPC, emitting
    identical JSON envelopes. events.subscribe has no CLI equivalent, so it
    degrades to poll-only; StateEngine already treats events as a poke, not
    a source of truth, so only poke latency changes, never correctness.

Config gains herdrBin (HERDR_BIN_PATH / COLLIE_HERDR_BIN, defaults to
`herdr` on PATH), consulted only on the Windows path. The manifest
declares the windows platform; README/ARCHITECTURE document the poll-only
degradation and the herdr-on-PATH requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@bwright2810

Copy link
Copy Markdown
Author

Hi I've been using a personal fork to get this running on Windows - would like to possibly get this merged upstream. Code and MR were written by Claude by I've reviewed and tested it myself. Thanks!

@AltanS AltanS left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, and the interprocess diagnosis looks right. I checked two of your claims locally: SocketHerdrClient's body is byte-identical to the old class, and the CLI contract holds on 0.7.5 (errors on stderr, exit 1), so the exit-code gate is sound.

Three changes I want before this lands.

1. Move the seam down to the transport. Don't split HerdrClient into two implementations. The divergence here is transport, not semantics, and duplicating all 15 methods means the file that's meant to make a Herdr API change a one-file fix now needs that fix twice, with only one half ever exercised on any given machine. What I want:

interface Transport {
  request<T>(method: string, params: Record<string, unknown>): Promise<T>;
  subscribeEvents(opts): { close(): void } | null;   // null = this transport can't stream
}

One HerdrClient class keeps every method and its doc comments. SocketTransport is today's request(). CliTransport owns a method→argv table and synthesizes the envelope for pane.read — which is also where the truncated approximation belongs (right now it's hardcoded false, which kills the "load older lines" button, since agent-chat gates on that flag). Adding a Herdr method then means one method plus one case, not three edits across two impls, and consumers keep importing the same HerdrClient type.

2. CliTransport takes its runner as a constructor arg, defaulting to the Bun.spawn one, so the argv table is covered by bun test. Nothing in the CLI path is reachable from a test today, and argv is exactly the part neither of us can verify by reading.

3. COLLIE_HERDR_TRANSPORT=auto|cli|socket, defaulting to auto. I have no Windows box, so without it nobody who can merge this can run it.

Related: a transport that returns null from subscribeEvents shouldn't drive EventPoker into a permanent reconnect loop, and it shouldn't leave StateEngine pinned at COLLIE_POLL_MS. That path exists because "stream down" normally means transient; here it's the steady state, so Windows spawns herdr.exe every 1.5s forever. It needs its own state and its own knob — 1.5s is too many spawns, the 12s idle cadence is too stale. Something like 4s behind COLLIE_POLL_NO_EVENTS_MS.

Smaller: ENOENT out of Bun.spawn should say "herdr not found, set HERDR_BIN_PATH" instead of surfacing as a disconnect banner; isTransientPipeError has four clauses but two are substrings of the other two, so half of it is dead; make Config.herdrBin optional so it stops leaking into server.test.ts; drop the version bump, I'll cut the release commit.

Questions, since I can't reproduce any of this:

  • did you reach it from a phone over tailscale, or just localhost in a browser? collie-ctl skips serve entirely when tailscale isn't on PATH
  • how did you start it — the Herdr action buttons (which shell bash collie-ctl.sh), or by hand?
  • is your herdr a real .exe or a .cmd shim? Bun runs shims through cmd.exe, which changes quoting for every arg, reply text included
  • what have you actually typed through send-text? multi-line, quotes, &, %, emoji
  • did you hit the pipe drop in practice? if so paste the stderr, that should drive the predicate rather than guesses

If you'd rather just get Windows working and not do the restructure, say so and I'll push it to your branch.

Addresses review on PR AltanS#25: move the platform divergence down to a
transport, restore testability and observability, and fix the poll-only
degradation path.

- herdr-client.ts is now ONE HerdrClient class (every method + doc comment,
  written once) over a Transport interface (request + subscribeEvents|null).
  SocketTransport is the unchanged Unix-socket path; CliTransport owns a
  method->argv table (CLI_SPECS) and synthesizes the pane.read envelope.
  Adding a Herdr method is one method + one CLI_SPECS entry.
- pane.read no longer hardcodes truncated:false (which killed agent-chat's
  "load older lines" button). approximateTruncated() reports truncated when a
  --lines N read returns >= N lines.
- CliTransport takes its runner as a constructor arg (defaults to Bun.spawn),
  so the argv table is covered by bun test. New bridge/herdr-client.test.ts.
- COLLIE_HERDR_TRANSPORT=auto|cli|socket (default auto) forces a transport, so
  the CLI path can be exercised off-Windows.
- A null subscribeEvents no longer drives EventPoker into a permanent
  reconnect loop or pins StateEngine at COLLIE_POLL_MS. New three-value
  PollMode (streaming|fast|no-events); no-events polls on a dedicated
  COLLIE_POLL_NO_EVENTS_MS (default 4s) with no reconnect.
- ENOENT from spawn throws an actionable "herdr not found, set HERDR_BIN_PATH"
  message. Dropped isTransientPipeError + the read-retry (never observed
  firing; half its clauses were dead substrings).
- Config.herdrBin optional (out of server.test.ts). Reverted the version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@bwright2810

Copy link
Copy Markdown
Author

Thanks for the detailed review — restructured to match. Rundown of what changed and answers to your questions.

Changes

1. Seam moved down to the transport. herdr-client.ts is now a single HerdrClient class (all methods + their doc comments, once) over a Transport interface:

interface Transport {
  request<T>(method: string, params?: Record<string, unknown>): Promise<T>;
  subscribeEvents(opts: SubscribeOptions): StreamHandle | null; // null = can't stream
}

SocketTransport is today's socket request()/subscribe() logic unchanged (visibility + type names changed, behavior didn't). CliTransport owns a method→argv table (CLI_SPECS) and synthesizes the envelope. Adding a Herdr method is now one method on HerdrClient plus one CLI_SPECS entry, not three edits across two impls, and consumers keep importing the same HerdrClient.

The pane.read case does the raw-text→{read} envelope wrap, and that's where the truncated approximation lives — you were right that hardcoded false killed the "load older lines" button. It now approximates: if a --lines N read comes back with ≥N lines, older scrollback almost certainly exists → truncated: true (approximateTruncated()). Errs toward showing the button rather than hiding history, since the CLI doesn't expose the real flag.

2. CliTransport takes its runner as a constructor arg, defaulting to the Bun.spawn one. New bridge/herdr-client.test.ts injects a recording fake and asserts the exact argv for every method — the part neither of us can verify by reading. Covers the list/snapshot subcommands, --no-focus/--label/--cwd on create, --clear vs. literal label on rename, HERDR_SOCKET_PATH in the env, the pane.read text→envelope wrap + both truncated outcomes (≥N → true, <N → false), and the error/unknown variant/null-stream paths.

3. COLLIE_HERDR_TRANSPORT=auto|cli|socket, default auto. auto = CLI on Windows, socket elsewhere; cli/socket force one regardless of platform, so you can exercise the CLI path without a Windows box.

Related — the poll-only degradation. A transport that returns null from subscribeEvents no longer drives EventPoker into a permanent reconnect loop or pins StateEngine at COLLIE_POLL_MS. EventPoker now has a three-value PollMode (streaming | fast | no-events): a null handle latches no-events — no stream tracked, no reconnect, no resubscribe on pane-set change — and StateEngine polls on a dedicated COLLIE_POLL_NO_EVENTS_MS (default 4s, your suggested cadence). fast/COLLIE_POLL_MS stays what it always was: a transient socket drop that's reconnecting.

Smaller items:

  • ENOENT out of Bun.spawn now throws herdr not found at "<bin>" — set HERDR_BIN_PATH or COLLIE_HERDR_BIN… — an actionable message instead of a raw ENOENT.
  • Dropped isTransientPipeError and the read-retry entirely. You flagged half its clauses as dead substrings — and checking my logs, I've never actually hit the pipe drop (zero occurrences across the bridge's stderr/audit). Rather than half-fix a predicate driven by a guess, I removed it; if a real drop shows up later it can come back keyed on the actual stderr. Every CLI call now runs once.
  • Config.herdrBin is optional, so it's out of server.test.ts.
  • Dropped the version bump + CHANGELOG entry (back to 0.14.2) — yours to cut.

87 unit tests pass on the touched files; tsc --noEmit clean; check-version.sh ✓ at 0.14.2. (Full suite has 14 pre-existing failures on Windows — 0600/0700 perms, POSIX path traversal, fs mocks — identical on the base commit, none in files I touched.)

Your questions

  • Phone over Tailscale, not localhost. Reached it as a PWA over the MagicDNS https origin, gated by COLLIE_TRUSTED_USER + COLLIE_PUBLIC_HOSTS.
  • Started via collie-ctl.sh through Git Bash, which is the same launch chain a Herdr action button triggers (bash → collie-ctl.sh → nohup bun). Not the action button UI directly.
  • Real herdr.exe, not a .cmd shim. COLLIE_HERDR_BIN points at the actual PE, so Bun.spawn passes argv straight through — no cmd.exe re-quoting layer.
  • send-text: the bridge writes an audit log of every reply, so this is from real payloads (387 of them) plus a couple of live tests. ", ', %, \, smart quotes / em-dash / ellipsis, and |, &, an emoji (U+1F642 🙂) all delivered clean as single argv elements. Consistent with the real-.exe path — shell metacharacters land literally. I did NOT need multi-line to work as a shell escape; see below.
  • Pipe drop: never hit it in practice — that's why the predicate is gone rather than guessed at.

One unrelated thing I noticed: multi-line reply text arrives flattened to one space-joined line. I traced it — the bridge receives it already flattened (the audit log shows \n→single-space before any transport call), so it's client/device-side, upstream of the transport, and platform-independent (would flatten the same on the socket path). Consistent with the mobile soft-keyboard substituting a space for the line break in the rows={1} textarea; I couldn't pin the exact mechanism without DOM-level input logging on the phone. Not introduced by this PR and not something it touches — flagging it in case you want a separate issue.

Happy to split any of this into separate commits if you'd rather review it in pieces.

@mikebenner

Copy link
Copy Markdown
Contributor

Author of #27 here. Since the two PRs rest on directly contradictory transport claims, I had the central one re-verified independently (two separate agent reviews; the second re-ran the probe live against a running herd) before commenting — this is what both agreed on.

The handshake claim doesn't reproduce on the current build

On Windows 11 with herdr 0.7.4-preview.2026-07-17-813fec141faa (protocol 16): a raw node:net client dialing \\.\pipe\<HERDR_SOCKET_PATH> and immediately writing a single newline-terminated workspace.list request receives a valid workspace_list reply. No application-visible handshake is required on this endpoint and build. The long-lived events.subscribe stream also acks and stays up — #27 has been running this transport continuously since it was opened (snapshot polling, pane reads/replies, resubscribes as panes change).

That matches the interprocess crate's documented behavior (its local sockets add no framing or metadata and interoperate with non-Rust peers) and Node's documented named-pipe support in net.connect(path).

If the EOF was observed on an older herdr build, an exact version + endpoint + probe would let anyone reproduce it. Two gotchas worth ruling out first, since either produces exactly the reported symptom: dialing herdr-client.sock instead of herdr.sock, and half-closing the write side (e.g. disposing a .NET StreamWriter, which closes the duplex stream before the reply can be read).

What CLI-spawn costs if the pipe does speak raw JSON-RPC

  • events.subscribe is dropped, and that's more than poke latency: StateEngine detects transitions between observed snapshots, so a short-lived blocked/done that falls entirely between polls can be missed outright, notification included.
  • One process spawn per RPC and per poll tick, multiplied across sessions.
  • pane.read is synthesized — revision: 0 stub and an approximated truncated (the diff's own comments say so) — so consumers of those fields lose the real contract.
  • Reply text rides the process command line (Windows caps it at 32,767 chars; the socket path has no comparable limit).
  • Runtime coupling to a same-version herdr on PATH — a bare PATH lookup can introduce version skew rather than remove it.
  • Two items described in the PR body don't appear in the current diff: the one-retry-on-transient-pipe-drop guard, and the 0.15.0/CHANGELOG bump (the manifest in the diff still reads 0.14.2). Possibly just not pushed yet.

Where this PR is ahead of #27

Packaging — manifest platforms, herdr-binary config plumbing, README/ARCHITECTURE docs, transport tests. #27 is deliberately bridge-only, and the same review flagged real gaps there too: pending connects aren't cancellable on timeout, pipe-path construction should normalize an already-prefixed name, no Windows transport tests, and the win32 default socket path should be %APPDATA%\herdr. I'm happy to fold those fixes into #27.

Suggested resolution

Direct named-pipe transport as the Windows default — from either PR, no ownership stake — with this PR's packaging and docs layered on top, and CLI-spawn retained as an explicit opt-in fallback for herdr builds demonstrated to be incompatible.

🤖 Generated with Claude Code, cross-checked by a second agent (Codex CLI); probe script available on request.

https://claude.ai/code/session_01VutEkP9A8oY72ZQBfFJdSw

AltanS pushed a commit that referenced this pull request Jul 26, 2026
…ization, APPDATA default, tests

Review follow-ups from #25/#27 cross-review:
- dialHerdr exposes onDial(cancel) so a caller timeout that fires
  mid-connect aborts the pending dial instead of leaking the OS handle
  (both request() and subscribeEvents() wire it up); a dial destroyed
  before connect now settles its promise instead of pending forever.
- toPipeName() passes through already-prefixed pipe names (either slash
  direction) instead of double-prefixing.
- defaultSocketPath() resolves %APPDATA%\herdr\herdr.sock on win32 so
  HERDR_SOCKET_PATH is no longer required there; pure and unit-tested.
- New dial.test.ts: pure toPipeName cases everywhere, plus live named-
  pipe round-trip, mid-codepoint chunk split, connect-error, and cancel
  coverage on win32 (skipped elsewhere).
- SockHandle.end() documented as immediate destroy, not a drained close.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VutEkP9A8oY72ZQBfFJdSw
AltanS added a commit that referenced this pull request Jul 26, 2026
Builds on mikebenner's named-pipe dial shim with the parts of bwright's
Windows PR (#25) that outlive its transport: a forced-dialer escape hatch,
the envEnum config helper, and the packaging/docs it carried.

COLLIE_HERDR_DIAL=auto|net|bun picks the dialer. `auto` is unchanged
behaviour — node:net on Windows, Bun.connect elsewhere. `net` forces the
Windows dialer anywhere, which matters because net.connect(path) opens an
AF_UNIX socket on POSIX and a named pipe on win32: the same branch, the same
code, reachable from a machine that can actually review it. bridge/dial.test.ts
uses it, so the four live-dial tests that were describe.skip off win32 now run
everywhere (2 pass + 4 skip -> 6 pass). Sabotaging net.connect fails them, so
they are exercising the branch, not skipping past it.

Verified against the live herd on Linux, both dialers, protocol 17: identical
snapshots (7 panes, 5 agent), and events.subscribe ACKs over node:net. The
event stream survives on this transport — that is the capability the competing
CLI-spawn approach in #25 had to give up, and with it StateEngine's ability to
see a blocked->done transition that resolves between two polls.

There is no handshake to speak: herdr's `interprocess` local sockets state
outright that the crate "never inserts its own message framing or any other
type of metadata into the stream", and no handshake/greeting/preamble exists
anywhere in its source. Recorded at the top of dial.ts so the next reader
doesn't re-derive it.

Config.dialMode is optional so it stays out of unrelated test fixtures —
the same call made when reviewing #25's Config.herdrBin.

herdr-plugin.toml still declares linux/macos only. The bridge runs on Windows;
the launcher does not (no systemd unit, and the plugin actions shell out to
bash, so they fire only when Git Bash is on PATH). Declaring the platform
would advertise buttons that may not work. README documents the manual path
instead, in the Variant C posture.

Version bump and CHANGELOG are deferred to the release commit, per the
land-features-first workflow.

Co-Authored-By: Brandon Wright <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AltanS added a commit that referenced this pull request Jul 26, 2026
Windows support for the bridge via herdr's named pipe. Supersedes #25 and #27:
mikebenner's dial shim as the base (authorship preserved), plus the testability
and packaging work from bwright's PR.
@AltanS

AltanS commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the restructure. Your round-2 response was exactly what I asked for: the Transport seam, the injected runner, the forced-mode env var, and dropping isTransientPipeError rather than half-fixing it. I verified SocketTransport.request was byte-identical to main before reviewing anything else.

The decision: I went with the direct named pipe transport (#27), so the CLI-spawn transport is not landing.

Why. I read the interprocess crate source. It has no handshake, greeting, or preamble anywhere, and its docs say the opposite of the premise: "Interprocess never inserts its own message framing or any other type of metadata into the stream". I then ran the real HerdrClient over node:net against a live herd. Snapshots match the Bun transport exactly, and events.subscribe acks and stays up. Your read on Bun.connect({unix}) was right, it cannot dial a named pipe, but node:net can. The EOF you hit was most likely the pipe name: herdr names it after the full socket path, so it is \\.\pipe\C:\Users\...\herdr.sock, not a short name.

That matters because keeping the event stream keeps correctness, not just latency. StateEngine derives transitions by comparing snapshots, so a blocked to done that resolves between two polls is missed outright on a poll-only transport, notification included.

What landed from your PR, credited to you in the commit:

  • The forced-dialer escape hatch, as COLLIE_HERDR_DIAL=auto|net|bun. This turned out to be the most valuable idea in either PR. net.connect(path) opens an AF_UNIX socket on POSIX and a named pipe on win32, so forcing it on Linux runs the exact code Windows runs. Four dial tests that were describe.skip off win32 now run everywhere.
  • Your envEnum() helper verbatim.
  • The packaging and docs, .env.example and a README Windows section, which feat: Windows support for the bridge via herdr's named pipe #27 deliberately skipped.
  • Config.dialMode is optional so it stays out of server.test.ts, the same call I made on your herdrBin.

One thing I did not take: the manifest platforms change. The plugin actions shell out to bash and there is no systemd unit on Windows, so declaring the platform advertises buttons that only fire when Git Bash is on PATH. The README documents the manual path instead.

Also filing your multi-line flattening find separately. Good catch, and thanks for tracing it to the client rather than leaving it as a transport suspicion.

Merged to main as dd6610d. Closing this one as superseded.

@AltanS AltanS closed this Jul 26, 2026
@bwright2810

Copy link
Copy Markdown
Author

Thank you both!

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.

3 participants