Skip to content
Open
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,17 @@ COLLIE_HOST=127.0.0.1
# --- Herdr connection ---
# Usually injected by the systemd unit; override only if your socket is elsewhere.
# HERDR_SOCKET_PATH=/home/you/.config/herdr/herdr.sock
# Transport to reach Herdr: auto (default) = the herdr CLI on Windows (its control socket is a named
# pipe Bun can't open), the Unix socket elsewhere. Force cli/socket to exercise one regardless of
# platform (e.g. the CLI path on macOS/Linux).
# COLLIE_HERDR_TRANSPORT=auto
# Path to the herdr binary for the CLI transport (Windows). Defaults to `herdr` on PATH.
# COLLIE_HERDR_BIN=C:\Users\you\AppData\Local\Programs\Herdr\bin\herdr.exe
# Poll cadence (ms) and scrollback lines pulled for the detail view.
COLLIE_POLL_MS=1500
# Poll cadence when the transport has no event stream at all (the Windows CLI). Its own knob because
# COLLIE_POLL_MS would spawn herdr.exe too often and COLLIE_POLL_IDLE_MS is too stale.
# COLLIE_POLL_NO_EVENTS_MS=4000
COLLIE_READ_LINES=200
# Keys sent to submit a reply after the text (comma-separated). Agent-dependent.
COLLIE_SUBMIT_KEYS=Enter
Expand Down
17 changes: 15 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ app. Closing this needs the server-side blocking-message capture described above
`agent.send`, `events.subscribe`, …). It translates to/from an internal domain model
(`AgentStatus`, `AgentView`, `SnapshotResponse` — `bridge/types.ts`), so a Herdr API rename is a
one-file fix, not a shatter.
- **One client, a pluggable transport chosen by platform.** `herdr-client.ts` exports a single
`HerdrClient` class — every method and its verified doc comment, written once — over a `Transport`
seam (`request()` + `subscribeEvents()`), selected by `createHerdrClient()`. The divergence between
platforms is transport, not semantics: `SocketTransport` (mac/Linux) opens Herdr's Unix socket
directly, the verified default; `CliTransport` (Windows) spawns the `herdr` CLI once per RPC via a
method→argv table, because Herdr maps the socket path onto a Windows named pipe that
`Bun.connect({unix})` can't reach. Both yield identical JSON envelopes, so nothing above the client
changes, and adding a Herdr method is one method plus one argv entry — never a change duplicated
across platforms. `COLLIE_HERDR_TRANSPORT=auto|cli|socket` forces a transport (so the CLI path can
be exercised off-Windows). The CLI has no `events.subscribe` equivalent, so `subscribeEvents()`
returns `null` and the poll below carries the whole load — which it's designed to do anyway.
- **Output model: poll, not stream — event-poked.** Herdr exposes `pane.read` (snapshot) and
`pane.output_matched` (regex event) but **no raw output-stream event**, so there is nothing to
stream even if we wanted to; the live pane view is poll-on-status-change + caching. The bridge's
Expand All @@ -133,8 +144,10 @@ app. Closing this needs the server-side blocking-message capture described above
alongside purely to **poke** that poll: lifecycle events plus a per-agent-pane
`pane.agent_status_changed` subscription trigger an immediate debounced re-poll, while the interval
relaxes to `COLLIE_POLL_IDLE_MS` (12 s default) whenever the stream is healthy and drops back to
the fast `COLLIE_POLL_MS` when it isn't. **The snapshot poll stays the source of truth throughout —
a missed event costs one interval, never correctness.**
the fast `COLLIE_POLL_MS` when it transiently drops. A transport with **no** stream at all (the
Windows CLI) is a distinct steady state — not a flapping stream — so it polls on its own
`COLLIE_POLL_NO_EVENTS_MS` cadence (4 s default) with no reconnect loop. **The snapshot poll stays
the source of truth throughout — a missed event costs one interval, never correctness.**
- **The browser polls too.** `useRevalidator` → `/api/snapshot` on an adaptive interval. There is no
WebSocket fan-out to the browser and no push of state; pulling is what makes the two recovery loops
below trivial.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ deps by hand — the build runs `bun install` for you; the backend imports only
[`web-push`](https://www.npmjs.com/package/web-push) is optional and lazy (see [Web
Push](#web-push-optional)).

### Windows

Collie runs on Windows with two caveats:

- **`herdr` must be on `PATH`** (or point `HERDR_BIN_PATH` / `COLLIE_HERDR_BIN` at it). On Windows,
Herdr's control socket is a named pipe the bridge can't open directly, so it talks to Herdr by
spawning the `herdr` CLI per request instead — this needs the same-version binary reachable.
- **Change detection is poll-only.** There's no live `events.subscribe` stream over the CLI, so the
UI refreshes on a dedicated poll cadence (`COLLIE_POLL_NO_EVENTS_MS`, default 4s) rather than being
poked instantly on a herd change. Events were only ever a poke — polling is the source of truth — so
nothing but refresh latency changes. (`COLLIE_HERDR_TRANSPORT=auto|cli|socket` forces the transport;
`auto` picks the CLI on Windows and the socket elsewhere.)

The control script (`collie-ctl.sh`) shells out via `bash`, so run it under Git Bash; without
`systemd --user` it supervises the bridge as a `nohup` process with a pidfile.

## Install

On the host, not your phone. Two ways in.
Expand Down
41 changes: 41 additions & 0 deletions bridge/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { homedir } from "node:os";
import { join } from "node:path";

import type { TransportMode } from "./herdr-client.ts";

// All bridge configuration, resolved once at startup. Env-driven so the systemd unit and the
// plugin launcher can configure it without code changes. Defaults are safe for a single-user,
// tailnet-only deployment.
Expand Down Expand Up @@ -32,6 +34,20 @@ function envInt(
return n;
}

/**
* Read an env var constrained to a fixed set of string values, falling back (with a warning) on
* anything not in `allowed`. Empty/unset → `fallback`. Case-insensitive.
*/
function envEnum<T extends string>(name: string, allowed: readonly T[], fallback: T): T {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") return fallback;
const v = raw.trim().toLowerCase();
const match = allowed.find((a) => a.toLowerCase() === v);
if (match !== undefined) return match;
console.warn(`[config] ${name}="${raw}" is not one of ${allowed.join("|")} — using default ${fallback}`);
return fallback;
}

function envList(name: string): string[] {
return (process.env[name] ?? "")
.split(",")
Expand All @@ -57,6 +73,20 @@ function envBool(name: string, fallback: boolean): boolean {
export interface Config {
/** Path to Herdr's control socket. A non-Herdr-launched daemon must discover this itself. */
socketPath: string;
/**
* Path to the `herdr` binary. Only used by the CLI transport (Windows, or a forced
* {@link transportMode}), where the bridge talks to Herdr by spawning this CLI — Herdr's socket is
* a Windows named pipe Bun can't open directly (see herdr-client.ts). Herdr injects `HERDR_BIN_PATH`
* into plugin commands; we fall back to that, then `COLLIE_HERDR_BIN`, then a bare `herdr` on PATH.
* Optional: the socket transport ignores it, so mac/Linux configs (and tests) can omit it.
*/
herdrBin?: string;
/**
* Which Herdr transport to use. `auto` (default) = the CLI on Windows, the Unix socket elsewhere.
* `cli`/`socket` force one regardless of platform — the only way someone on macOS/Linux (no Windows
* box) can exercise the CLI path. Set via `COLLIE_HERDR_TRANSPORT`.
*/
transportMode: TransportMode;
/** TCP port the bridge listens on (loopback only). `tailscale serve` proxies to it. */
port: number;
/**
Expand All @@ -72,6 +102,14 @@ export interface Config {
* one of these, never correctness. Falls back to {@link pollMs} the moment the stream drops.
*/
pollIdleMs: number;
/**
* Poll cadence, ms, when the transport has NO event stream at all (the Windows CLI). This is a
* steady state, not a transient drop, so it gets its own knob between the two socket cadences:
* {@link pollMs} (fast, transient-drop) would spawn herdr.exe far too often, and {@link pollIdleMs}
* (relaxed, stream-healthy) is too stale when polling is the ONLY signal. Set via
* `COLLIE_POLL_NO_EVENTS_MS`.
*/
pollNoEventsMs: number;
/**
* Debounce window before a blocked/done transition becomes a push, ms. An agent that resolves
* within this window (you handled it at your desk) never notifies; one that fires is retracted
Expand Down Expand Up @@ -149,10 +187,13 @@ export function loadConfig(): Config {

return {
socketPath: process.env.HERDR_SOCKET_PATH ?? join(homedir(), ".config", "herdr", "herdr.sock"),
herdrBin: process.env.HERDR_BIN_PATH ?? process.env.COLLIE_HERDR_BIN ?? "herdr",
transportMode: envEnum("COLLIE_HERDR_TRANSPORT", ["auto", "cli", "socket"] as const, "auto"),
port: envInt("COLLIE_PORT", 8787, { min: 1, max: 65535 }),
host: process.env.COLLIE_HOST ?? "127.0.0.1",
pollMs: envInt("COLLIE_POLL_MS", 1500, { min: 250 }),
pollIdleMs: envInt("COLLIE_POLL_IDLE_MS", 12_000, { min: 1000 }),
pollNoEventsMs: envInt("COLLIE_POLL_NO_EVENTS_MS", 4000, { min: 1000 }),
notifyDelayMs: envInt("COLLIE_NOTIFY_DELAY_MS", 30_000, { min: 0 }),
readLines: envInt("COLLIE_READ_LINES", 200, { min: 1 }),
submitKeys: submitKeys.length ? submitKeys : ["Enter"],
Expand Down
53 changes: 35 additions & 18 deletions bridge/event-poker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";

import { buildSubscriptions, EventPoker, sameIdSet, type Subscription } from "./event-poker.ts";
import { buildSubscriptions, EventPoker, type PollMode, sameIdSet, type Subscription } from "./event-poker.ts";
import type { HerdrClient } from "./herdr-client.ts";

// EventPoker owns the stream lifecycle (ack → healthy, events → debounced poke, down → backoff
Expand All @@ -19,12 +19,15 @@ interface FakeStream {

class FakeClient {
readonly streams: FakeStream[] = [];
/** When true, subscribeEvents returns null — modelling a transport (Windows CLI) that can't stream. */
constructor(private readonly noStream = false) {}
subscribeEvents(opts: {
subscriptions: Subscription[];
onUp: () => void;
onEvent: (event: string, data: unknown) => void;
onDown: (reason: string) => void;
}): { close(): void } {
}): { close(): void } | null {
if (this.noStream) return null;
const stream: FakeStream = { ...opts, closed: false };
this.streams.push(stream);
return {
Expand All @@ -42,17 +45,17 @@ class FakeClient {
}
}

function makePoker(opts?: { debounceMs?: number; backoffMs?: number[] }) {
const client = new FakeClient();
function makePoker(opts?: { debounceMs?: number; backoffMs?: number[]; noStream?: boolean }) {
const client = new FakeClient(opts?.noStream ?? false);
const poker = new EventPoker(client as unknown as HerdrClient, {
debounceMs: opts?.debounceMs ?? 10,
backoffMs: opts?.backoffMs ?? [10, 20],
});
const pokes: number[] = [];
const health: boolean[] = [];
const modes: PollMode[] = [];
poker.onPoke(() => pokes.push(1));
poker.onHealth((h) => health.push(h));
return { client, poker, pokes, health };
poker.onPollMode((m) => modes.push(m));
return { client, poker, pokes, modes };
}

describe("buildSubscriptions / sameIdSet", () => {
Expand Down Expand Up @@ -82,16 +85,30 @@ describe("buildSubscriptions / sameIdSet", () => {
});
});

describe("EventPoker — health", () => {
test("goes healthy on ack and unhealthy on down, notifying each transition once", () => {
const { client, poker, health } = makePoker();
describe("EventPoker — poll mode", () => {
test("goes streaming on ack and fast on down, notifying each transition once", () => {
const { client, poker, modes } = makePoker();
poker.start();
expect(client.streams.length).toBe(1);
client.last.onUp();
client.last.onUp(); // duplicate ack — no second notify
expect(health).toEqual([true]);
expect(modes).toEqual(["streaming"]);
client.last.onDown("socket error");
expect(health).toEqual([true, false]);
expect(modes).toEqual(["streaming", "fast"]);
poker.stop();
});

test("a transport with no stream reports no-events once and never reconnects", async () => {
const { client, poker, modes } = makePoker({ noStream: true, backoffMs: [10] });
poker.start();
// subscribeEvents returned null: no stream tracked, mode latched to no-events.
expect(client.streams.length).toBe(0);
expect(modes).toEqual(["no-events"]);
// A changing pane set must NOT trigger a resubscribe/reconnect attempt on a poll-only transport.
poker.setAgentPanes(["w1:p1"]);
await sleep(30);
expect(client.streams.length).toBe(0);
expect(modes).toEqual(["no-events"]); // still just the one transition
poker.stop();
});
});
Expand All @@ -113,15 +130,15 @@ describe("EventPoker — debounced poke", () => {

describe("EventPoker — reconnect backoff", () => {
test("reconnects after a down per the backoff schedule and resets on the next ack", async () => {
const { client, poker, health } = makePoker({ backoffMs: [15, 40] });
const { client, poker, modes } = makePoker({ backoffMs: [15, 40] });
poker.start();
client.last.onUp();
client.last.onDown("boom");
expect(client.streams.length).toBe(1); // not yet — waiting out the backoff
await sleep(30);
expect(client.streams.length).toBe(2); // reconnected (first backoff step)
client.last.onUp(); // healthy again → backoff reset
expect(health).toEqual([true, false, true]);
client.last.onUp(); // streaming again → backoff reset
expect(modes).toEqual(["streaming", "fast", "streaming"]);
poker.stop();
});
});
Expand Down Expand Up @@ -157,13 +174,13 @@ describe("EventPoker — stop()", () => {
expect(client.streams.length).toBe(1); // the scheduled reconnect was cancelled
});

test("closing an up stream on stop() does not flip health or schedule work", async () => {
const { client, poker, health } = makePoker();
test("closing an up stream on stop() does not flip the mode or schedule work", async () => {
const { client, poker, modes } = makePoker();
poker.start();
client.last.onUp();
poker.stop();
expect(client.last.closed).toBe(true); // stop() closed it
expect(health).toEqual([true]); // the deliberate close is stale — no spurious unhealthy
expect(modes).toEqual(["streaming"]); // the deliberate close is stale — no spurious fast flip
await sleep(20);
expect(client.streams.length).toBe(1);
});
Expand Down
Loading