Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
pull_request:
push:
branches: [main]

# A new push to a PR supersedes the old run — don't burn a runner on the stale one.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: typecheck + tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2

# The MANDATORY invariant: the three version files + the newest CHANGELOG heading must agree.
- name: Version consistency
run: bash scripts/check-version.sh

- name: Install (root)
run: bun install --frozen-lockfile
- name: Typecheck (root)
run: bun run typecheck
- name: Test (bridge)
run: bun run test

- name: Install (web)
run: cd web && bun install --frozen-lockfile
- name: Typecheck (web)
run: cd web && bun run typecheck
- name: Test (web)
run: cd web && bun run test
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Release

# Every pushed vX.Y.Z tag becomes a GitHub Release whose notes carry the update commands — the
# in-app update banner links here, so the page a user lands on tells them exactly how to update.
on:
push:
tags: ["v*.*.*"]

permissions:
contents: write # create the release

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Assemble release notes (CHANGELOG section + update commands)
id: notes
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
notes="$RUNNER_TEMP/notes.md"
# This version's CHANGELOG block: everything between its `## [x.y.z]` heading and the next.
awk -v ver="$version" '
$0 ~ ("^## \\[" ver "\\]") { grab = 1; next }
grab && /^## \[/ { exit }
grab { print }
' CHANGELOG.md > "$RUNNER_TEMP/changelog.md" || true
{
if [ -s "$RUNNER_TEMP/changelog.md" ]; then
cat "$RUNNER_TEMP/changelog.md"
echo
fi
echo "## Update"
echo
echo "Update the plugin — pulls, rebuilds, restarts, and re-links (runs from any directory):"
echo
echo '```'
echo "herdr plugin action invoke update --plugin herdr.collie"
echo '```'
echo
echo "Or just restart the bridge to pick up an already-built change:"
echo
echo '```'
echo "herdr plugin action invoke restart --plugin herdr.collie"
echo '```'
} > "$notes"
echo "file=$notes" >> "$GITHUB_OUTPUT"

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --title "Collie $GITHUB_REF_NAME" --notes-file "${{ steps.notes.outputs.file }}"
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ All notable changes to Collie are recorded here. The format follows
`version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by
`scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy.

## [0.11.0] - 2026-07-15

### Added
- Pluggable harness-adapter architecture: a `HarnessAdapter` registry replaces the single Claude-only gate, Claude's detectors move to `lib/harness/claude/`, and a core race-guard engine (`lib/harness/guard.ts`) is the only module that may touch the network — an import fence (enforced by `fence.test.ts` under `bun run test`) + a conformance suite let contributors add codex/pi/opencode (see `HARNESS_CONTRIBUTING.md`)
- multiSelect AskUserQuestion support: checkbox options up-level to tappable checkbox rows (terminal is source of truth), with a closed-loop Submit that navigates the pointer to Submit and verifies before Enter (never blind-sends), plus the review/confirm screen
- Prompt overlay: interactive prompts render in a bordered `bg-card` panel that lifts the whole dialog off the terminal mirror, with elevated option rows, leading key-digit badges, and a family-aware caption
- Update notifications: a footer banner (linking to the GitHub release) and an opt-out web-push when a newer release is published upstream or the running bridge is behind the on-disk code — checks the repo's tags over anonymous HTTPS, stamps its own sources for the restart signal, a Settings "check for updates" button forces an immediate check, an `updates` notify pref is the off-switch, and update/restart are surfaced as location-independent Herdr plugin actions

### Changed
- Keys and Quick menus dock in-flow above the controls row instead of a fixed overlay, so the terminal mirror shrinks and re-pins to the bottom (ResizeObserver) — the prompt/cursor stays visible; both buttons are toggles
- Prompt option rows compacted (tighter padding, snug line-height) so a multi-option dialog fits the phone viewport
- "Sent" status toast moved from a bottom overlay (which covered the terminal tail) to a slim in-flow row below the header
- Build stamp marks a dirty working tree (`<sha>-dirty`), so the footer no longer claims HEAD when the build carries uncommitted work
- multiSelect Submit is ~2s instead of ~15s: the pointer walk re-reads the actual position each step and stops on "Submit", instead of polling for the bottom row after every key (which timed out ~2.8s per step)

