Skip to content

feat(herdr): run og inside a terminal multiplexer - #21

Open
jmvbambico wants to merge 78 commits into
mainfrom
integration/herdr-bridge
Open

jmvbambico wants to merge 78 commits into
mainfrom
integration/herdr-bridge

Conversation

@jmvbambico

@jmvbambico jmvbambico commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Runs og inside herdr: one space per conversation, each
delegated worker a tab inside it, live agent-status badges, and a terminal UI
over the session tree. The browser and the ngrok tunnel keep working — herdr
becomes the desk interface, the web UI stays the phone one.

og is fully usable without herdr. The multiplexer is a view, never the
runtime; og start herdr on a machine without it warns and starts normally.

SPACES                                    AGENTS                      grouped
○ projects            ← yours, untouched  ○ New Alignment
○ New Alignment       ← a conversation        Claude Code      [working]
○ Omnigent Herdr …    ← a conversation    ○ Omnigent Herdr … · coder_zen:space-per-root
                                              OpenCode (Zen)   [blocked]

What's in it

Surface What it does
og start herdr [tunneled] [--use tunnl] opens a space for this directory, runs og chat in it, optionally splits og agents beside it, attaches the TUI
og herdr the bridge daemon — projects live sessions into spaces and tabs, keeps badges current
og agents [--all] TUI over the session tree. Not herdr-specific — reads Omnigent's API, runs in any terminal

Four modules: a herdr socket client, an Omnigent session watcher (knows nothing
about herdr), the bridge, and the launcher. Plus installer/registry.json
gaining a multiplexers catalog and a new install question.

Gate

pytest tests/ -q                      819 passed
bash -n bin/og install.sh             clean
shellcheck -S error bin/og install.sh clean

Zero failures, including the two test_og_install.py PYTHONPATH cases that
fail on main for anyone who has actually installed og (#22 folded in; #23's
spec folded in as docs/MUX-LAUNCHER.md).

Found by running it, after the tests were already green

The author installed this and ran a real session. Five things the suite could
not have told us:

Symptom Cause
no agents in herdr's sidebar, no worker tabs the bridge was never started — og start herdr opened the space and ran og chat, and nothing ran the daemon that projects sessions
a browser opened itself Omnigent's server opens one at boot — server/app.py:1674, gated by OMNIGENT_ACCOUNTS_AUTO_OPEN, default True
50/50 split pane.split was never passed a ratio
the address was never visible og start prints the QR in the terminal that exec herdr then replaces
— (the QR fits a 20% pane: 27 cols, not the 41 first measured — see below)

All five fixed. The bridge is now a proper daemon (OG_HERDR_PIDFILE, started
with the server, stopped by og stop); the browser is suppressed only on
the mux path and never when the operator set the variable themselves; the split
is 80/20; og chat prints the access banner; and og agents gained an a
toggle showing a content-sized QR panel on demand.

A measurement error worth recording

The QR was first measured at 41 columns by taking len() of qrencode's
ANSIUTF8 output — which counts ANSI colour escapes as visible characters. The
true width is 27. On that bad number the agents pane looked too narrow for a
QR and the feature was nearly built URL-only.

It was caught because a worker independently measured via qrencode -t ASCII
and halved, and the two disagreed. There is now a test asserting that a block
carrying colour escapes is measured by visible columns — the one defect class
that would have silently degraded this feature forever with every test green.

pytest tests/ -q                      787 passed
bash -n bin/og install.sh             clean
shellcheck -S error bin/og install.sh clean

