Skip to content

Releases: jmvbambico/og

v0.26.0 — a contract that parses, and a start that says when it doesn't

Choose a tag to compare

@jmvbambico jmvbambico released this 21 Oct 21:44
0b5df75

og init was writing contracts that do not parse, and nothing said so. This release fixes that, and makes og start tell you when the contract it is about to work without is missing or broken.

Found by running og init in two real repos: both reported stack none detected and produced a .agents/orchestration.yaml that yaml.safe_load refuses.

The silent failure

When the generated YAML fails to parse, _load_contract catches the exception and returns None. Every gate, protected branch and never_write rule is then ignored — silently. You believe you have a contract; you have none.

It fails closed: require_contract=True means a merge in a repo with no loadable contract is ASKed, never auto-allowed. So the cost was lost configuration and false confidence, not an unguarded merge. But nothing surfaced it.

Two bugs produced it, and they compounded. detect_stacks only looked at the repo root, so any monorepo or nested layout detected nothing — and with no stack detected, never_write was empty, which tripped a fallback that emitted - "# - "vendor/**"" and broke the whole file. Nested-project repos, exactly the ones that need orchestration, reliably got a broken contract.

Both are fixed. Stack detection now walks to depth 3, skipping node_modules, target, vendor, dist, build, .venv and hidden directories, and emits each gate with its directory:

- name: unit-console-src-tauri
  run: cd -- console/src-tauri && cargo test
  scope: worker

render() now parses its own output before writing and refuses loudly on failure — one line that would have caught the original bug at authoring time.

Values in the contract stay what you wrote

Directory names now reach the contract from the filesystem, which made them an untrusted input. A directory named console app produced cd console app && cargo build, a command that cannot run inside a contract that still validates. A directory containing a quote broke the YAML outright, and the new guard then blamed og for it.

Separately, bare values were emitted unquoted, so YAML 1.1 coerced them: a branch named off loaded as boolean False, 2026-10-21 as a date, 123 as an int — handed to a merge gate that compares branch names. Values now keep the plain form only when they round-trip as the identical string, checked against PyYAML itself rather than a character class that would drift from it. run: cargo test, integration_base: main and task_prefix: feature/ stay unquoted.

og start says when there is no usable contract

With no contract, og start served fine and the merge policy failed closed, but the orchestrator planned and delegated with no gates, no protected branches and no integration base — which looks like it is working.

It now warns, to stderr, and never blocks. Resolution mirrors _load_contract exactly: walk up from the workspace directory, first .agents/orchestration.yaml wins, a parse failure there is final. A check that disagreed with the policy would teach you to distrust the warning.

Four states are reported separately, because they have different fixes: nothing found; present but unparseable; valid YAML that is not a mapping; and PyYAML missing — which makes the policy load nothing whatever is on disk, and is a machine problem, not a repo one. A valid contract prints nothing.

On a terminal it offers to run og init, naming the file and saying plainly that --force will overwrite it. Ctrl-C at the offer or during the questions abandons the scaffold and lets the start continue. --no-contract-check, or OG_START_CONTRACT_CHECK=0, silences it.

og init --questions

An AI driving og init had to scrape interactive prompts while install.sh --questions handed it a JSON schema. That asymmetry is gone. og init --questions emits the decision schema — what was detected, what is being asked, and not_asked with reasons for what og decided itself. Read-only, writes nothing.

A marked block in an existing AGENTS.md

A project with its own AGENTS.md used to print AGENTS.md exists — left untouched and never receive the orchestration directives at all. It now gets a block between <!-- og:orchestration:begin --> markers, updated in place on rerun. The markers are the entire boundary of what og may rewrite: your line endings, your fenced examples and everything outside them come through byte-identical. If a document somehow carries two og blocks, og picks one deterministically and says which.

Three questions, and only three

On a terminal og init now asks what it cannot measure: your branching model (it sets protected, which the merge gate DENYs on), which gates touch a shared database, a fixed port or the network — asked concretely, because a shared-state gate running per-worktree is how parallel workers corrupt each other — and confirmation of the detected gates. Gates og is unsure about default to removed; cleanly detected ones stay.

merge_policy.enabled, diff thresholds, task_prefix and high-risk paths are decided, not asked. If your roster holds a single vendor, it says so rather than letting you find out when a review is silently degraded.

Input that ends is a decline: EOF or Ctrl-C aborts with exit 2 and writes nothing. A contract built from unanswered questions fails open — the orchestrator quotes it back and runs exactly what it says — where no contract fails closed and warns. --force plus EOF leaves an existing contract byte-identical.

og start waits for a host before pinning

start_host_daemon exits 0 when the daemon is running but has not yet registered, so a start could reach the project pin with no online host. That pin is the host a new session launches its runner on, so a session created any other way got The runner for this session is not available. It now waits, then restarts the daemon once — the only thing that clears a daemon that is alive but wedged.

default_agent means less than it claimed

Verified against omnigent 0.17.0: default_agent is read only by omnigent's cli.py, never by the server. It governs og chat alone; what binds the agent in the web UI is the project pin. Every message on that path overstated its reach, and og status now distinguishes the two. The failed-pin message also explains that the picker cannot stand in for the pin — it lists built-in agents only, so your orchestrator shows as "Agent unavailable" even while the pin works.

Tests

1657 → 2498 passing. tests/test_og_init.py is new (it did not exist), and includes a property sweep over generated AGENTS.md documents asserting that user text survives, exactly one block is live, at most one is rewritten, and the warning agrees with the resolution. bin/og's new paths are covered by pty-driven tests verified against deliberately broken copies of the script.

Four cross-vendor review passes found seven blocking defects across the series; the shipped state returned none.

v0.25.0 — one harness, several pins, and long work goes to the worker that can hold it

Choose a tag to compare

@jmvbambico jmvbambico released this 20 Oct 10:06
71a78f7

You can now install a coder harness more than once, each copy pinned to a different model. The case that prompted it: OpenCode on Zen's free tier for everyday work, plus a second OpenCode pinned to a paid provider (e.g. Command Code's commandcode/deepseek/deepseek-v4.1-flash) for long-running and background tasks. The paid copy is spared both ACP transport timeouts and free-tier rate limits.

In og setup, repeat a harness's number in the coder picker. 2,2,4,1 installs two copies of coder #2, in that preference order, and the model step asks for a pin for each one. Choosing the same model twice for one harness gets a warning. Existing installs load and behave exactly as before.

Each copy is its own worker:

  • The first keeps today's names (coder_zen, opencode). Later copies take their name from the pin's provider (coder_zen_cmdcode, opencode@cmdcode), or get a numeric suffix (coder_zen_2).
  • og stats, dry marks (--mark / --clear), the start-time probe and the quota checks are tracked per copy. A rate-limit mark on the free copy never blocks the paid one.
  • The liveness probe follows each copy's own pin against the unchanged free_models allow-list, so it still never bills.
  • Cross-vendor review takes each copy's vendor from its pinned model, not from the harness.

The orchestrator now knows which workers can carry long jobs. Every roster line includes a generated long-running trait:

  • yes for a native harness on a paid pin.
  • no (ACP; long steps time out) for ACP workers.
  • no (free-tier; rate-limited) for free-tier pins.

Test suites, migrations and big refactors go to a long-running: yes worker even over preference, and are split when no such worker exists. Dispatches now tell workers to run long commands in the background and poll them in short steps, never blocking on one long foreground command.

v0.24.0 — every start re-checks capacity, and a dry mark can no longer stick

Choose a tag to compare

@jmvbambico jmvbambico released this 20 Oct 08:39
b2d4416

A worker marked dry used to stay dry until someone cleared it by hand. og stats --mark without --until stored a mark with no expiry. For agents whose quota cannot be queried (OpenCode Zen's free tier, Command Code), no measurement ever arrived to override it. One "rate limit exceeded" mark kept two workers out of the roster for a week.

og start and og restart now probe every installed agent before handing over:

  • Agents with a quota probe use it, as og stats already did.
  • Agents without one get a one-line liveness request through their own CLI, on their pinned model.
  • A worker that answers has its dry mark cleared.
  • A real quota rejection sets a dry mark that expires when the error says it resets (Resets in 2h 41m, 2d, …), or after an hour.
  • A missing binary, an auth error or a timeout is reported as broken, never as dry.

Probes run in parallel with a 20-second limit each. They never block or fail og start, and og start --no-probe skips them.

A probe never spends money. It runs only when the exact pinned model is on the registry's free_models list for that agent; a model id that merely looks free is not enough. Pins not on the list, such as Command Code's credits and Kiro's auto, are reported skipped and their CLI is never run. Each probe runs with stdin closed, in its own process group and an empty temporary directory, with no flags that let the agent act. A timeout kills the whole process group.

og stats --mark without --until now defaults to one hour. Marks, clears and probe results are written under a file lock, so og stats and a starting og no longer overwrite each other.

Also in this release (#47, thanks @sebastianfellerich-collab):

  • Kilo pins with a :free suffix (e.g. kilo/poolside/laguna-s-2.1:free) are treated as free, so a $0 paid balance no longer reports them dry.
  • Antigravity coders now run with bypassPermissions. A host-spawned agy worker no longer parks its first command on an approval prompt nobody answers.

v0.23.0

Choose a tag to compare

@jmvbambico jmvbambico released this 19 Oct 07:04
8672974

v0.23.0 — the orchestrator pins its own model, and the agents pane is never optional

The brain was the one role with no model pin: an unpinned required harness
(OpenCode) resolved no model at all, so the session's FIRST conversation went
faulty. The orchestrator now carries a pin at executor.model, exactly like a
worker — required where the registry requires it, and deliberately omitted for
Claude Code, whose pin would pin the routing family and strand the non-Claude
workers it routes.

The orchestrator is also a single entry now, not a chain: nothing dispatches the
brain, so a second entry could never take over. og install repairs a saved
chain by keeping its head and saying so, rather than launching an inert tail.

The og agents pane beside og chat is no longer optional. The
herdr_agents_pane install key is gone — not asked, not read, and ignored if
your saved configuration still carries it.

og start and og status now page through /v1/agents instead of reading only
the first page, so an installed agent whose name sorts past the first twenty is
found rather than reported missing.

The model picker lifts its per-provider cap when a search narrows the menu, so
an id the cap had hidden is reachable by number. And a TROUBLESHOOTING entry
covers the cosmetic "Models unavailable" dropdown under a non-Claude/non-Codex
orchestrator.

v0.22.0 — any agent in any role, with measured orchestrator capability

Choose a tag to compare

@jmvbambico jmvbambico released this 19 Oct 01:30
3d8359c

Added

  • Any agent can be offered for any role. The registry's roles array no longer gates the pickers. Every detected agent appears for orchestrator, coder, reviewer, scout and integrator; roles is kept and documented as informational so existing readers keep working.
  • Orchestrator capability is MEASURED, not declared. og now probes Omnigent's own harness capabilities (capabilities[<harness>].subagents) to decide whether an agent can actually dispatch sub-agents, caching the result and falling back to the registry relay flag when Omnigent is not importable from the installer's interpreter. Every message says which source answered. An acp:<slug> harness resolves through the shared acp key first.
  • The capability list is visible. ./install.sh --check prints an orchestrator-capability block, and --questions carries a structured per-agent dispatch map ({can, source}) on the orchestrator question alongside the existing human-readable note — so an AI installer never has to parse prose to learn whether an agent can orchestrate.
  • Delegated worker tabs carry the vendor glyph. In og herdr, a worker's tab read coder_cmdcode:fix-stale-comments, repeating the agent name already drawn as a mark in og agents. It now reads ⌘ fix-stale-comments, resolved through the same chain the pane uses so a tab and its row can never disagree about a worker's brand.

Fixed

  • Six registry rows claimed a dispatch capability they do not have. agy, cursor, gemini, goose, hermes and grok were all marked relay: true while their harness reports subagents=False. grok was additionally offered as an orchestrator, so the installer would happily have built a brain that could never delegate — and nothing would have said so until the first real dispatch.
  • devin pointed at a harness id that has no capability entry at all (devin rather than devin-native), so the preflight map would have dropped the worker. Repointed; devin-native measures subagents=True, and the row stays flagged unverified_roles: ["orchestrator"] until a real run proves it.
  • A refusal now names the fix in the same sentence as the fault:
    cmdcode cannot be the orchestrator: omnigent reports harness 'acp:command-code' (via shared key 'acp') cannot dispatch sub-agents, so it could never delegate. Orchestrator-capable agents here: claude, opencode, codex, devin.

Changed

  • Former hard refusals for non-orchestrator roles are now warnings. An unverified row or an unknown prompt_delivery warns and proceeds — the first real dispatch is the proof, so neither may block an otherwise-legal install.

Docs

  • AGENTS.md and the registry header now describe roles as informational and relay as a fallback used only when the measured probe is unavailable.

v0.21.0 — live freebuff model lineup and a free fallback pin

Choose a tag to compare

@jmvbambico jmvbambico released this 18 Oct 23:30
f3ed9dd

Added

  • Live model probe for freebuff. Setup now runs blink --list-models and offers every id it prints. freebuff's lineup rotates server-side, so the hardcoded choices list went stale and setup could no longer pin a valid model; choices is kept only as a fallback when the probe fails, and og tells you when it fell back.
  • A free fallback model for freebuff. freebuff bills in hours from a small daily pool and per-hour rates differ wildly by model, so a pinned paid model can exhaust the pool and leave every later dispatch refused with not enough Freebucks. Setup now offers a second pick from the picker's genuinely free (price 0) models and exports it as BLINK_FALLBACK_MODEL; blink falls back to it when the pin is unusable. An older blink ignores the variable, so leaving it unset is always safe.

Changed

  • --show and --questions cover the fallback. --show renders the configured fallback next to each pinned model and prints an explicit (fallback: none) marker when unset, instead of a silent empty field. --questions gains a per-item fallback_model question (blank = none; freebuff only) so interactive and non-interactive setup agree. The fallback has no static choices — its lineup comes from the live probe at setup time.

v0.20.1 — Enter reaches every worker, and the footer reads

Choose a tag to compare

@jmvbambico jmvbambico released this 16 Oct 04:44
47cd4a0

Three bugs reported against v0.20.0, all in og agents. The first made that
release's headline feature unusable for the rows it was built for.

Fixed

  • Enter now reaches every worker. Selecting a sub-agent returned
    focus: … is not a harness og knows while that agent's tab sat visibly open
    beside the pane. This affected every worker row, not only the one
    reported: a worker's listing row carries no harness (only roots get one, from
    a detail fetch), so focus arrived with an empty string and declined. The
    resolution order the row's mark already used — the row's own harness, then
    the harness the agent's installed spec declares — is now shared by focus, so
    the three readers of that rule cannot drift apart again. The drift was the
    bug.
  • An unknown harness no longer blocks focusing a tab og has already
    recorded.
    The harness kind decides which tab to prefer and whether og may
    create one; it is not needed to focus a tab already on record. Creation is
    still refused for an unknown harness.
  • Finished workers no longer sink to the bottom. Workers were bucketed by
    state, so going idle moved one to the end of the list. They now keep
    delegation order, and the list reads top to bottom as a timeline of when each
    agent was dispatched. Nothing is lost: every row already draws its own state
    glyph and colour, and the meta column already says done / waiting on you /
    the running duration.
  • The footer's labels were too dim. They were dimmed twice over — meta is
    already the quieter colour, and the footer line paints DIM on top. The key
    escaped that; the label never did. Both now paint at full brightness and are
    told apart by colour rather than by dimness. The ∙ separator deliberately
    stays recessive.
  • og's footer strip was invisible. The strip already spanned the full pane
    width — it simply had no colour, because og inherits the terminal's
    background and there was nothing to shade. It is the default theme and the
    only one of 26 affected, so the feature could not appear for the people most
    likely to see it.

Changed

  • Themes may declare an optional strip role, and og sets it to
    #232625 (cell 235). Resolution is declared → derived from bg → none, so
    the other 25 themes are byte-identical. A blanket dark strip would have broken
    the light themes, whose footer text is dark.
  • This is a deliberate exception to og's promise of the terminal's own
    colours: the pane stays transparent, only the footer chrome is painted. A
    menu strip that cannot be seen is not a menu strip. The assumption — a dark
    terminal with a light foreground — is documented where it is made.
  • One breathing rail per root, replacing one per state group, carrying the most
    urgent state present among that root's workers.

Gate

pytest tests/ 1499 passed, 0 failed · bash -n bin/og ·
bash -n install.sh · shellcheck -S error bin/og install.sh — all clean on
the release commit.

Reviewed cross-vendor before merge. The review found one issue late, in a test
rather than the code: the height-trimming test promised in its docstring that
"a rail never leaves its boxes" but asserted only the box half, so a trimmer
that orphaned a rail would have passed. Closed, and verified by mutation —
breaking the trimmer now fails the new assertion after the box assertions
still pass.

v0.20.0 — Enter opens an agent's real CLI, interactively

Choose a tag to compare

@jmvbambico jmvbambico released this 16 Oct 02:46
5239a4d

og agents listed your agents but could not get you to them. Every row is now
selectable — the orchestrator included — and Enter focuses a tab holding that
agent's real CLI, interactively: a vendor CLI's blocking permission prompt
can be answered in the terminal instead of opening the web UI to do it.

Added

  • Enter opens an agent's real CLI in its own tab. Native agents (Claude
    Code, Codex) get the vendor TUI; ACP agents get their own
    omnigent attach view — the same one og chat's down-arrow menu shows. The
    tab is created on demand the first time you ask for it, and pressing Enter
    again returns to the same tab rather than opening another.
  • The orchestrator row is selectable. Root rows could not be reached at all
    before, so selecting claude and landing in Claude Code was impossible.
  • Terminal discovery asks the runner. og scanned $TMPDIR for a
    bridge.json/tmux.json pair, which only ever worked for claude-native —
    a codex sub-agent writes no tmux.json and was unreachable. Discovery now
    asks the runner for the session's terminal resources, keeping the scan as a
    fallback so nothing regresses when the runner is unreachable.
  • The agents pane is themed by role — footer keys and labels, the access
    panel, and a · separator drawn from the active theme.

Changed

  • Enter focuses the selected agent's own tab, not its root. Selecting a
    worker used to focus the conversation it belonged to.
  • Esc clears the selection; q is the only quit. Esc used to quit.
  • space expand is gone — Enter supersedes it.
  • r refresh is gone — the pane already polls on its own timer, so r
    only skipped the wait.
  • The footer is now ↑/↓ move ∙ ⏎ focus ∙ t theme ∙ a access ∙ q quit,
    measuring 50 / 30 / 20 columns against the 80-column ceiling. Esc is bound
    but deliberately unlisted.
  • The tmux attach is interactive. It kept tmux's read-only,ignore-size
    pair where only ignore-size was load-bearing — that is what stops a narrow
    client resizing and corrupting the live agent's TUI, and it still does.

Fixed

  • Selection dimming was a one-way door. Pressing an arrow key dimmed every
    unselected agent, and nothing brought the list back — it stayed dimmed for
    the rest of the session.
  • herdr started its server after the case-(B) attach rather than before it.
  • The access panel no longer draws dimmed, and fills its pane.
  • The footer strip no longer paints over the selection's cell, and uses a
    Narrow separator (none in the compact form) so the measured widths hold.

Docs

  • docs/HERDR.md documents how a tab's pane is routed by harness kind.
  • The tab.create timeout case is recorded where it happens: a create that
    times out may still have landed, leaving a rare orphan tab. It is not
    specific to this path — every create in og has it, because the herdr protocol
    has no idempotency key — and reconciling by tab label is deliberately not
    done, since the label is the harness product name and is not unique per
    session, so it could close another agent's tab.

All of #37, #38, #39 and #40. Gate on the release commit: pytest tests/
1486 passed, 0 failed, bash -n and shellcheck -S error clean.

v0.19.0 — `og` is the default theme, and a resumed space gets its agents pane

Choose a tag to compare

@jmvbambico jmvbambico released this 15 Oct 12:32
d73e65b

Changed

  • og is the default theme for og agents. DEFAULT_THEME was dracula; a machine with no stored preference now opens in og's own palette rather than needing the picker to find it. Nothing else moves: dracula stays an ordinary catalog row, a stored {"theme": "dracula"} still opens dracula, and --theme / $OG_AGENTS_THEME override as before. og keeps order: 0 so the picker still lists it first.

    This reverses a decision the code recorded in three places — og_themes.py, themes.json's $comment, and og_agents.py all said "dracula stays the default, and og is the way back, not the way in". All three now state the new rule and keep dracula's own rationale, rather than being left asserting the opposite of the code. The BUILTIN fallback moved with the default, forced by an existing contract (list(BUILTIN) == [DEFAULT_THEME]): leaving it behind would make load_theme() and theme_for() disagree whenever themes.json is missing.

Fixed

  • og chat --resume into a herdr-made space now gets its og agents pane. v0.18.1 stopped og start opening a third pane by having og chat stand down when og's state record names the pane it is running in, reasoning that "the launcher keeps the split". That is true for og start and false on the history path: _add_history_space splits and runs og agents at space creation, so when you later resume into that space the record still matches, og chat stands down — and nothing is coming, because _split_agents has exactly one caller and _add_history_space is only reached for a new space or a re-projection where the space is gone. The second pane stayed empty for the life of the space.

    The record's owner answers the question the pane list cannot — is a second half still coming?

    • owner == launcher: the split is in flight or done in the same call. Stand down immediately, before any pane listing and without opening a herdr socket at all.
    • owner == history / bridge: the split, if any, happened at creation. Nobody is coming, so the tab is checked and the pane opened when it is missing.

    An intermediate approach — requiring the pane to exist in every case — reintroduced the v0.18.1 bug through a race, because the launcher writes the record, calls pane_run and splits in one call, so on a fresh launch the pane may not exist yet when the chat process looks. A test pins that: it fails on the intermediate commit with pane.list, pane.split, pane.list, the duplicate split reproduced.

  • A space recorded with the agents pane disabled no longer stands down forever. _split_agents returns early when the pane is off, but the record is written regardless — so enabling herdr_agents_pane afterwards and resuming produced no pane, no error, and no way back. The same owner rule covers it: a bridge record with no second pane is split.

  • A state record with no owner is no longer treated as nobody's. A record matching all three ids is og's pane, whatever the writer left out, so it now takes the pane check rather than splitting unconditionally — which could have duplicated an agents pane that was already there.

  • A test that depended on whose machine ran it. test_theme_for_falls_back_for_a_stale_slug_but_a_typed_slug_is_checked read the real ~/.omnigent instead of a tmp home like its neighbours, so its verdict turned on the developer's own stored theme. It now redirects $OMNIGENT_HOME the way the rest of the module does. This was the one failing test across the whole v0.18.x series; the suite is green for the first time.

Known limits, unchanged

pane.list reports only {pane_id, tab_id, workspace_id} — no command, no process — so a pane whose og agents crashed is indistinguishable from a healthy one. Such a pane is not repaired; close it and re-run. And a launcher space whose split failed is no longer repaired by og chat: the launcher reports that failure through report before the terminal is handed to herdr, so it reaches you, which the bridge path's reconcile-line degradation does not.

Verification

  • Full gate: 1394 passed, 0 failed, run twice with identical counts under a 600s alarm.
  • Baseline v0.18.1 is 1376 passed + 1 failed; the deltas (+1 theme, +9 resume, +8 polish) reconcile exactly.
  • New tests were verified to bite: the history-resume test fails on v0.18.1, and the race test fails on the intermediate commit, each for its stated reason.

Review

cross-vendor-review: passed

Implemented by opencode-zen-vendor and deepseek-vendor agents, reviewed independently by an openai-vendor agent. Verdict APPROVE, no blocking issues. Two non-blocking findings were applied rather than deferred: the launcher stand-down was building a herdr client before returning on the hot path, and a record with a missing owner skipped the pane check in a way that could duplicate a pane.

v0.18.1 — a third pane on a fresh start, duplicate spaces, and `og agents` dying on a 404

Choose a tag to compare

@jmvbambico jmvbambico released this 15 Oct 09:28
004225c

Fixed

  • A fresh og start herdr no longer opens a third pane. Two halves were doing one job: _open_space types the chat command into the pane it is about to split, and since v0.18.0 og chat splits the pane it finds itself in — so the process pane_run starts runs the very code that splits beside it. The shared split_agents_pane guarded nothing; its known_panes argument only ever identified the new pane afterwards.

    The fix is at the og chat end. Deleting the launcher's split was the other candidate and is worse: the launcher is the half that still holds the pane when the chat command never reaches its own, and the half whose degradation reaches you before the terminal is handed to herdr. A guard keyed on known_panes is not possible either — pane.list returns only pane_id, tab_id and workspace_id, so "this tab already holds the og agents pane" and "this tab holds a pane you split yourself" are the same answer, and declining on the second is how og chat in a space you made by hand would silently lose the pane known_panes exists to protect.

    So the question asked is one og can answer: did og open this pane? og_owns_pane looks for a record naming this workspace, tab and pane. Both would-be splitters write one before they act, so it is ordered rather than raced, and both state buckets are read because a claim moves the record from pending to spaces unchanged. All three ids must match exactly — a record with no tab_id matching every tab would stand down in a tab the launcher never touched and cost you the pane. It fails open: an unreadable state file is og having nothing to say, not og saying "mine".

  • The bridge no longer opens duplicate spaces for one directory. og start launches the bridge before exec'ing the launcher, and the bridge loaded its state once at construction — so the launcher's pending record landed in a file it had already read, the dedupe missed, and a second space opened for a directory that already had one. Worse than a stale read: _prune_pending saves the whole file, so a prune against the startup snapshot deleted the launcher's record, and a record no process holds can never be found by a later pass. That is why the duplicate never healed.

    The bridge now re-reads at each reconcile pass, ahead of the prune, and merges per key against the last state it knew the file held — a key it has not changed belongs to whoever wrote the file last, including a writer who replaced it. A claim is a completed operation, not an unsaved edit, and is no longer mistaken for one.

  • The two writers of og-herdr.json take a lock. Every interesting operation on that file is a read-modify-write, and re-reading immediately before writing narrows the window without closing it — a second read only moves the race to the other side of itself. state_update now does load → decide → write as one transaction under an advisory fcntl.flock, and og chat's own pane record shares it. The lock lives on an og-herdr.json.lock sidecar, never the state file: the writer finishes with os.replace, which swaps the inode, so a lock taken on the file itself would leave two holders locking different inodes and excluding nobody. Advisory, so a writer that ignores it is still last-writer-wins; where fcntl is absent the behaviour is exactly what it was.

  • og agents talks to og's own server, and degrades instead of dying. cmd_agents was the one caller that never forwarded --server, so on an install whose server moved off 6767 it queried the default port and read an unrelated listener's 404. Every sibling already forwarded it. The viewer also died on any non-200 — fetch_listing was the single escaping wire call, every detail fetch already being guarded — tearing down curses with a raw traceback. It now keeps the last good rows behind a footer notice naming the server and status, and heals when the server returns.

  • og start no longer types an attach for a session on another machine. A session for the same directory living on a different host matched workspace == cwd and won, and the attach died with "its host is offline". runner_is_offline cannot catch it: a session on another machine has no offline runner here at all. A session whose host is known and different is now skipped.

  • The host rule above could never actually fire. host_id was not among the fields merged onto the session row the rule tested, so the check read None every time and was dead code. It is forwarded now, and this machine's host id comes from the daemon record first, falling back to /v1/hosts only when exactly one host is online — never "the first online host", which on a multi-host account is a guess.

  • A --dry-run claim consumes nothing, in memory either. It previously moved the record out of pending and declined only the disk write, leaving memory a key short with no save to reconcile it — which the new merge rule then reads as a completed claim, permanently. A run that changes nothing could durably remove a real launcher record from the bridge's view.

Docs

  • docs/MUX-LAUNCHER.md §2 gains the pane-ownership rule and the pane.list limitation that forces it; the "two things open the agents pane" note was stale since v0.18.0 and now names three.