### Fixed
- Prompt-select + wizard grammars: a numbered list in a dialog body (e.g. a plan's steps) no longer breaks menu detection — the menu is taken as the trailing `1..m` run, so plan-approval prompts up-level correctly

## [0.10.3] - 2026-07-12

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ push origin vX.Y.Z` (or `git push --follow-tags` so the tag ships *with* the rel
tag per shipped version on the remote. Not hook-enforced — it's on you. (Adding/adjusting this note is
a doc-only change and needs no version bump.)

**Update notice (user-facing).** The app's in-app update banner links to the newest release's GitHub
page and shows the command to run. Pushing a `v*` tag auto-creates that GitHub Release (with the
commands) via `.github/workflows/release.yml`. **Always express user-facing update/restart
instructions as Herdr plugin actions** — `herdr plugin action invoke update --plugin herdr.collie`
(or `restart`) — never `collie-ctl.sh …` / `systemctl … collie`, which depend on the caller's cwd and
the unit name; the Herdr action runs from anywhere.

## Build / run (operational facts that are easy to forget)

- **Frontend changes** (`web/`): rebuild with `bun run build` (root) or `cd web && bun run build`.
Expand Down
95 changes: 95 additions & 0 deletions HARNESS_CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Adding a harness adapter

Collie up-levels an agent's terminal dialogs (permission prompts, AskUserQuestion menus, plan
approvals, …) into native phone buttons. The per-agent knowledge that makes this safe lives in a
**harness adapter**. Claude Code is the one verified adapter today; this is how you add another
(codex, pi, opencode, …).

Read first: [`ARCHITECTURE.md`](./ARCHITECTURE.md) (the interaction loop + security model),
[`HERDR_API.md`](./HERDR_API.md) (the verified socket + `pane.send_keys` key grammar), and
[`web/src/fixtures/panes/README.md`](./web/src/fixtures/panes/README.md) (the fixture corpus).

## Architecture in one paragraph

An adapter is a [`HarnessAdapter`](./web/src/lib/harness/types.ts) —
`{ agent, buildBlocks, extractStatusLine, extractInputDraft }` — registered by its Herdr `agent`
string in [`web/src/lib/harness/registry.ts`](./web/src/lib/harness/registry.ts). The registry is the
single decision site for "which agents get grammars"; every agent absent from it keeps the universal
raw terminal mirror. Claude is the reference adapter, under
[`web/src/lib/harness/claude/`](./web/src/lib/harness/claude/): its detectors (prompt-select, wizard,
preview-select, chrome, markers) are **pure functions over `StyledLine[]`** — no pane access, no
network. Detection only says "this dialog is on screen"; the keystroke recipes and the race-guard
that actually types live elsewhere. [`guard.ts`](./web/src/lib/harness/guard.ts) is the **only** module
in `harness/` allowed to touch the network (it re-fetches the pane before a guarded keystroke) — a
**capability fence** (see below) enforces that every other harness module stays I/O-pure, because a
socket call types into a live terminal.

## Fixtures-first workflow

Detectors are developed and gated entirely against **byte-faithful pane captures** — never guessed
from screenshots. The loop:

1. In a **sandbox pane** (a scratch agent, never a real work session), drive the agent into the dialog
state you want to lift.
2. Capture it byte-for-byte:
```sh
scripts/capture-fixture.sh <paneId> <name> # paneIds: GET /api/snapshot
```
The capture is real terminal output and **this repo is public** — review every file for secrets
before `git add` (`less -R`), per
[`web/src/fixtures/panes/README.md`](./web/src/fixtures/panes/README.md).
3. Write a **pure detector** over `StyledLine[]` and test it against the fixture through the real
`parseAnsi → splitLines` pipeline (copy the shape of
[`claude/prompt-select.test.ts`](./web/src/lib/harness/claude/prompt-select.test.ts)). Anchor
detection on the **buffer tail** — a dialog that has scrolled up (real output below it) must not
match. That tail invariant is the core false-positive guard.

## The capability tier ladder

An adapter earns capability incrementally. Ship a lower tier first; each is independently useful.

- **Tier 0 — raw mirror.** Every agent gets this for free: the colored terminal mirror + slash palette
+ special-keys pad. No adapter needed. It already works.
- **Tier 1 — read-only lift.** Chrome/status/draft extraction (`extractStatusLine`,
`extractInputDraft`) plus **detection of a NEW, not-yet-wired block kind** — recognised and drawn,
but with no keystroke recipe behind it, so taps send **no keystrokes**. Mergeable **from fixtures
alone**: a mis-parse only costs cosmetics because there is no send path to fire into a terminal.
**Caveat:** this holds only for a brand-new kind. If your adapter emits an EXISTING interactive kind
(`prompt-select` / `wizard` / `multi-select`), its keystroke recipe is already live, so those taps
go hot the moment your detector matches — that is automatically **Tier 2** and must clear the full
Tier-2 bar below (corpus, notes, conformance, live-verification), not the read-only one.
- **Tier 2 — interactive.** Wiring taps to keystrokes (the buttons go hot). This is the bar that types
into a real shell, so it requires **all** of:
- a **dated fixture corpus** covering the dialog's states,
- a **choreography notes file** documenting the verified keystroke recipe (à la
[`web/src/lib/grammar/WIZARD_NOTES.md`](./web/src/lib/grammar/WIZARD_NOTES.md)),
- a green **`describeAdapterConformance`** run (the CI gate, below), and
- **maintainer live-verification against a real pane** before the send path is enabled.

### The fail-closed contract (non-negotiable)

**A detector MUST return `null` on anything it does not confidently recognise.** A partial lift is a
bug, not a nicety — it types a keystroke into a live terminal. When in doubt, fall back to the raw
mirror; the user can always drive Tier 0 by hand. Never up-level a dialog you can't fully model (e.g.
a menu numbered past 9, whose option would need the unsendable key `"10"` — bail to raw instead).

## The two gates

- **CI gate — the conformance suite.**
[`web/src/lib/harness/conformance.ts`](./web/src/lib/harness/conformance.ts) exports
`describeAdapterConformance(adapter, { ownFixtures, foreignFixtures, neutralFixtures })`. Call it
from your adapter's `*.test.ts` (see
[`conformance.test.ts`](./web/src/lib/harness/conformance.test.ts)). It asserts three invariants:
conservative detection (raw-only on foreign + neutral buffers), tail-anchoring (a dialog lifts only
at the tail), and key-grammar validity (every emittable keystroke passes `isValidHerdrKey` — the
verified `pane.send_keys` grammar: single-digit only, `ctrl+c` not `C-c`, no
`PageUp`/`Home`/`End`/`Delete`).
- **Safety gate — the capability fence.** The live enforcement is
[`web/src/lib/harness/fence.test.ts`](./web/src/lib/harness/fence.test.ts): it fails the build if any
module under `harness/` except `guard.ts` imports the network API (`@/lib/api` or a relative
`…/api`), matching the specifier anywhere in the file so a Prettier line-wrapped import can't slip
through. It runs under `bun run test` (which the pre-push hook runs). The `no-restricted-imports`
rule in [`web/eslint.config.mjs`](./web/eslint.config.mjs) encodes the same fence but is
**aspirational** — no ESLint runner is wired yet, so it does not execute; the test is the real gate.

Run both — and the full suite — with `cd web && bun run test`; typecheck with `bunx tsc --noEmit`.
61 changes: 59 additions & 2 deletions bridge/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";

Expand All @@ -18,10 +18,20 @@ import {
} from "./sessions.ts";
import { Snooze } from "./snooze.ts";
import { StateEngine } from "./state-engine.ts";
import {
bridgeStampSync,
githubTagsFetcher,
UpdateMonitor,
UpdateStateStore,
} from "./update.ts";
import { SWEEP_INTERVAL_MS, sweepUploads } from "./uploads.ts";

// How often the registry rescans the filesystem for sessions that appeared/disappeared after boot.
const SESSION_REFRESH_MS = 15_000;
// Upstream release check cadence. Releases are rare, so poll every few hours; the first check is
// delayed so we never probe the network mid-boot.
const UPDATE_FIRST_DELAY_MS = 90_000;
const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;

// Entry point: resolve config, wire the pieces, start polling and serving.
const cfg = loadConfig();
Expand All @@ -44,6 +54,51 @@ await notifyPrefs.load();
// inside record() so it can never break the user action it's auditing.
const audit = new AuditLog(fileAuditAppender(join(cfg.stateDir, "audit.log")));

// ── Update-availability monitor ───────────────────────────────────────────────
// The running plugin version, captured NOW at module load — never re-read from disk later, or a
// post-pull package.json would mask the very update we detect (same class of bug as the buildId gap).
// The bridge-source stamp is snapshotted here too, so a rebuilt-but-not-restarted process reads stale.
const bridgeDir = import.meta.dir;
const rootDir = join(bridgeDir, "..");
const currentVersion = (
JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as { version: string }
).version;

const updateStore = new UpdateStateStore(cfg);
await updateStore.load();

// The repo the release check + release links point at. Defaults to Collie's own; overridable for a
// fork (or a synthetic test target) via COLLIE_UPDATE_REPO.
const updateRepo = process.env.COLLIE_UPDATE_REPO?.trim() || "AltanS/collie";
const updateMonitor = new UpdateMonitor({
repo: updateRepo,
current: currentVersion,
startupStamp: bridgeStampSync(bridgeDir, rootDir),
fetchTags: githubTagsFetcher(updateRepo),
bridgeStamp: () => bridgeStampSync(bridgeDir, rootDir),
store: updateStore,
now: Date.now,
// The `updates` notify pref is the off-switch — update pushes bypass snooze, so this is their gate.
updatesEnabled: () => notifyPrefs.current().updates,
notify: (latest) =>
void push.send({
type: "update",
tag: "collie:update",
// No command in the body — the tap opens Settings (target below), and the update banner / linked
// release page carry the location-independent Herdr actions. Keeps this off the cwd-dependent path.
title: "Collie update available",
body: `Version ${latest} is available`,
target: "settings",
}),
});

// First check delayed (don't probe mid-boot); then every few hours. unref() so neither timer holds
// the process open; both cleared on shutdown.
const updateFirstCheck = setTimeout(() => void updateMonitor.checkRelease(), UPDATE_FIRST_DELAY_MS);
updateFirstCheck.unref();
const updateTimer = setInterval(() => void updateMonitor.checkRelease(), UPDATE_INTERVAL_MS);
updateTimer.unref();

// ── Per-session runtime factory ──────────────────────────────────────────────
// One HerdrClient + StateEngine + EventPoker + NotificationCoordinator per herdr session. The
// registry calls this for the primary at construction and for each session discovered later. Push,
Expand Down Expand Up @@ -132,7 +187,7 @@ const sweepTimer = setInterval(() => {
}, SWEEP_INTERVAL_MS);
sweepTimer.unref();

const server = startServer({ cfg, registry, push, snooze, notifyPrefs, audit });
const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit });

const shutdown = async () => {
console.log("\n[bridge] shutting down");
Expand All @@ -142,6 +197,8 @@ const shutdown = async () => {
clearInterval(refreshTimer);
registry.disposeAll();
clearInterval(sweepTimer);
clearTimeout(updateFirstCheck);
clearInterval(updateTimer);
process.exit(0);
};
process.on("SIGINT", shutdown);
Expand Down
Loading
Loading