Skip to content

Latest commit

 

History

History
328 lines (283 loc) · 22.1 KB

File metadata and controls

328 lines (283 loc) · 22.1 KB

Herdr socket API — empirically verified (v0.7.2, protocol 16)

Probed live against a running Herdr server, most recently re-probed 2026-07-07 and cross-checked against the bundled machine-readable schema — herdr api schema [--json | --output PATH] (schema_version 1, covering requests, responses, errors, and events) is now the fastest way to re-derive this contract without probing. These are the facts the bridge is built on; they confirm the socket assumptions behind the design in ARCHITECTURE.md.

Transport

  • Unix domain socket at $HERDR_SOCKET_PATH (default ~/.config/herdr/herdr.sock).
  • Newline-delimited JSON. Request: {"id": <string>, "method": <string>, "params": <object>}.
    • id must be a string (integer → invalid_request).
  • Response: {"id", "result": {"type": "...", ...}} or {"id": "", "error": {"code", "message"}}.
  • RPC is one-shot: the server closes the connection after a single response. Send one request per connection. (Confirmed: a second request on the same connection never replies — the socket is already closed.)
  • Malformed requests close the connection too, and the serde error message names the missing/ wrong field — which is how this contract was reverse-engineered without side effects.
  • Exception: events.subscribe keeps the connection open and streams events.

Methods the bridge uses (verified params)