Zero failures. The two test_og_install.py PYTHONPATH cases that fail on
main for anyone who has actually installed og are fixed here too (#22 is
folded in; #23's spec is folded in as docs/MUX-LAUNCHER.md).

Verified live, not just in tests

Run against the author's real herdr session and Omnigent server:

  • Case A (fresh directory) — space created, tab renamed hivemind, og chat
    started, pending record written keyed by cwd
  • The handshake — the bridge claimed the launcher's space rather than
    creating a second one: pending emptied, owner preserved, setup not re-run.
    This was the design's central risk and it holds.
  • Case B (directory already has a session) — focused the existing space,
    created nothing
  • Workers as tabs — dispatched sub-agents appeared inside their parent's
    space, with working / blocked / idle badges
  • Panes really attach — a live Omnigent REPL rendering inside a herdr pane,
    spinner, prompt, status bar and all
  • og agents — the session tree with correct status glyphs, scoped by cwd

23 blocking defects found and fixed

The split by how each was found is the most useful thing in this PR:

Found by Count
Probing the live herdr socket / Omnigent API 10
Reviewer reading code 7
Running the thing for real 4
Escalated from a finding filed as a "suggestion" 2

The ones that would have shipped broken

  1. pane.run is not a herdr API method. It's a CLI subcommand. Every call
    was rejected — four tabs opened, all empty. No pane would ever have run
    anything.
    Survived 223 tests, a full cross-vendor review and a live dry run;
    caught only by running the bridge for real.
  2. A herdr connection serves exactly ONE request. The client reused them, so
    the bridge's four-calls-per-session flow failed on every second call.
  3. tab.create takes workspace_id, not workspace. serde drops unknown
    keys silently, so --workspace did nothing and every tab landed in the
    focused workspace — including the author's real one.
  4. pending_elicitations_count is plural. The code read the singular MCP
    spelling, so blocked — the state a human most needs — could never fire.
  5. The session listing is paginated, newest-first, default 20. The root
    session was pushed off page 1 and never projected; a session crossing the page
    boundary looked identical to a deletion, so the bridge closed a live pane.

Defects of the author's own making

Several were wrong contracts written from the endpoint that was to hand rather
than the one the code calls — pending_elicitation_count, session_id, kind
and workspace all came from sys_session_get_info's MCP output instead of the
REST shape. The implementers built the contract faithfully. docs/HERDR.md now
carries the measured contract so the next change doesn't re-derive it.

The guard that stops it recurring

herdr publishes its full contract (herdr api schema --json). It's vendored as
tests/fixtures/herdr_api_schema.json, and a conformance test drives every
client wrapper against a recording fake, captures each emitted frame, and asserts
the method is a real variant with valid, complete parameters.

It's driven by introspecting the client, not a hand-written list, because a
hand-written list rots exactly the way the comments did. The distinction that
matters: a strict fake catches a bad method; only the schema catches a bad
parameter. When workspace was reintroduced as an experiment, the fake was
perfectly happy — the schema check was the only thing that failed.

What it cannot do

  • herdr can't host Omnigent's agents. "tmux" is hardcoded in
    inner/terminal.py with no backend hook, and each terminal is its own private
    tmux server. Nothing for a shim to impersonate.
  • ACP workers have no terminal (inner/acp_executor.py:514 — stdio
    JSON-RPC, no pty), so those panes show the attach event stream, not a TTY.
  • Free-form elicitations decline in the REPL with "use the web UI".
  • The bridge never answers an approval. Omnigent broadcasts an elicitation to
    every client but parks a single Future — first resolver wins. Auto-answering
    would race the user's own browser. An AST test pins this.

Non-blocking, left for later

Listed in docs/HERDR.md §Open items: a never-fetched session retires only when
it comes back into view; duplicate rows for one id are last-wins; pane_read
accepts five payload spellings where one is in use; _announce_listing can lose
a notice if stderr fails.

🤖 Generated with Claude Code

jmvbambico and others added 30 commits October 9, 2026 23:23
Stdlib-only NDJSON-over-AF_UNIX client for herdr 0.9.3: socket path
resolution (arg > HERDR_SOCKET_PATH > HERDR_SESSION > default), request
framing with monotonic ids, result unwrapping, HerdrError for both error
frames and transport failures, wrappers for the workspace/tab/pane/agent
methods, and an events.subscribe generator that skips unsolicited event
frames on the shared connection.
Projects live Omnigent sessions into herdr panes: one tab per session whose
pane runs `omnigent attach <session_id>`. Adds installer/og_herdr.py (the
bridge + CLI) and an `og herdr` subcommand in bin/og that forwards to it.

The two low-level sibling modules (og_herdr_client, og_herdr_watch) live on
other branches, so the bridge imports them lazily and the tests inject fakes
— nothing touches a real socket or server. The bridge is idempotent per
session, tolerates HerdrError without aborting, and by contract only REPORTS
the blocked state: it never resolves an elicitation, which would race the
user's own client for Omnigent's single answer Future.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Polls GET /v1/sessions?kind=any (the server default hides sub-agent
workers) and diffs the listing into added/removed/changed SessionEvents.
Also parses the per-session SSE tail with an incremental parser, so a
frame split across reads still dispatches. No herdr knowledge, no
websocket dependency, no token ever logged.
sun_path in an AF_UNIX sockaddr caps at 104 bytes on macOS and 108 on
Linux. pytest's tmp_path is already long on a stock macOS TMPDIR
(/private/var/folders/<..>/T/ + pytest-of-<user>/pytest-N/<test-name><n>/),
so parametrised names such as
test_report_agent_rejects_invalid_state_before_socket[IDLE] pushed the
bind past the cap and every test errored with 'AF_UNIX path too long'
before its body ran -- a flake that only showed up off the author's
short-TMPDIR machine.

Give each fake its own tempfile.mkdtemp(dir="/tmp") with a 6-char
socket name (~25 bytes, independent of the test name) and remove it in
close(), so nothing is left behind. Adds a test asserting the path still
fits the 104-byte cap.
Captures what was measured against Omnigent 0.17.0 and herdr 0.9.3 while
scoping this: why the pane command is `omnigent attach`, why multiple
clients are safe, why agent state is pushed rather than detected, and the
three things that cannot work (replacing Omnigent's tmux layer, detecting
an agent through a nested tmux attach, giving an ACP worker a terminal).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_module_imports_without_sibling_modules asserted the sibling modules were
ABSENT from disk — true only while the fan-out branches were separate, and
inverted the moment they batch together. It was never evidence of the property
that matters: that og_herdr.py imports cleanly when a sibling is missing.

Rewrite it to parse og_herdr.py with ast and split its imports by where they
execute, asserting none of the sibling imports is module-level while the ones
inside function bodies are present (a positive control so deleting them cannot
pass vacuously). This is independent of file layout and of test execution
order, so it holds whether or not the siblings are on disk.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
… event

Three blocking findings, all about partial failure while handling a single
event:

- `_added` now records the tab/pane mapping immediately after `tab_create`
  and tracks setup progress, so a failure in pane_run/report_agent/
  report_metadata no longer leaves the session unmapped. A redelivered
  `added` resumes the remaining steps on the existing pane instead of
  opening a second tab (Finding 1).
- `tab_create` results are validated before use: a reply without usable
  `tab_id`/`pane_id` stores no mapping and reports a reconcile-style error,
  and a named tab with no pane is closed best-effort so it is not orphaned
  (Finding 2). `_id_of` now only checks the real `pane_id`/`tab_id` keys.
- `_removed` runs release_agent/tab_close before dropping the mapping, so a
  failed cleanup survives for a retry; a `not_found` reply is treated as
  already-gone success (Finding 3).

Tests: fakes now return the real `tab_id`/`pane_id` keys; added coverage for
partial-setup retry, unusable ids, orphan-tab cleanup, failed removal retry,
and not_found removal.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The old test_events_do_not_alias_server_objects asserted on
event.previous, which poll_once builds from the *first* poll's stored
object — a dict unrelated to the one it mutated. It passed with the
copies removed from poll_once, so it reported coverage that did not
exist. Mutating the fixture listing proves nothing either: StubOpener
JSON-encodes it, so poll_once sees a fresh decode.

Reach the real object through watcher._seen instead, rewrite it after
the poll, and assert the event still reports the poll-time values;
then assert neither event dict is that object. The second direction
gets its own test: scribbling on event.session must not edit the
watcher's retained state into a fabricated `changed` event. Both go
red when the corresponding dict() in poll_once is removed.

Also drops the sys.path insert and the now-unused `sys` import: the
root conftest.py already puts installer/ on the path, which is how the
other test modules import their module.
An independent reviewer raised one blocking and two non-blocking findings
against the bridge removal fixes.

- _removed wrapped release_agent and tab_close in one try, so a `not_found`
  from release_agent skipped tab_close entirely and then dropped the mapping,
  orphaning an open tab nothing tracked. A `not_found` from release_agent only
  means "no marker to release" (setup may have failed part-way), not that the
  tab is gone. The two calls are now handled separately: release_agent
  `not_found` is nothing-to-do and we PROCEED to tab_close; only a `not_found`
  from tab_close drops the mapping; any other error keeps it for a retry.
- _id_of iterated ("pane_id", "tab_id") for both sub-objects, so it was only
  accidentally correct — the live root_pane carries BOTH keys. The caller now
  names the id it wants, and the live shape is recorded in the docstring.
- _reject_partial_create could only clean up an orphan tab when a tab id was
  present; a pane id without a tab id left the pane behind. That case now
  closes the pane best-effort with the same record-don't-raise handling.

Adds tests for both not_found directions, the retry-after-tab_close-failure
path, key-explicit _id_of lookups on the real shape, and both asymmetric
partial-create cleanups. Stdlib only; the AST test proving the bridge never
resolves an elicitation still passes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
…rror frames

The test suite was green because the fake server encoded a wrong belief about
herdr. All three defects below were measured against a live herdr 0.9.1 server
by probing it directly; the fake now models what the server actually does.

1. herdr serves ONE request per connection and then hangs up, whatever the
   method. Measured: workspace.list x3 on one connection -> reply, EOF,
   BrokenPipeError; ping then agent.list -> pong, BrokenPipeError. The client
   reused one connection, so every second call failed — for the bridge's
   four-call session (tab_create, pane_run, report_agent, report_metadata) that
   was alternating failure on every event. call() is now one connection per
   request: connect, send, read, close. It also sets aside a live subscription
   socket rather than borrowing and closing it. events_subscribe keeps its own
   connection, since a subscription is the connection. close() and the context
   manager still work. Module docstring, class docstring and the _write
   no-retry comment rewritten to say what is true now.

2. events.subscribe takes `subscriptions`, not `types`. Measured: `{}` and
   `{"types":[...]}` are both rejected with "missing field `subscriptions`";
   entries are internally-tagged objects, a per-type entry also needs `pane_id`,
   and `[]` is accepted as the catch-all. The parameter is `subscriptions`,
   entries pass through verbatim, the default is `[]`, and `types=` is kept as a
   source-compatible alias. All of it documented in the docstring.

3. A request herdr cannot parse is answered with `"id": ""`, not our request
   id. _await_reply skipped it on the id mismatch, read EOF, and reported
   `disconnected` — discarding the server's actual message. An empty-or-absent
   id on a frame carrying `error` is now raised as the answer to the in-flight
   request. Also skip any frame carrying an `event` key before the id check:
   defensive, not observed (ids are client-generated and monotonic), but it
   removes the class for one condition. Same empty-id carve-out applied to the
   subscribe ack loop.

Tests: the fake closes after every reply (real behaviour), which is the change
that would have caught #1. Added four-calls-in-a-row regression, the exact
subscribe params frame plus the `[]` default and the `types=` alias, the
empty-id error frame on both the call and subscribe paths, an event frame
carrying the awaited id, and a call not disturbing a live subscription. The
hand-written peer-close test became a hangup-before-reply test — against a
server that hangs up after every reply, "the second call fails" is no longer
what a correct client does. RESULTS["tab.create"] and test_tab_create updated
to the real reply shape (tab.tab_id / root_pane.pane_id, root_pane carrying
both). Dropped the unused `client` fixture from the tests that build their own.

Gate: /opt/homebrew/bin/pytest tests/ -q -> 453 passed.
…okens to the server

Four findings from review of installer/og_herdr_watch.py:

* watch() called poll_once() bare, and the only production caller
  (og_herdr.py's run_forever) wraps nothing — one refused connection or
  malformed listing stopped the daemon until someone noticed and restarted it.
  The loop now reports the failure on stderr (URL userinfo scrubbed, token
  never printed), retries on a backoff that doubles to MAX_POLL_BACKOFF and
  resets on the first success, and resumes yielding on its own. poll_once()
  stays strict; _seen is replaced as its last statement, so a failed poll
  leaves the last observed state intact and recovery does not emit a
  removed/added storm for every live session.

* _SSEParser grew without bound on a peer that never sent a newline or a
  dispatching blank line. Both buffers are now capped; over the cap the
  pending junk is dropped (resynchronising on the line terminator) rather than
  raised, because a live tail must survive one malformed frame.

* Bare-CR line endings were not parsed, though the docstring claimed them.
  Fixed rather than documented away: CR, LF and CRLF all terminate a line,
  with a lone CR at the end of a buffer held back so a split CRLF is not read
  as two terminators.

* discover_token sorted for a loopback key and returned the first usable
  token, so a store holding only a remote server's token sent that bearer to
  the loopback server. It now matches the token to the base URL it was issued
  for and returns None when nothing matches; the loopback preference is gone
  because a matching key is by definition this server.

The dict-subclass concern in herdr_state is not addressed: every session here
comes from json.loads, which only produces plain dicts.
events_subscribe advertised types=[<name>, ...] and mapped the names into
subscriptions, but a list of strings is precisely what herdr refuses:

  {"subscriptions": ["pane.agent_status_changed"]}
    -> invalid type: string, expected internally tagged enum Subscription

So the alias could only ever produce a request guaranteed to be rejected on
the wire, and the rejection surfaced at stream time as a server error rather
than at the call site. There is no in-repo caller of the alias, so keeping
it preserved compatibility with a call that could not work.

The parameter is gone; events_subscribe(subscriptions=None) only, still
defaulting to the measured catch-all []. The docstring keeps all four
measured wire results and now states why no types= spelling exists.

The alias test becomes one that pins the supported form: subscriptions
entries are sent through verbatim, and passing types= raises TypeError at
argument binding with nothing written to the socket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three facts about the herdr socket were only discovered by probing a running
server, each after passing a full suite against a fake built on the opposite
assumption: a connection serves exactly one request, events.subscribe takes
`subscriptions` rather than a list of type names, and an unparseable request
answers with an empty id. The first would have broken the feature outright.

Also records the operational behaviour the bridge needs in order to run for
days (poll backoff, SSE buffer caps, per-server token matching, resumable
pane setup) and the non-blocking findings review left for later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… field

`_added` read `session.get("workspace")`, but a live session row has no
`workspace` key at all, so the lookup always yielded None and every pane
opened in $HOME instead of the repo the session works in. The `or` hid the
missing field behind what looked like a deliberate default.

Drop the lookup. Add `--cwd` (default: the process cwd) threaded through
Bridge as `cwd=None`, falling back to os.getcwd(); `og herdr` runs from the
repo whose sessions it projects, so that is the directory to use. Record why
the cwd comes from the invocation rather than the row, citing the live-server
verification so nobody goes looking for the field again.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Four defects, all from the watcher being written against a contract that was
taken from sys_session_get_info's MCP output instead of the REST API.

* The elicitation field is PLURAL. The row carries
  pending_elicitations_count; reading pending_elicitation_count meant the key
  never matched and "blocked" never fired — the one state a human most needs
  to see, silently dead. Plural decides; the MCP spelling stays a fallback for
  a caller that hands us a session dict from that tool.

* No pagination, so sessions silently vanished. The listing is newest-first
  with a default page of 20: the root session — the conversation the user is
  driving — was pushed off page 1 and never seen, and a session crossing the
  page boundary between polls looked exactly like a deletion, which the bridge
  turns into a closed tab. list_sessions now walks ?after=<last_id> while
  has_more is true, asking for limit=100 explicitly, bounded by
  MAX_SESSION_PAGES=10 and MAX_LISTED_SESSIONS=1000 (order-of-magnitude
  headroom over the ~100 sessions a busy machine holds) so one poll cannot be
  made unbounded.

* Nothing filtered dead sessions: all 91 idle and 8 failed ones here would
  have been projected. New module-level should_project() decides what is worth
  projecting and poll_once applies it, so the bridge only ever sees the live
  set: a root while listed and not archived (it reads idle the whole time it
  waits for the human to type — retiring it would close the pane they are
  typing into), a sub-agent only while running or holding elicitations.

* The envelope and id keys were guesses that happened to work through
  ("sessions", "items", "data") and ("session_id", "id") fallbacks. data and
  id are now the documented primary path, pinned by tests built from a row
  captured off a live 0.17.0 server, and the module docstring records the
  measured envelope and row keys — replacing the list that claimed a row has
  session_id, kind and workspace. It has none of them.

Unchanged: bounded poll backoff, SSE caps, bare-CR handling, per-server token
matching, event copy semantics. Still no herdr in this module, stdlib only,
no test opens a socket.
The cap left a residual from the last round: when the walk stopped early, the
rows it never fetched were indistinguishable from deleted ones, so poll_once
reported them removed and the bridge closed their tabs. On this machine the
first rows to fall past the cap are the oldest — the roots, the panes the user
is typing into — so the symptom the cap was supposed to avoid was reachable
from the cap itself, at a threshold it merely delayed.

A truncated listing now says so, and that changes what an absence means:

* _fetch_listing returns (rows, truncated) and reports every way the walk can
  stop short — page cap, row cap, an envelope promising more with no cursor to
  continue with, a cursor the server repeats. list_sessions keeps its list
  return and delegates.
* poll_once emits no removals on a truncated poll, and merges the fresh rows
  over _seen instead of replacing it. Replacing would only move the damage:
  this poll's phantom removals become the next complete poll's phantom
  `added`, and the consumer opens a second tab for a session whose pane is
  already there. `added` and `changed` keep flowing from the rows that were
  fetched, so only the absent-based events go quiet.
* One stderr line on entering the truncated state and one on leaving it, and
  nothing in between: a watcher polling every few seconds in a permanently
  truncated state would otherwise bury the rest of the log.

The cost is named in poll_once's docstring rather than hidden: a session that
finished during a truncated window keeps a stale pane until a complete listing
arrives, and if no listing is ever complete again, removals never resume. That
is a stale pane instead of a wrongly closed one, and the stderr line is what
makes the condition findable.

Unchanged: the plural-primary elicitation count and its documented singular
fallback, cursor pagination with the three caps, should_project, the measured
envelope/row shape, bounded poll backoff, SSE caps, bare-CR handling, per-server
token matching, event copy semantics, no herdr in this module.
The session listing was written up here from sys_session_get_info's MCP
output rather than the REST response, and the wrong version reached all
three implementation contracts. Measured against a live 0.17.0 server, a
row spells the field pending_elicitations_count (plural, so the singular
key never matched and `blocked` could never fire), keys the id as `id`,
carries no `kind` and no `workspace`, and arrives in a paginated
{"object":"list","data":[...]} envelope with no server-side status filter.

Records why pagination is a correctness problem rather than a performance
one, why truncation must suppress removals and merge rather than replace,
that the suppression depends on the listing being newest-first, and which
sessions earn a pane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two blocking defects from the independent review.

* poll_once suppressed EVERY removal on a truncated poll, which conflated two
  absences that mean opposite things. A row it fetched and rejected — a
  sub-agent that went idle, a row that came back archived — is direct evidence
  the session is done, and suppressing it left a finished worker holding a
  stale pane for as long as the listing stayed too big. Absence is now read in
  three states: fetched-and-rejected retires (truncated or not), never-fetched
  is kept and emits nothing, and unchanged keeps flowing as before.

  poll_once therefore tracks every id that came back, not only the ones that
  passed should_project. On a duplicated id the last row wins, for the
  retained object and the verdict alike, which is the rule fresh already
  applied to the object; the id keeps its first appearance's position so event
  order still matches listing order. The merge still preserves never-fetched
  ids, and now explicitly drops the retired ones so the next poll does not
  retire them a second time.

* The SSE frame cap cleared the data buffer at the line that broke it but did
  not mark the frame as finished, so the rest of that frame accumulated as
  though it were fresh and the next blank line dispatched it: an oversized
  frame followed by `data: {"ok": true}` handed the consumer a fragment it
  cannot tell from real data, and the consumer is a UI that acts on what it
  gets. The cap now poisons the frame, _line swallows every line until the
  blank line that would have dispatched it, and that boundary clears the flag
  so the next frame — multi-line included — parses normally. flush() refuses
  to dispatch at all while a frame is poisoned, so a stream that ends
  mid-discard cannot turn its remainder into a frame.

Six new tests cover each behaviour. Verified against the pre-fix module: seven
of the new tests fail there, including the reviewer's exact SSE example.
Noted in passing: two of the flush tests were written calling feed() without
draining it — a generator, so the parser was never fed and the tests passed
vacuously. Fixed, and the comment in the test says why the drain matters.
jmvbambico and others added 15 commits October 10, 2026 13:12
Pending-by-cwd with existence-based pruning (no TTL to tune), og agents
scoped by cwd the same way the launcher is, and warn-and-proceed for a
missing multiplexer.

Records why the first could not go the other way: og chat is `exec
omnigent run`, so the session id does not exist when the launcher needs
it, and the launcher ends in `exec herdr` so it cannot wait to learn it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Frames multiplexer support as a catalog the way supported agents already
are, and says plainly what a registry row buys -- detection and listing,
not a working backend.

The Agent status column is the point: the backend contract is
capability-based, not lowest-common-denominator. create/close/run/focus/
rename are required, report-agent-state is optional. herdr has it; tmux
would not, so a tmux backend gives named windows and nothing more.

Also flags the naming hazard for a future tmux backend: tmux is already a
required dependency, since Omnigent runs every native agent terminal in
its own private tmux server, so `og start tmux` would make the word mean
two things in one tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`og agents` renders the orchestrator (a ROOT session) and its delegated
workers, live. The listing drives the view; the 121 KB DETAIL row is read
lazily, only for the report snippet and the root's workspace/harness.

The renderer is a pure function from state to lines, so `--once` drives it
with no tty and curses only paints the same list. Nothing is ever written
to a session: the module only GETs.

Budget: one listing walk per refresh, plus detail fetches only for (1) a
live root's first sighting, (2) a worker's first idle report, (3) the
selected row. Cached by session id, so a stable listing costs nothing
after the first refresh.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The state file moves from `{"version": 1, "workspaces": {ws_id: sid}}` to
`{"version": 2, "spaces": {sid: {...}}, "pending": {cwd: {...}}}`.

v1 is keyed by workspace id, which answers "is this space ours?" — all that
--cleanup needs — but not "does this session already have a space?", which is
the whole of adoption. Once `og start <mux>` exists, a space may be on screen
for a session this process has no memory of: the launcher made it, or the
bridge restarted and its map died with the process. Either way the old code
opened a second space for one conversation.

v1 is UPGRADED IN PLACE at read time, never discarded: a v1 file can describe
spaces live on the operator's screen, and dropping it orphans them from
--cleanup. The upgrade is a transpose, so the workspace ids survive, and it
records only what v1 held — no tab id, no pane id, none invented, because a
guessed pane id is exactly what a stale adoption hands to every later
report_agent.

In `_added`, in order: already in `_recs` (unchanged), then ADOPT from
`spaces[sid]`, then CLAIM a `pending[cwd]` whose directory matches this
session's live `workspace`, then create. Roots only — a worker that reached
the launcher's directory before its root would otherwise claim the launcher's
whole space and leave the root to open a second one. `owner` survives a claim:
it records who opened the space, not who is looking at it.

Every adopt and claim requires the workspace to appear in workspace.list, and
`workspace.list` answers THREE ways — present, absent, or "could not read".
Only the first two are answers; the third adopts nothing and prunes nothing,
because treating unknown as absent would turn one transient herdr hiccup into
a duplicate space for every live session. A stale record is dropped and a
fresh space created: a dead pane id fails silently forever, a duplicate space
the operator can close.

Adoption runs no setup steps. The pane is already live and already running
what it should; re-running would type the attach command a second time, and
for a launcher's space that is a second co-drive client on one session — the
race `_state` exists to avoid. What it does instead is rename the space to
the session title, which is the launcher's entire reason for labelling from
the directory.

Pending records are pruned by EXISTENCE — drop any whose workspace is not in
workspace.list. No TTL and no clock: a space closed by hand must stop being
claimable immediately, and a clock would need a field only time could advance.

--cleanup closes both halves, for the same reason: a pending record is a space
something opened on the operator's behalf, and leaving it behind means
`og start <mux>` has no way back.

Two bugs the new tests caught while being written: a worker's removal
forgetting its ROOT's space record, and a dry run writing the state file it
had just read (which would consume the pending record the next real run
needed). Both fixed, both now guarded.
Add a `multiplexers` catalog to registry.json as a sibling of `agents`, with
one herdr row and a `$multiplexers` comment stating plainly that a row buys
detection and listing only -- the backend code still has to exist per
multiplexer, exactly as an agent row needs a harness plugin. tmux is
deliberately absent (no backend, no `og start tmux`).

Add multiplexer detection alongside the agent PATH scan, through an injectable
`which` seam so tests never depend on the real PATH (this machine has herdr).
Surface it in `--check` as an optional tool -- a missing multiplexer is a warn,
never an err, and never touches the exit code -- and in `--questions` as
`detected_multiplexers` plus the catalog.

Add the `herdr_agents_pane` install question (bool, default true), asked always
like tunnl_ssh_key and noting it is ignored without herdr. It reaches
og-install.json via the interactive plan and, crucially, via `main()`'s --plan
path, which is how `og update` re-applies a live state file that predates the
key: it now loads, gets the default, and re-applies cleanly.

Tests: catalog shape (+ no tmux row), injected-seam detection, --check exit 0
without a multiplexer, the --questions entry, the old-state idempotence case,
and the state round-trip.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Three small cleanups in the herdr bridge, no behaviour change:

- og_herdr.main(): delete a duplicated run_forever block that sat below
  the return above it and could never run. Pre-existing at HEAD. The
  live forever path still catches KeyboardInterrupt and returns 0.

- SessionWatcher._fetch_listing -> fetch_listing. og_agents reached
  across modules into a private to read the `truncated` flag that
  list_sessions discards. Renamed public with a docstring stating the
  (rows, truncated) contract; no alias kept, so a straggler caller fails
  loudly. Callers updated: og_herdr_watch (internal x2) and og_agents.

- HerdrClient.workspace_rename: workspace.rename had no wrapper, so the
  bridge sent it through the generic call() and the conformance guard in
  tests/test_og_herdr_client.py — which enumerates wrappers by
  introspection — could not see the frame. Wrapper added beside
  workspace_create/workspace_close; og_herdr now routes through it.

Tests: fetch_listing is public and _fetch_listing is gone; the conformance
inventory and the named workspace-wrappers test now cover workspace_rename;
the exact workspace.rename frame is pinned; main() returns 0 on
KeyboardInterrupt. The bridge-side rename schema test is kept as the
specific case, now driving the named wrapper.

741 passed (was 738; three new tests).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
`og start herdr` now starts the server as usual and then opens the
session inside a herdr space: a space for this directory, a chat (or an
attach) in it, and the TUI handed to the operator. Four cases, keyed on
whether we are already inside herdr and whether this directory already
has a live session.

bin/og:
- Split argument parsing into parse_start_args so the multiplexer keyword
  is stripped from ANY position BEFORE the bare-argument arm can read it
  as an ngrok reserved domain. `og start herdr` no longer resolves to
  "tunneled on ngrok reserved domain herdr".
- Mux ids come from registry.json's multiplexers array, with a fallback
  row (herdr) so a checkout predating that array degrades to "no
  multiplexer" rather than to a keyword that does nothing.
- Widen the help-block sed range (2,20 -> 2,26) for the two added lines.

installer/og_herdr_launch.py (new):
- The launcher itself. Everything is an injected dependency (herdr
  client, session watcher, server spawn), so no test reaches a socket.
- Adoption asks workspace.list EVERY time and treats an unreadable
  listing as an error: "nothing recorded", "not in the listing" and
  "could not read the listing" are three different answers.
- No redundant workspace.focus after workspace.create(focus=True) in
  case B; case C still focuses explicitly because it is about to close
  the pane it is in, which lives in a different space.
- The pending record (cases A/D) is written through og_herdr's own
  writer so its reader/`--cleanup` can claim it by directory.

tests/test_og_herdr_launch.py (new): fake client + stub watcher covering
all four cases, the agents split, the create-reply guard, and the
install settings.

Verified: pytest tests/ -q -> 761 passed; bash -n bin/og; shellcheck -S
error bin/og install.sh.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
tests/test_og_start_mux.py did not exist, though bin/og:2165 cites it by
name as "the guard that cannot rot". Add it: the missing guard is exactly
how a phantom herdr method shipped past a green suite.

Drives parse_start_args by sourcing bin/og in a subshell (never `og start`,
which would boot a server) and pins:
  - the 8-row argv -> mode/domain/provider/mux table, including the
    regression that `tunneled mydomain` and bare `mydomain` still read as
    an ngrok reserved domain now that a mux keyword can appear anywhere;
  - the bare-argument info line, so the explicit form is distinguishable;
  - every top-level dispatch arm appears in the `sed -n '2,26p'` help block;
  - `og start herdr` on a PATH with no herdr warns and degrades, proven by
    reaching the NEXT precondition (no agent bundle) instead of a mux fatal.

HOME is sandboxed per test; nothing starts a server, touches ~/.omnigent,
or runs herdr. Verified to bite: a scratch copy with the mux-strip loop
neutered fails the herdr rows with the old ngrok-domain reading.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
@jmvbambico jmvbambico changed the title feat(herdr): run og from herdr instead of the browser feat(herdr): run og inside a terminal multiplexer Oct 10, 2026
jmvbambico and others added 14 commits October 10, 2026 22:12
Three defects from a real `og start herdr` session.

1. The bridge was never started, so nothing was ever projected: the
   space opened with one chat pane and no agent tabs. bin/og now starts
   og_herdr.py as a detached daemon (nohup + og-herdr.pid + log under
   logs/herdr) just before the launcher takes over, idempotent on a live
   pid; cmd_stop ends it and clears its pidfile.

2. Omnigent's server auto-opened a browser at boot
   (OMNIGENT_ACCOUNTS_AUTO_OPEN defaults to true, server/app.py:1674).
   A mux launch now exports it as 0 before the server boots — scoped to
   the mux path and only when the operator has not set it themselves.

3. pane.split had no ratio, so herdr halved the tab. Pass 0.8 (the
   fraction RETAINED by the split pane), giving the chat 80% and the
   agents sidebar 20%.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
A multiplexer launch replaces `og start`'s terminal with `exec <mux>`, so
the QR + URL show_access prints there is never seen — the chat pane only
ever ran `og chat`, which printed one info line and exec'd the REPL.

cmd_chat now recovers the address and prints the SAME banner before the
exec, on a tty only:

- `access_url()` is the one recovery helper: a live tunnel URL (validated
  against its owner's pid) else the LAN address, setting OG_PUBLIC_URL and
  OG_MODE for show_access. It returns non-zero and touches nothing when
  neither resolves, so no banner beats a wrong address. cmd_start keeps its
  own logic on purpose — its branches establish the address and owe the
  provider log / missing-LAN-IP failure a read-only recovery cannot give.
- print_qr() now skips a code too wide for the pane (measured from the
  ASCII render, bytes/2, locale-independent; `tput cols` with an 80-column
  fallback), so a narrow pane keeps the URL line and drops the QR. Missing
  qrencode still degrades to nothing.

tests/test_og_chat_access.py drives cmd_chat under a pty with a fake
omnigent on PATH; 5 cases fail on the pre-fix tree (bug witnesses) and 2
pass on both (regression guards).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Inside a multiplexer launch the terminal `og start` printed the access
banner into is replaced by `exec`, so the address is never visible in
the split. Add a bottom panel to `og agents`, toggled with `a` (default
off, also `--access` for the one-shot view), that shows the QR and the
address on demand.

The panel is part of `render`'s lines, computed from state on the Frame,
so the renderer stays pure and the whole feature is testable through
`render` / `--once` with no tty.

- The QR is drawn only when it fits the pane's width. Fit is measured
  with the escapes stripped (`_visible_width`): ANSIUTF8 wraps each line
  in SGR escapes, and measuring those with `len()` reads a 27-column code
  as ~41 and silently degrades the panel to URL-only forever.
- `qrencode` absent -> URL line only. No address resolved -> one honest
  line naming no address, because a banner pointing somewhere dead is
  worse than none.
- Address recovery lives in one function (`access_info`): the tunnel
  cache `$OMNIGENT_HOME/og-tunnel.url` first, else the LAN IP at the
  server's port. It names bin/og's counterparts (`access_url`,
  `tunnel_url`, `lan_ip`, `show_access`) so a future consolidation is one
  edit. Deliberately a second implementation, not a call into bin/og:
  `og agents` is standalone and must not depend on `og` being on PATH.
- Panel height is content-sized; the agent list keeps a floor of
  ACCESS_LIST_FLOOR (3) rows when it is open.

Module stays read-only toward Omnigent (the AST guard passes).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
`access_info` read `og-tunnel.url` as-is, unlike bin/og's `tunnel_url`, so a
tunnel that crashed but left its cache behind was advertised as authoritative —
the worst address to hand a phone, since scanning it yields nothing. The panel
was built on "a wrong address is worse than no address"; this closes that gap.

`_cached_tunnel_url` mirrors the three gates of bin/og's `tunnel_url` in its
order: a non-empty URL file, a provider record naming ngrok or tunnl, and that
provider's pidfile holding a live pid (`_pid_alive`, the Python form of
`kill -0`). Any gate failing falls through to the LAN address, exactly as if no
cache existed.

It does NOT clear the cache, unlike bin/og: `og agents` is a read-only viewer
(the AST guard pins that), and the operator's state files belong to `og`'s
lifecycle, not a viewer's. The stale value is ignored; `og` cleans it up.

Tests cover a dead pid, an unrecognised provider, a missing pidfile, a live pid
and a missing/empty URL file, plus a byte-for-byte assertion that no case
modifies or deletes the cache.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
…den the pane

Three defects from a real `og start herdr` on adoboflix.

1. TWO spaces for one conversation. The pending record was written AFTER
   `og chat` was typed, but `og chat` mints the session the moment it runs and
   the herdr bridge is already polling (bin/og starts it first) — so a poll in
   that window saw a root session with no record for its directory and opened
   its own space. Write the pending record BEFORE the send, in cases A and D
   (both go through `_open_space`). Every id it needs is in the
   workspace.create reply, so nothing is waited on. If `pane_run` then fails,
   the record is dropped and the space closed, so no pending record points at
   a dead space; the create-missing-pane cleanup runs before the record, so it
   cannot dangle one either.

2. The browser still opened. `suppress_accounts_autopen` silenced omnigent's
   SERVER auto-open, which (a) receives the var fine — `nohup env VAR=1 cmd`
   inherits the environment — and (b) could never fire for og anyway, because
   that open is gated on a LOOPBACK OMNIGENT_ACCOUNTS_BASE_URL and og always
   points it at the LAN/tunnel address (docs/TROUBLESHOOTING.md says so). The
   browser came from og's OWN openers on the same run: `og chat` ->
   `omnigent run` opens the conversation for an interactive launch, and
   cmd_start's needs_setup path calls open_in_browser itself. `omnigent run`
   has no --no-open and no env var for it, so the mux suppression now also
   sets the `auto_open_conversation` config key (only when unset — an explicit
   choice wins) and gates the needs_setup open behind a flag that prints the
   URL instead. The `has_autopen`/value is NOT the bug: env_var_is_truthy
   treats 1/true/yes as true, so "0" was already falsy.

3. The agents pane was too narrow. 0.8 left it ~43 columns on the operator's
   215-column terminal and clipped the ~52-column `og agents` footer. 0.7
   gives ~30% (~64 columns), enough for the footer and the 27-column QR. A
   pane that cannot show its own controls is broken.

Tests: the ordering is asserted at the instant the chat line is sent (the new
tests fail on the pre-fix tree); the config write and the needs_setup gate are
pinned with a stubbed omnigent.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
…ig write

Follow-up to b902c15. That fix shut the chat pane's browser by running
`omnigent config set --global auto_open_conversation=false` — a persistent,
machine-wide change to the operator's omnigent install, triggered by one
`og start herdr`. It overstepped: they asked for no browser on the mux path, not
to change omnigent's default everywhere, and nothing would have told them og
did it.

Replace it with a per-pane environment variable. `webbrowser` — which
`omnigent run` uses to open the live conversation — honours $BROWSER, and
pointing that at `true(1)` makes open() run `/usr/bin/true <url>`: it succeeds
and opens nothing. The pane runs `og chat` in a FRESH shell, so the value has to
travel to the pane rather than the launcher; herdr takes an env map on every
pane-creating call and installs it in the pane's shell.

- og_herdr_client: `workspace_create` / `tab_create` take an optional `env` map,
  sent only when set — every existing frame is byte-for-byte unchanged.
- og_herdr_launch: `pane_env_for` builds `{BROWSER: <operator's own or "true">}`
  and it is threaded onto every pane a launch creates — the space (both
  `_open_space` and `_adopt`) and the agents `pane.split`.
- bin/og: delete `suppress_conversation_autopen` and its global write. Keep
  `suppress_server_autopen`, re-commented as a GUARD for a path that does not
  fire for og today (that open is loopback-gated), and keep the needs_setup gate.

An operator who already set $BROWSER is passed through untouched, not
overridden.

Tests: BROWSER on workspace.create / tab.create / pane.split (schema-checked), an
existing $BROWSER passed through, no `env` key when a launch supplies none, and
a pinned assertion that no code path writes omnigent's config.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
…it covers

The approved design: a plain harness header per root, a full box per worker
(`├─`/`└─` down a tree spine, `▸` on the selected box's FIRST row only), ASCII
glyphs (`+ ! x ?`) with a 6-dot braille spinner for anything working, an access
box sized to its own content and flush left, and a one-line footer. render()
stays pure — the spinner frame and the selection are inputs, not clocks — so
`--once` prints exactly the lines curses would paint.

Bug 1 — the QR printed its own escapes. `qr_lines` returned `qrencode -t
ANSIUTF8` output with its ANSI colour escapes intact, and curses does not
interpret escapes, it PRINTS them: the operator's pane showed `^[[40;37;1m` down
both sides of the code. `--once` looked fine only because the terminal running
it interpreted them. Escapes are stripped at the source and again on the way out
of `access_lines`, so no ESC byte can reach `addnstr`; the block characters
alone already read as black-on-white.

Bug 2 — the view showed every session on the machine. `select_scope` was
correct and re-resolved every poll; the failure was upstream, where a detail was
fetched only for roots the live heuristic kept — and a brand-new `og chat` in
this directory (idle, nothing dispatched yet) is not live by that rule, so its
`workspace` was never fetched, nothing matched `$PWD`, and the view fell back
to the wide set and stayed wrong. The cwd question is not a question about
activity, so it is now asked independently: roots are probed until one matches
(each result cached per session id, bounded by the same MAX_ROOT_DETAILS cache
and pruned against the roots this poll asked about), and a match IS the scope.

Also: the spinner ticks on its own 120ms clock, independent of the 3s data
poll, and nothing is repainted unless the composed lines actually change — with
nothing working the render is byte-identical, so the loop blocks on the
keyboard until the next poll instead of erasing and refreshing eight times a
second forever in a pane the operator leaves open all day.

The display state is derived here from `status` + `pending_elicitations_count`
rather than by widening `herdr_state`: that function is shared with the herdr
bridge, where a fifth state would change which sessions get a herdr tab. The
only difference is unknown -> failed.

Tests cover the B1 shape verbatim, every glyph, the spinner's advance and
purity, the six-dot invariant (no descenders, so the row cannot jitter), the
absence of ESC in every line handed to the painter, a quiet worker-less root in
`$PWD` narrowing the view, the content-sized access box, an idle pane
scheduling no repaint, and `--once` without curses. Fifteen of the seventeen new
tests fail against the pre-change module; the two that pass on both
(`test_the_cwd_probe_is_skipped_entirely_under_all_roots` and
`test_main_once_reports_the_missing_tty`) are regression guards, not bug
witnesses.
tui_proto.py renders the four layout candidates and the component
variants (glyphs, headers, selection markers, footers, access panels);
tui_pick.py renders the chosen combination -- B1 -- with the four box
styles and a live spinner, at the width the pane actually gets.

Kept because the design was chosen by looking at these rather than from
a description, and because the pane being NARROW is the constraint that
decided it: every variant renders at the real width, so a future change
can be judged the same way.

  python3 docs/proto/tui_proto.py layouts --demo
  python3 docs/proto/tui_pick.py --live B1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant