Skip to content

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

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

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) <noreply@anthropic.com>
@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) <noreply@anthropic.com>
@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.

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.

2 participants