Method Params Returns (result.type)
session.snapshot {} session_snapshotsnapshot{workspaces[], tabs[], panes[], agents[], layouts[], focused_*}
workspace.list {} workspace_listworkspaces[]
pane.list {} pane_listpanes[]
pane.read {pane_id, source, lines, format} pane_readread{text, truncated, revision}
pane.send_text {pane_id, text} (ack)
pane.send_keys {pane_id, keys} (ack)
agent.send {target, text} (ack) — writes literal text, no Enter
  • pane.read sourcevisible | recent | recent_unwrapped | detectionsnake_case on the wire: recent-unwrapped gets invalid_request: unknown variant (live-probed 2026-08-03, herdr 0.7.5; the variant list above is quoted from that error). detection's semantics are unverified. formattext | ansi.
    • recent_unwrapped is a no-op for Claude panes (byte-identical to recent across working and idle panes, live-probed 2026-08-03): Claude Code runs on the alt screen, so the read is the visible grid, and its renderer hard-wraps prose at the pane width — there are no soft-wrapped rows for the unwrap to merge. It only differs on scrollback-accumulating panes (shells: one probe measured 199 → 188 lines with logical lines up to 222 cols re-joined).

    • A recent text read can scroll the pane it reads. Herdr's agent-automation docs state that for an idle, recognized agent at the bottom of its transcript, recent / recent_unwrapped reads "automatically use the agent's mouse-scroll interface" when lines asks for more than the visible screen, collecting overlapping pages and "returning the viewport to the bottom before completing the read". Claude runs on the alt screen, which has no host scrollback, so that is the only way those rows can be reached — and the operator watches their terminal scroll up and snap back, once per read. Live-probed 2026-08-10 (herdr 0.8.0), idle claude pane, viewport_rows: 71:

      source lines format elapsed lines returned
      recent 70 text 0.00s 71
      recent 71 text 0.00s 72
      recent 72 text 0.85s 73
      recent 400 text 13.8s 401
      recent 200 ansi 0.00s 71
      recent 600 ansi 0.00s 71
      visible 600 ansi 0.00s 71

      The threshold is exactly lines > viewport_rows; one row over is enough. format: "ansi" was never observed to harvest — that is an observation across the probes above, not a documented guarantee, so don't lean on it. visible is immune by construction: it is the rendered viewport, clamped to it however large lines is, so there is nothing above to collect. A background poll that reads panes on a timer must therefore use visible (bridge/state-engine.ts); anything asking for scrollback is asking to move the operator's screen, and should be a deliberate, user-initiated read. format: "text" returns clean plain text (no ANSI escapes) → safe to render, no XSS surface.

  • agent.send writes literal text only; to submit a reply, follow with an Enter keypress (pane.send_keys {keys: ["Enter"]}) — submit-key name needs live confirmation per agent.
  • pane.send_text writes RAW bytes — no bracketed paste. Live-probed 2026-07-27 (herdr 0.7.4) by sending into a pane running /usr/bin/cat -v, which renders control bytes visibly: the text came back bare, with no ^[[200~ / ^[[201~ framing. Two consequences worth keeping:
    • A PTY is an ordered byte stream, so a following send_keys cannot overtake the text. Any "the Enter arrived before the text" theory is dead on arrival — including blaming the settle delay between the two calls (sendReplySteps, bridge/server.ts). See #34, where that was the first and wrong hypothesis.
    • A \n inside text is delivered as a real newline keypress, not as pasted content. What the TUI does with it (submit vs. insert) is the harness's choice, not something the paste framing hides.
  • An ack means "herdr took the bytes", never "the TUI acted on them". Both send_text and send_keys return before the target program has read, let alone rendered, anything. So a successful RPC pair is not evidence a reply was delivered — a focused TUI dialog can swallow the text and consume the Enter with both calls reporting success. Anything that needs delivery confirmed must read the pane back and look (web/src/lib/reply-action.ts).

Herdr answers no OSC 10 / OSC 11 background query, and relays SGR verbatim (live-probed 2026-07-29 in a pane running a raw-mode stty probe: both queries returned nothing).

Two things follow, and both matter to anything that renders pane output:

  • A harness that asks what background it is on gets no answer and falls back to dark. Codex emits both queries at startup; with no reply its output is dark-authored (#f6e2b7, #abdfa7 — L=0.774 and 0.642, light values that only make sense on a dark ground). Collie cannot answer either: it reads a rendered buffer downstream of the PTY, so the negotiation happens between the harness and herdr on a channel Collie does not own.
  • Herdr does not rewrite escape codes into its own theme. format:"ansi" returns what the program wrote — \x1b[38;5;1m stays a palette index, never a resolved RGB. So herdr's [theme] setting governs how herdr's own UI paints a pane, not what a client receives, and the same palette-index colour can legitimately differ between the desktop TUI and Collie (which applies its own 16-slot table). See .adr/0002.

session.snapshot — one RPC, the whole herd (new in 0.7.2)

session.snapshot {}{"type":"session_snapshot","snapshot":{...}}. One-shot like every RPC — no special connection handling, no streaming. The snapshot bundles everything a client needs to bootstrap or resync in a single round trip:

{ "version":"0.7.2", "protocol":16,
  "workspaces":[ /* same record shape as workspace.list → workspaces[] */ ],
  "tabs":      [ /* same record shape as tab.list → tabs[] */ ],
  "panes":     [ /* same record shape as pane.list → panes[] */ ],
  "agents":    [ /* precomputed subset of panes[] that carry an agent */ ],
  "layouts":   [ /* per-tab PaneLayoutSnapshot, see layout.updated below */ ],
  "focused_workspace_id":"w0…", "focused_tab_id":"w0…:t1", "focused_pane_id":"w0…:p1" }
  // focused_* are string | null

Docs-blessed pattern: bootstrap with session.snapshotevents.subscribe → re-session.snapshot on reconnect or staleness. CLI mirror: herdr api snapshot prints the raw reply — handy for diffing shapes without writing a client.

Collie's bridge polls this method (one RPC per tick instead of the workspace.list + pane.list

  • tab.list trio) and falls back to the trio on older servers that don't know the method. Old-server detection: the error reply is {"id":"","error":{"code":"invalid_request","message":"invalid request: unknown variant `session.snapshot`, expected one of ..."}} — the bridge treats an unknown variant error on session.snapshot specifically as "fall back," not a hard failure.

pane.send_keys key grammar (verified)

The server validates every key and rejects unknown names with {error:{code:"invalid_key", message:"unsupported key <X>"}} (pane lookup happens first, so probe against a real pane). Empirically enumerated against Herdr 0.7.0 — it is NOT tmux syntax:

  • Special keys (bare, case-insensitive): Up Down Left Right Tab Enter Escape Space Backspace (alias BS), and function keys F1F12.
  • Literal single characters: a one-character string is typed as that character — digits ("1", "2", …), letters, punctuation (live-verified 2026-07-04). This is what Collie's prompt-select taps send: {keys:["1"]} answers a permission dialog; {keys:["2","Enter"]} picks option 2 of an AskUserQuestion select.
  • Modifier chords (join with +): ctrl+c, ctrl+u, ctrl+d, ctrl+l, ctrl+r, shift+tab, ctrl+left, alt+f, … Modifiers: ctrl / shift / alt / cmd / super (case-insensitive). This is the same grammar as config.toml [keys].
  • Multi-modifier chords work, in any modifier order (live-verified 2026-07-20 against 0.7.3 on a throwaway sandbox pane, with PageUpinvalid_key in the same run as proof the validator was active): ctrl+shift+p / shift+ctrl+p, alt+shift+p / shift+alt+p, triple ctrl+alt+shift+p / ctrl+shift+alt+p, and modifier+special alt+Up all ack. Independently confirmed against 0.7.4 by @bnivanov (issue #20).
  • NOT supported (all return invalid_key): tmux-style C-c / BTab; and the keys PageUp PageDown Home End Insert Delete (in any spelling). There is no forward-delete and no scrollback paging via keys — the web mirror is scrollable instead.
  • ⚠️ Consequence: Ctrl-C is ctrl+c, not C-c. Multiple keys per call are applied in order, e.g. {keys:["Down","Enter"]}.
  • Re-checked against 0.7.2's bundled schema: unchanged.

Rename methods — set an object's label (verified)

Three sibling RPCs set a display label on a workspace, tab, or pane. Live-verified 2026-07-18.

Method Params label Returns (result.type) Event
pane.rename {pane_id, label} string | nullnull clears pane_info{pane} none
tab.rename {tab_id, label} string (non-null) tab_info{tab} tab_renamed
workspace.rename {workspace_id, label} string (non-null) workspace_info{workspace} workspace_renamed
  • pane.rename is the odd one out, twice over. Its label accepts null, which clears the label (the label key then disappears from the pane record); the sibling two take a non-null string. And it emits NO event — a renamed pane surfaces only on the next session.snapshot / pane.list poll. tab.rename / workspace.rename DO emit: tab_renamed{type, tab_id, workspace_id, label}, workspace_renamed{type, workspace_id, label} (the event field is snake_case on the stream, as everywhere).
  • Errors: an unknown id → {code:"pane_not_found" | "tab_not_found" | "workspace_not_found", message:"<kind> <id> not found"}.
  • No length limit; empty string accepted (stored as-is on tab/workspace). Re-verified on tab.rename 2026-07-19: label:"" is stored literally (the tab's label becomes empty — it does not reset to the default number), and label:null is rejected with {code:"invalid_request", message:"invalid request: invalid type: null, expected a string"} — confirming tabs/workspaces have no "clear" (only pane.rename clears, via null). Collie makes its own opposite choices per object: a blank pane "Save" clears (blank → null), while a blank tab "Save" is refused client- and bridge-side, since a literal-empty tab chip is useless. See bridge/server.ts (normalizeTabLabel).
  • Undocumented field: once set, a pane's label rides along as label?: string in pane.list, pane.get, pane.current, and session.snapshot panes (omitted when unset — so it's absent from the base pane shape below). Workspaces already expose label; tabs likewise.
  • agent.rename {target, name} also exists in the schema, but it is a DIFFERENT operation (renames an agent session, not a pane/tab/workspace) — unverified and unwired by Collie. Listed only so it isn't mistaken for the label renames above.

Close methods — kill a pane or a whole tab (verified)

Two sibling structural ops remove panes. tab.close live-verified 2026-07-19 on the sandbox session.

Method Params Returns (result.type) Event Error (unknown id)
pane.close {pane_id} ok pane.closed pane_not_found
tab.close {tab_id} (schema: TabTarget) ok tab.closed tab_not_found
  • tab.close is a BULK pane-close: closing a tab terminates EVERY pane inside it. Verified by creating a throwaway tab holding a plain shell pane, then tab.close {tab_id} — the next session.snapshot no longer lists the tab or its inner pane. So it's no more privileged than closing those panes one-by-one (which pane.close already allows) — same remote-shell threat model.
  • Success is a bare {"result":{"type":"ok"}} (same shape as pane.close), not a record reply like the renames — there's nothing left to describe. The closure surfaces on the next snapshot poll; tab.close also emits a tab_closed event (which Collie doesn't consume).
  • Errors: unknown id → {code:"tab_not_found", message:"tab <id> not found"}; a missing tab_id{code:"invalid_request", message:"invalid request: missing field `tab_id` …"}.

Move methods — reorder tabs and workspaces (verified)

Two sibling structural ops reorder objects. Both live-verified 2026-07-20 on the sandbox session.

Method Params Returns (result.type)
tab.move {tab_id, insert_index} tab_list → that workspace's tabs, post-move order
workspace.move {workspace_id, insert_index} workspace_list → all workspaces, post-move order
  • Tabs: array order is authoritative, number is stable. tab.move reorders the array returned by tab.list / session.snapshot without renumbering — after moving t2 (number 2) before t1 (number 1), the snapshot lists [t2, t1] with numbers unchanged. Herdr itself renders array order: the default label of an unlabeled tab is positional (post-move, t2 displays as "1"). ⚠️ Consequence: a client that sorts tabs by number un-does the user's reorder — render tabs in array order, never number order.
  • Workspaces are the opposite: workspace.move renumbers. After moving w7 (number 5) to the front, it becomes number 1 and every other workspace shifts — number always equals position, so array order and number order never disagree and sorting workspaces by number is safe.
  • insert_index counts positions in the PRE-removal list (workspace-scoped for tabs, clamped at the end). Moving an item toward the end therefore needs target + 1: with [t2, t1], tab.move {tab_id: t2, insert_index: 1} is a no-op; insert_index: 2 yields [t1, t2].
  • The event catalog lists sibling tab.moved / workspace.moved events (0.7.2); emission not observed here (no live subscription during the probe).

Object shapes (observed)

// workspace.list → workspaces[]
{ "workspace_id":"w0000000000000", "number":1, "label":"demo",
  "focused":false, "pane_count":2, "tab_count":1,
  "active_tab_id":"w0000000000000:t1", "agent_status":"done" }

// pane.list → panes[]
{ "pane_id":"w0000000000000:p1", "terminal_id":"term_…", "workspace_id":"w0000000000000",
  "tab_id":"w0000000000000:t1", "focused":false, "cwd":"/…/demo",
  "foreground_cwd":"/…/demo", "agent":"claude", "agent_status":"done",
  "agent_session":{"source":"herdr:claude","agent":"claude","kind":"id","value":""},
  "revision":0,
  "scroll":{"offset_from_bottom":0,"max_offset_from_bottom":128,"viewport_rows":48} }

agent_statusidle | working | blocked | done | unknown. Panes without an agent omit/null agent.

agent_session has TWO kinds, and it can outlive the agent that reported it (live-verified 2026-07-29 against claude, codex and pi panes). Each harness's herdr integration reports through pane.report_agent_session, and what it reports differs:

  • kind:"id" + a uuid — claude (source:"herdr:claude") and codex (source:"herdr:codex", from codex's SessionStart hook; verified on codex 0.145.0).
  • kind:"path" + an absolute path to the log file — pi (source:"herdr:pi"). pi's integration prefers agent_session_path over an id whenever its session manager has a file open.

Two consequences. A harness only reports at all once herdr integration install <agent> has been run — pi's was missing on this host and the pane simply carried no session of its own. And Herdr keeps reporting the LAST session announced for a pane, so relaunching a pane's agent as a different harness leaves the previous one's ref behind: a pane running pi was observed still advertising a herdr:claude id. The record's own agent field is what distinguishes the two — compare it against the pane's agent before trusting the ref (bridge/state-engine.ts).

Pane records now carry scroll (new in 0.7.2, live-verified 2026-07-07): pane.list, pane.get, pane.current, and session.snapshot panes all include scroll: {offset_from_bottom, max_offset_from_bottom, viewport_rows} | null (all uint64; offset_from_bottom == 0 means the pane is scrolled to the bottom). Collie doesn't consume it yet.

revision is a stub on Herdr 0.7.x (live-verified 2026-07-05 on 0.7.0; reconfirmed unchanged on 0.7.2, live-verified 2026-07-07): pane.read, pane.list, and session.snapshot all return revision: 0 for every pane, including actively-changing ones. Treat it as advisory / future-proofing only — never as a load-bearing change detector (Collie's prompt-select race guard re-derives the menu from content for exactly this reason).

Event stream (now wired: event-poked polling)

events.subscribe {subscriptions: [{type, pane_id?}]} keeps the connection open and streams events. Empty subscriptions: [] → ack only, no events ever arrive. The ack and the event frames are shaped differently — worth calling out explicitly:

  • Ack: {"id":"<id>","result":{"type":"subscription_started"}}.
  • Event: {"event":"<snake_case>","data":{...}}. Note the split: subscription type values are dot-form (pane.agent_status_changed), but the event field on each streamed line is snake_case (pane_agent_status_changed). Real example line: {"data":{"pane_id":"w6:p3","type":"pane_agent_detected","workspace_id":"w6"},"event":"pane_agent_detected"}.

The full event catalog (subscription type values), 0.7.2 additions marked *:

workspace.created  workspace.updated  workspace.renamed  workspace.closed  workspace.focused  workspace.moved *
worktree.created   worktree.opened    worktree.removed
tab.created        tab.closed         tab.focused        tab.renamed       tab.moved *
pane.created       pane.closed        pane.focused       pane.moved        pane.exited
pane.agent_detected  pane.output_matched  pane.agent_status_changed
layout.updated *   pane.scroll_changed *

* = new to the catalog in 0.7.2 (workspace.moved, tab.moved, layout.updated, pane.scroll_changed); workspace.updated and pane.focused were already listed but are called out here too since they're easy to miss in the block above.

  • Scoping, verified: pane.agent_status_changed, pane.scroll_changed, and pane.output_matched require pane_id in the subscription (omit it → invalid_request: missing field `pane_id` ). Everything else is global — subscribe with just {type}.
  • layout.updated (global) payload is a full PaneLayoutSnapshot: {workspace_id, tab_id, zoomed, area, focused_pane_id, panes:[{pane_id,focused,rect}], splits:[{id,direction,ratio,rect}]} — the same shape as session.snapshot's layouts[].
  • pane.scroll_changed (pane-scoped) payload: {pane_id, workspace_id, scroll} (scroll shape as in "Object shapes" above).
  • Rich payloads: pane_created / workspace_created carry the full pane/workspace record, not just ids. pane_exited carries {pane_id, workspace_id}. pane_agent_detected carries {pane_id, workspace_id, agent?} and can fire in herd-wide bursts on re-detection — consumers should debounce it.

Collie now polls session.snapshot (above) as the source of truth, and additionally holds a long-lived events.subscribe stream — global lifecycle events plus a per-agent-pane pane.agent_status_changed subscription, resubscribed whenever the agent-pane set changes — purely to poke the poller: an event triggers an immediate debounced re-poll, it never updates state by itself. While the stream is healthy, interval polling relaxes to COLLIE_POLL_IDLE_MS (default 12000 ms, min 1000 ms); when the stream is down or reconnecting, it drops back to the fast COLLIE_POLL_MS cadence. Events accelerate; the snapshot stays authoritative — a missed event costs one interval, never correctness.

Also visible in the 0.7.2 schema but unused by Collie: events.wait, pane.send_input, agent.list, pane.wait_for_output — run herdr api schema for the full ~80-method catalog.