cowbell
by WSHAPER
README.md
<div align="center">
# cowbell
**Drive [herdr](https://github.com/herdrdev/herdr) fleets from any AI agent.**
Properly typed MCP tools for terminal workspace, pane, and agent control.
[](https://github.com/WSHAPER/cowbell/actions/workflows/ci.yml)
[](LICENSE)
[](#requirements)
[](#requirements)
</div>
---
## Why
AI agents don't stick to prose protocols. You can write a detailed playbook for driving a terminal multiplexer, but the agent will improvise. Reviewing shell calls in agent output is error-prone: the agent's native language is tool calls, not command strings.
cowbell exposes [herdr](https://github.com/herdrdev/herdr)'s workspace, pane, and agent operations as typed MCP tools with schemas the agent can discover and self-check against. Instead of hoping the agent constructs the right `herdr agent prompt --until idle --timeout 90000 target "text"` invocation, it calls a tool with named parameters, validated before execution.
When the CLI underneath changes, the drift tripwire names the exact break instead of your fleet silently dying.
## Quick start
```bash
git clone https://github.com/WSHAPER/cowbell.git
cd cowbell
npm install
npm test
```
Add to your MCP client config:
```json
{
"mcpServers": {
"cowbell": {
"command": "npx",
"args": ["tsx", "src/bin/stdio.ts"],
"cwd": "/path/to/cowbell"
}
}
}
```
## Tools
### Workspaces
| Tool | Description |
| ------------------ | ----------------------------------------- |
| `workspace_list` | List herdr workspaces |
| `workspace_get` | Show one workspace |
| `workspace_create` | Create a workspace, returns the root pane |
| `workspace_close` | Close a workspace |
| `workspace_focus` | Focus a workspace |
### Panes
| Tool | Description |
| ---------------- | ------------------------------ |
| `pane_get` | Get pane details |
| `pane_read` | Read terminal output |
| `pane_send_text` | Type text without Enter |
| `pane_send_keys` | Send key presses |
| `pane_run` | Type a command and press Enter |
### Agents
| Tool | Description |
| ---------------- | ----------------------------------------------------------------------------------- |
| `agent_list` | List recognized agents |
| `agent_get` | Show one agent |
| `agent_read` | Read agent terminal output (pane view) |
| `agent_messages` | Read an agent's structured transcript (channel-labeled) |
| `agent_health` | One verdict: process liveness + state (blind spots flagged) + transcript error tail |
| `agent_prompt` | Submit a prompt (types + Enter; `deliver_when: now \| idle`) |
| `agent_wait` | Block until a status |
| `agent_start` | Start an agent in an existing pane (`model` or `args`) |
| `agent_focus` | Focus an agent pane |
| `agent_rename` | Rename an agent |
| `models_list` | List local opencode models, default marked (discovery) |
### Waits (session-owned)
| Tool | Description |
| ----------------- | ---------------------------------------------------------------------------------- |
| `wait_start` | Non-blocking wait until a status; returns `{wait_id}` (timeout REQUIRED) |
| `file_wait_start` | Non-blocking report-marker watch: file matches a regex AND is quiet `stability_ms` |
| `wait_status` | One (`wait_id`) or all waits owned by THIS cowbell process |
| `wait_cancel` | Cancel one (`wait_id`) or all owned waits |
Every herdr-backed tool accepts optional `machine` or `remote` parameters (mutually exclusive). Without them, commands run against your local herdr session. Remote workspaces and agents are invisible in the local UI, so callers opt in per call. Exceptions: `models_list` (local opencode API) and the wait tools (local herdr events socket) take no transport params, and neither do the machine tools; the saved SSH-machine registry lives in the local herdr client by construction.
### Tabs
| Tool | Description |
| ------------ | ------------------------------------------------------- |
| `tab_list` | List tabs (active workspace by default) |
| `tab_create` | Create a tab (active workspace by default) |
| `tab_get` | Show one tab |
| `tab_focus` | Focus a tab |
| `tab_rename` | Rename a tab (label keeps spaces verbatim, never split) |
| `tab_close` | Close a tab |
### Sessions
| Tool | Description |
| ---------------- | ------------------------------------------------ |
| `session_list` | List named persistent sessions |
| `session_stop` | Stop a named persistent session |
| `session_delete` | Delete a stopped session by name (stop it first) |
### Machines
| Tool | Description |
| ----------------- | ----------------------------------------------------------------------- |
| `machine_list` | List machines saved in the local SSH registry |
| `machine_status` | Check saved machines without auth prompting (all when label/id omitted) |
| `machine_rename` | Rename a saved SSH machine (sets the sidebar label) |
| `machine_remove` | Remove a saved SSH machine from the local registry |
| `machine_enable` | Enable a saved SSH machine in the local registry |
| `machine_disable` | Disable a saved SSH machine in the local registry |
The saved-SSH-machine registry lives in the LOCAL herdr client, so these tools take no `machine`/`remote` transport params. `machine add` and `machine reconnect` are deliberately not wrapped: interactive SSH authentication flows.
## Transcript view vs pane view
Two ways to read an agent, two different questions:
- **`agent_messages`, the transcript view.** Resolves the target through `herdr agent get`; recognized agents carry an `agent_session{source, agent, kind, value}` reference (native opencode `ses_...` ids). For opencode sessions it queries the local opencode background-service HTTP API (`opencode api` handles discovery + auth) and returns a validated, normalized transcript: user/assistant turns with texts, reasoning, tool name + status, plus event entries (`system`, `synthetic`, `idle`, `agent-switched`, ...). Every result carries an explicit `channel` field:
- `channel: "structured"`: read from the native session API.
- `channel: "pane_fallback"`: no native session available (pane-level agent), a non-opencode session, a `machine`/`remote` transport, or the API was unreachable; the result says why in `reason` and carries the pane text instead. Pane scraping here is a deliberate keep, not an accident.
- **`agent_read`, the pane view.** The raw terminal: spinner frames, blocked-state prompts, exactly what is on screen right now. This is what you want for UI-state questions ("is it showing a permission prompt?"); the transcript is what you want for "what has it said/done?"
## Queue-of-one prompt delivery
`agent_prompt` accepts `deliver_when`:
- `"now"` (default): submit immediately; `wait`/`until`/`timeout_ms` behave as one `herdr agent prompt` call.
- `"idle"`: first blocks on `herdr agent wait --until idle` (event-driven; `timeout_ms` guards that wait), then submits, so the text lands as the agent's next input. Exactly two calls, no general queue, no persisted state, no daemon; cowbell stays a stateless validated wrapper. Combining `"idle"` with `wait`/`until` is a validation error (the flow already owns the waiting).
## Model selection: typed at start, discovered read-only
`agent_start` accepts `model`, a non-empty selector like `zai/glm-5.3` (optional `#variant` suffix), compiled per kind after `--` in the herdr argv: opencode spawns run `mini -m <model>` (opencode's full TUI takes no model flag at all, verified live on v2.0.27; `mini` is its interactive surface that does), every other kind gets the common `-m <model>`. herdr's argv stays authoritative; cowbell never invents flags. `model` and `args` are mutually exclusive (a validation error, no silent precedence): use `model` for the common case, `args` when you need full control of the agent binary's argv. The value is NOT checked against a live model list at start time; spawn stays offline-safe, and an unknown selector still spawns (the failure surfaces only when the model is first used), so discover selectors first. One trade-off: opencode model spawns run the minimal interface, and herdr links a native agent_session only for the full TUI, so `agent_messages` on such agents degrades to its documented pane_fallback channel.
Discovery is the separate, read-only tool: `models_list` queries the local opencode background service (`GET /api/model` + `GET /api/model/default` via the `opencode api` CLI, which handles discovery + auth) and returns
```json
{
"models": [
{
"model": "zai/glm-5.3",
"provider": "zai",
"name": "GLM-5.3",
"variants": ["low", "high", "max"],
"default": true
}
],
"default": "zai/glm-5.3"
}
```
The `model` field is exactly the string `agent_start` wants (and `opencode mini -m` / `opencode run -m` accept); the default is returned at the top level and flagged on its entry (null when nothing is configured). No routing, no pool economy; just typed selection plus honest discovery.
## Session-owned waits
`wait_start` / `wait_status` / `wait_cancel` fix the collateral/leak/hang class of blocking waits **by construction**: a wait is a resource owned by the cowbell process that created it.
- **In-memory, per-process, nothing else.** All wait state lives inside one cowbell instance: no files, no daemon, no durability. When the process dies, its waits die with it: an orchestrator's `pkill` can never touch another session's waits, and stale waits can never accumulate anywhere. Cross-session invisibility is the point: `wait_status` reports exactly what THIS process owns.
- **Non-blocking by design.** `wait_start` returns `{wait_id}` immediately; poll `wait_status` (states: `pending | fired | timed_out`, `result` carries the status that fired). It is not a queue: no ordering, no priority, no delivery semantics; just owned status observation.
- `timeout_ms` is **required** and enforced client-side; no wait can outlive its deadline, no matter what the transport does.
- **Direct socket subscription.** Waits subscribe to the local herdr socket's `pane.agent_status_changed` events (newline-delimited JSON over `~/.config/herdr/herdr.sock`, or `HERDR_SOCKET_PATH`) rather than shelling out to `herdr agent wait`; that CLI primitive hangs intermittently, so cowbell routes around it. The current status is checked first via an authoritative `agent.list` read (already-matched targets fire immediately, no subscription at all).
- **`events_lost` handling.** Per herdr socket semantics, when a subscriber falls behind retained history the server sends an `events_lost` error and closes the connection. cowbell then resubscribes and reconciles from a fresh authoritative read; buffered events are never replayed, and the read is the truth after a gap. The same recovery covers plain stream death, and the deadline timer is never cleared during recovery (clearing it would resurrect the hang class).
```json
// wait_status -> { "waits": [ ... ] } or one record:
{
"wait_id": "wait_1f0c…",
"target": "w4C:p1",
"pane_id": "w4C:p1",
"until": ["idle"],
"state": "fired",
"result": "idle",
"started_ms": 1792177611227
}
```
## Drift detection
```
src/herdr/spec*.ts every herdr command, single source (split by domain)
tests/fixtures/herdr-surface.txt captured --help output
tests/upstream.drift.test.ts cross-checks spec vs fixture
src/herdr/socket.ts raw herdr events-socket client (waits)
tests/fixtures/herdr-events-surface.json captured events.subscribe contract
tests/herdr-events.drift.test.ts cross-checks socket tokens vs fixture
src/opencode/spec.ts every opencode api call, single source
tests/fixtures/opencode-session-message.json captured operation
tests/fixtures/opencode-models.json captured model.list + model.default
tests/opencode.drift.test.ts cross-checks spec vs fixture
tests/opencode.models.drift.test.ts cross-checks model surface vs fixture
```
When herdr renames a flag or subcommand:
```bash
npm run reference:update # pull upstream source
npm run reference:surface # re-capture --help tree
npm test # names the exact broken builder
```
When herdr renames the events socket surface (`events.subscribe`, the
`pane.agent_status_changed` subscription variant, or the pushed event
contract the wait registry reads):
```bash
npm run reference:herdr-events # re-capture the events contract
npm test # names the exact token
```
When opencode renames the message operation, its parameters, or the
message/part type unions:
```bash
bash scripts/dump-opencode-surface.sh # re-capture the operation
npm test # names the exact token
```
When opencode renames the model operations (`model.list`,
`model.default`) or drops a `Model.Info` key the models view pins:
```bash
npm run reference:opencode-models # re-capture both operations
npm test # names the exact token
```
## Requirements
- Node.js >= 22
- [herdr](https://github.com/herdrdev/herdr) >= 0.9.5 on PATH (for local tools) or reachable via SSH (for remote tools)
## Development
```bash
npm run build # compile to dist/
npm run typecheck # tsc --noEmit
npm run lint # eslint (typechecked)
npm run test # vitest
npm run e2e # full loop against live herdr
npm run e2e:wait-own-pane -- /tmp/evidence.json # own-pane wait-until-idle live e2e
npm run bench # e2e + hyperfine
```
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues