Compare commits

...
15 Commits
Author SHA1 Message Date
dev60-review a226b1b6cb docs(release): note the scrolled-pane stale-binding fix for 0.7.10
Add the #2644 entries to the 0.7.10 CHANGELOG section and whats-new, after
#2594's tmux mode item.
2026-11-07 07:51:52 +00:00
review50-r3 d83962a843 fix(transport): leave a pane's mode through the seat's address
A send to a seat whose pane was scrolled into copy mode tried to leave the
mode by the pane's raw tmux id. The input guard resolves a write's target to
exactly one seat, and a pane id can also match a stale binding elsewhere,
because tmux reuses pane ids after a server restart. The guard then refused
the write before typing anything, the pane stayed in its mode, and the send
was refused as pane_in_mode. Text and Enter were never affected: they are
addressed by the seat's session name.

Leave the mode through the session name too. The guard resolves the seat and
writes to its bound pane, as it does for text and Enter; the mode reads still
use the bound pane.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3e0d7e7865)
2026-11-07 07:51:15 +00:00
dev60-review f90481bc23 docs(release): qualify plain rig in Codex seats by allow_login_shell support
The 0.7.10 notes and the capability-map line now say plain rig works however OpenRig was installed with a Codex that supports allow_login_shell, and the notes add that it hasn't yet run with a live Codex seat.
2026-11-07 06:10:10 +00:00
dev60-review cd8e32f18d docs(release): describe non-login Codex tool shells for 0.7.10
Carries #2613's 0.7.10 notes (the CHANGELOG section and whats-new) and adds one capability-map line: managed Codex seats run tool commands in a non-login shell, and launch.codex_login_shell defers to Codex's own setting at each seat's next launch.
2026-11-07 06:00:42 +00:00
dev-driver 6130b4308c test(codex): update six expectations for the non-login flag and its setting
Four daemon tests pin Codex launch commands or the resume call's shape.
They now expect -c 'allow_login_shell=false' where the launch adds it.

Two settings tests list the keys. They now include
launch.codex_login_shell, and the routes test also checks its default.

No assertion is weakened.

(cherry picked from commit 3c0d90e530)
2026-11-07 05:58:06 +00:00
dev-driver 6f0f0888c1 docs(config): name what non-login Codex tool shells can lose
The launch.codex_login_shell help and the getting-started reference now
name the trade-off. Tool commands inherit the seat's environment instead
of re-running login setup for each command. Setup that lives only in
login or rc files, including a tmux default-command that isn't a login
shell, may be missing, and so may shell functions and aliases.

Turning the option on omits OpenRig's override and defers to Codex's own
configuration. It doesn't force login shells. It applies at each seat's
next launch, and a restart in place keeps the running choice.

(cherry picked from commit 1c32da8ea1)
2026-11-07 05:58:06 +00:00
dev-driver d98f4759fe feat(config): launch.codex_login_shell keeps Codex's own login shells
Off by default, so managed Codex seats keep OpenRig's command folder on
their tool PATH. A seat's pane normally starts a login shell, so its tools
already see what login files export. A person whose panes don't (a tmux
default-command) and whose tools need setup that only their login files
make can turn it on. The rig config help says when, and what it costs.

Read at each Codex launch, by the runtime adapter and the legacy resume.

(cherry picked from commit b02bdef63a)
2026-11-07 05:58:06 +00:00
dev-driver bc793ba3ff fix(codex): keep OpenRig's command folder on managed seats' tool PATH
Codex gives each tool command the user's login-shell environment, then puts
back only its own directories. When a login file sets PATH outright, the
folder OpenRig puts first on a seat's PATH is dropped. A seat whose rig isn't
on the login PATH, such as one from a project-local install, then reports
"rig: command not found".

Managed Codex launches now pass -c allow_login_shell=false. Tool commands then
run in a non-login shell with the environment the seat's Codex was started
with, OpenRig's command folder included. Fresh, fork and resume launches add
it, including the legacy resume path.

An in-place restart keeps what the running Codex was launched with, as it
already does for --no-daemon and the network override. A seat launched
earlier picks the flag up at its next managed launch.

(cherry picked from commit 2385d48e41)
2026-11-07 05:58:06 +00:00
dev60-review 5eb917891a docs(release): a kept Herdr viewer keeps its earlier sizing while it runs 2026-11-07 04:45:43 +00:00
dev60-review 32531ae08e docs(release): 0.7.10's kept Herdr views keep working 2026-11-07 04:39:02 +00:00
Mike Schwarz c17f8a53d6 fix(terminal): keep Herdr views from earlier releases after upgrading (#2634)
Kept Herdr viewer journal entries and follow-view saved tiles store the whole tile attach argv. #2614 changed the writable tile script, and #2464 changed both scripts before it, so views kept from 0.7.5-0.7.9 failed the strict comparison after upgrading: a kept viewer read unconfirmed and a kept follow view stopped adding and closing tiles. earlierTileAttachment accepts a kept argv only when it exactly equals what 0.7.9's or 0.7.5-0.7.8's generator makes for the current session, tmux, server and recorded read-only intent; the entry then moves to the current command. A different target, a changed read-only intent, or any other script (including one sharing the old prefix) still mismatches as before. The earlier generators live in src/domain/terminal/earlier-tile-attach.ts, shared with the tests. The approach follows natefaerber's #2467. No new refusal, error code or setting.

(cherry picked from commit 6a46172df1)
2026-11-07 04:38:35 +00:00
dev60-review 3c8b72d54c docs(release): name the Herdr views kept from before 0.7.9 in 0.7.10 known limits 2026-11-07 03:02:10 +00:00
dev60-review 14d89431f9 docs(capability-map): teach what 0.7.10 added, and correct two lines it made stale
The packaged capability map takes in 0.7.10's changes, and its header marker
moves to capability-delta-v0.7.10, with the verb count at ninety-two (rig exec
is the new top-level verb).

- rig exec: a finite Claude Code or Codex turn through the harness's ACP
  adapter, run by a detached connector; the row stays the custody record, one
  run per row, status reads interrupted runs, and cancel's acknowledgement
  isn't a settled shutdown or a statement about spend. Not yet run with a real
  adapter.
- rig seat status: the latest observed permission request, with a fixed
  command descriptor that hides arguments and bounded suggested rules that can
  contain paths, patterns or sensitive values. Evidence only: it applies and
  approves nothing, lasts five minutes in memory and is lost on a restart.
- rig agent validate and launch advisories: unknown uses keys and a single
  value for uses.<category> are advisories; that value is a one-item list, so a
  member selecting an unavailable resource fails its resolution as before.
- Managed compaction: typed, unreadable or changed input still holds the
  preparation prompt and /compact; after it, a typed draft holds the step, and
  two idle polls that can't read the box send it, recorded as delivered over an
  unreadable box, so an unrecognised draft may receive the restore text.

Corrected: a send leaves every tmux mode, not one, and the OpenShell line names
0.7.10's source repairs while saying the worker hasn't run end to end. The
agent spec reference gains the single-value rule for uses. Docs only.
2026-11-06 21:48:43 +00:00
dev60-review c105b9c2f3 docs(release): describe 0.7.10 2026-11-06 20:43:28 +00:00
dev60-review 52d6dff860 chore(release): prepare 0.7.10 2026-11-06 20:42:47 +00:00
40 changed files with 1307 additions and 253 deletions

No files matched your search

+341
View File
@@ -8,6 +8,347 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.10]
OpenRig 0.7.10 is a patch for people running their own agent teams. After a
managed compaction, a step held because OpenRig can't read the seat's input box
now goes in after two idle polls instead of waiting with no end. Wakes and sends
get through a seat pane left in more than one tmux mode, and `rig exec` hands a
queue row's task to Claude Code or Codex as one finite run that outlives the
command (not yet run with a real adapter). Installing through the rigs skill tries a user-writable npm prefix
before a project-local install, and agent spec advisories now show at launch.
There are fixes across refocus, routing, status and speed under load, many
contributed by people using OpenRig. The two problems that kept the OpenShell
worker from working in 0.7.9 are fixed in the source, but the worker hasn't run
end to end on 0.7.10: see [the OpenShell worker](#the-openshell-worker-in-0710).
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.10
rig daemon stop && rig daemon start
```
This release adds no migrations.
- **The bundled openrig-core plugin advances from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from that copy. ([#2615](https://github.com/mvschwarz/openrig/pull/2615), [#2562](https://github.com/mvschwarz/openrig/pull/2562), [#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Check your agent specs:** `rig agent validate <path>` now prints each
advisory. A profile's `uses.<category>` written as a single value now selects
that resource instead of being dropped, so a member using that profile fails to
resolve if the resource pool doesn't hold it (see Behaviour that changed).
([#2585](https://github.com/mvschwarz/openrig/pull/2585), [#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **If you run an OpenShell environment, rebuild its guest image** with this
release's `guest.py` (`daemon/assets/openshell/README.md` describes the build).
The CA repair below is in that file, which the image carries. ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
### Finite runs: `rig exec`
- **`rig exec` runs one turn of a queue row's task on Claude Code or Codex**
through its maintained ACP adapter, in a connector that outlives the command.
`rig exec harnesses` shows the versions installed,
`rig exec run <row> --harness claude` (or `codex`) starts it (`--plan` starts
nothing, `--wait` waits), and `rig exec status` and `rig exec cancel` follow
it. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **One run per row, and the row stays open.** Running it again returns the
row's run instead of sending the task twice; `--again` starts another turn once
it has ended. A run whose connector died without recording an outcome reads
`interrupted`, outcome unknown. The outcome is the agent's report, and
accepting the work stays with you. `--wait` exits 0 for `completed`, 2 for any
other outcome and 3 when you stop waiting; a Ctrl-C there stops the wait, not
the run. ([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **An ACP run's outcome note,** `rig acp run`'s included, carries the usage and
cost the agent reported, each with its source, or unknown. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **`docs/reference/acp-agents.md`** covers both adapters
(`@agentclientprotocol/claude-agent-acp` 0.101.0 and
`@agentclientprotocol/codex-acp` 2.5.0): installing them, which native
versions run, and how their permissions, refusals and cancels differ. ([#2596](https://github.com/mvschwarz/openrig/pull/2596))
### The OpenShell worker in 0.7.10
**The OpenShell worker hasn't run end to end on 0.7.10. Don't rely on this path
yet.**
0.7.10 fixes, in the source, the two problems our release testing on 0.7.9
found:
- **The guest's CA settings reach its Claude.** The guest launcher built
Claude's environment from scratch with only the provider settings, so a CA the
OpenShell exec supplied never arrived, and API calls failed with
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
Rebuild the guest image to get it (above). ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
- **Deleting an owned environment reads Podman's inventory.** The cleanup step
asked for routes Podman serves only under a version prefix, misread the "Not
Found" reply, and never sent the delete. It now uses routes Podman serves
without one, and a volume counts as gone only when one complete volume listing
leaves it out. Any other answer stops as `environment_cleanup_unconfirmed`,
with the HTTP status. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
A verified export, a finished task and an exact restore haven't been shown.
### Compaction, delivery and terminals
- **A held post-compaction step now goes in.** Each step after a managed
compaction (the turn boundary, the restore request, the audit) went only into
an input box that read empty, so a box OpenRig couldn't read held the restore
request on every poll, and the seat sat idle without its restore. Now, after
two idle polls in a row that can't read the box, the next poll sends the step,
and `rig compact <seat> --state` and `rig seat status` say it went in over a
box OpenRig couldn't read, never that the box was empty. Some cases still hold
(see Known limits). ([#2576](https://github.com/mvschwarz/openrig/pull/2576), refs [#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Wakes and sends leave every tmux mode.** A pane scrolled into copy mode, with
run-shell output, a chooser, customize or clock mode on top, kept a mode after
one cancel, and the send was refused until someone left it by hand. Delivery
now runs tmux's `copy-mode -q`, which leaves them all, with a single cancel
where that fails (tmux before 3.1a), and a refusal names why leaving failed.
([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A scrolled pane with a stale record no longer blocks delivery.** tmux
reuses pane IDs after a server restart, so a stale binding, from another rig
for example, can name the same pane as a live seat. Leaving the mode was
addressed by that raw pane ID, which then matched two seats, so it was
refused before anything was written and the send failed with nothing sent.
Leaving the mode now goes through the seat's address, as the text and Enter
writes already did. The stale binding itself is unchanged.
([#2644](https://github.com/mvschwarz/openrig/pull/2644))
- **A writable rig, pod or seat view sizes the pane it shows,** instead of
keeping an older attached client's size. Read-only views are unchanged. ([#2614](https://github.com/mvschwarz/openrig/pull/2614))
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
### Coordination and status
- **Routing and receipts:**
- a send to `<seat>@<rig>@<this host's ID>` goes to this host even when a
remote host is selected;
- `rig broadcast` prints `UNCONFIRMED`, not `FAILED`, for a recipient its text
or Enter may have reached, and points to `rig capture` before any resend;
- `rig seat stop`, `clean` and in-place restart, when they refuse a seat
handed over before 0.7.9, say why and no longer suggest `rig unclaim`;
- `rig roster show` and `find` read a seat's activity as unknown
("Conflicting activity observations") when two routes to it disagree;
- `rig seat set-runtime` finds a pod seat's authored recap.
([#2600](https://github.com/mvschwarz/openrig/pull/2600), [#2572](https://github.com/mvschwarz/openrig/pull/2572), [#2574](https://github.com/mvschwarz/openrig/pull/2574), [#2593](https://github.com/mvschwarz/openrig/pull/2593))
- **Refocus:** unresolved typed work, and a `rig queue whoami` that timed out,
print a named trace gap with project-level orientation, labelled as not
evidence of a current mission, instead of counting as a refocus failure.
([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Updates to people:** the stuck sweep no longer files an informational update
to a registered person as an unclaimed obligation, unless its quiet-hours hold
was orphaned. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **Slack:** `rig slack status` shows relay attempts to another host still
awaiting a response. It's a count for the current connector at that moment, so
zero doesn't prove nothing is in flight. ([#2569](https://github.com/mvschwarz/openrig/pull/2569))
- **Permission requests in seat status:** `rig seat status` shows a Claude
seat's latest permission request as openrig-core's hook observed it, with
Claude's own suggested rules labelled as suggestions. Nothing is applied,
approved or stored. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
### Install, specs and errors
- **The rigs skill's install step,** when npm's global prefix isn't writable,
now has the agent point npm's prefix at a folder the person owns whose `bin` is
on their login PATH, or use a version manager's Node, saying first what that
changes, and install inside the project only if neither works. ([#2611](https://github.com/mvschwarz/openrig/pull/2611))
- **Plain `rig` works in managed Codex seats however OpenRig was installed,**
with a Codex that supports `allow_login_shell` (checked in Codex's source at
0.162.1 and 0.166); not yet run with a live Codex seat. OpenRig now passes Codex `-c allow_login_shell=false`, so tool commands run in
a non-login shell and inherit the environment their pane's shell set up,
OpenRig's command folder included. To defer to Codex's own setting instead,
run `rig config set launch.codex_login_shell true`; it applies at each seat's
next launch, and a restart in place keeps the running choice. ([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **Agent spec advisories show where you'll see them:** `rig up`, a fresh restore
and a first-start retry report each as a launch warning naming the member,
spec and path, and `rig agent validate` prints them whether or not the spec is
valid. They never stop a launch. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **An unknown key under a profile's `uses`,** such as a typo,
gets an advisory instead of being ignored silently. ([#2618](https://github.com/mvschwarz/openrig/pull/2618))
- **Clearer errors:**
- a rejected terminal-token request says "Terminal auth failed" and names
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when
terminal auth falls back to it;
- the TUI shows the daemon's own message when it refuses a launch;
- `rig doctor` gives up on its cmux status and `--spec` topology reads after 2
seconds each, so a listener that never answers can't hold it.
([#2577](https://github.com/mvschwarz/openrig/pull/2577), [#2556](https://github.com/mvschwarz/openrig/pull/2556), [#2564](https://github.com/mvschwarz/openrig/pull/2564))
- **Docs:** a Codex config fragment can't install a named Codex profile, with
how to keep a profile with full access or a `codex_permissions` envelope; and
a Claude seat that selects openrig-core loads its skills with the plugin.
([#2604](https://github.com/mvschwarz/openrig/pull/2604), [#2584](https://github.com/mvschwarz/openrig/pull/2584))
### Speed under load
- **The execution view** (`GET /api/views/execution`, behind the TUI's execution
view and `rig view show execution`) reads the review registry once per request
instead of once per slice, and yields to the event loop while it does. Its
other synchronous steps are unchanged. ([#2578](https://github.com/mvschwarz/openrig/pull/2578))
- **A queue listing** looks recovery rows up through one index per listing
instead of a scan for each listed row. It hasn't been measured on a live
daemon. ([#2588](https://github.com/mvschwarz/openrig/pull/2588))
- **The event notification drain** reads pages of at most 500 rows instead of
the whole remaining tail. It bounds rows per read, not bytes, and still runs to
the end synchronously; no latency change on a live install is claimed.
([#2610](https://github.com/mvschwarz/openrig/pull/2610))
### For runner authors and integrators
- **The runner contract says what has run with a real runner.**
`docs/reference/harness-runner-contract.md` and the dsh example say what has
run natively, at what scope, and what hasn't; any other runner, provider or
path stays unqualified until shown. The 0.7.8 Known limits line below that said
no harness had run end to end is struck and corrected in place.
([#2592](https://github.com/mvschwarz/openrig/pull/2592), [#2595](https://github.com/mvschwarz/openrig/pull/2595))
- **Telemetry:** `docs/reference/telemetry.md` says OpenRig's guarantee ends at
its finite reads, versioned by `schemaVersion` 1, and links the separately
maintained openrig-telemetry exporter, which OpenRig doesn't test or
guarantee. ([#2589](https://github.com/mvschwarz/openrig/pull/2589))
- **Also:** the real-tmux transport test waits for the shell's prompt before
typing. ([#2559](https://github.com/mvschwarz/openrig/pull/2559))
### Behaviour that changed
- **Managed Codex seats run tool commands in a non-login shell** (above).
Inherited setup isn't re-run for each command, so setup that lives only in
login or rc files (such as `~/.bashrc` lines your login file doesn't source, or
a tmux `default-command` that doesn't start a login shell), shell functions and
aliases may be missing from tool commands. A model request for `login: true`
gets Codex's own refusal, "login shell is disabled by config". With
`launch.codex_login_shell` on, OpenRig omits its override rather than forcing
login shells, so a login file that sets `PATH` outright can then hide `rig`.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **A held post-compaction step goes in after two idle polls that can't read the
input box** (above). A draft OpenRig can't recognise in that box may receive
the restore text. ([#2576](https://github.com/mvschwarz/openrig/pull/2576))
- **A send or wake leaves every tmux mode in a seat's pane,** including a
chooser, customize or clock mode a person has open, where before it was
refused. ([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A send to `<seat>@<rig>@<this host's ID>`** goes to this host, not the
selected remote host. ([#2600](https://github.com/mvschwarz/openrig/pull/2600))
- **A profile's `uses.<category>` written as a single value** (for example
`uses.skills: review`) used to validate and then be dropped. It now counts as a
one-item list, with an advisory suggesting list syntax: a member using that
profile gets the resource, or fails to resolve with "not found in resource
pool" when the pool doesn't hold it. Other malformed `uses` shapes are
advisories, not new errors. ([#2585](https://github.com/mvschwarz/openrig/pull/2585))
- **`rig up` can print new warnings,** one per agent spec advisory, and
`rig agent validate` prints them. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **`rig broadcast`** prints `UNCONFIRMED` for a recipient it may have reached;
its exit code and `N/M delivered` count are unchanged. ([#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **`rig roster show` and `find`** read conflicting activity as unknown.
([#2574](https://github.com/mvschwarz/openrig/pull/2574))
- **The refocus hook** no longer counts unresolved typed work or a timed-out
`rig queue whoami` as a failure, and a timed-out refocus clears the pending
post-compaction retry. ([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **The stuck sweep** no longer flags informational updates to registered people
as unclaimed obligations. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **A rejected terminal-token request** gets "Terminal auth failed" instead of
"Mission Control auth failed"; its status code and fields are unchanged.
([#2577](https://github.com/mvschwarz/openrig/pull/2577))
### Known limits
- **OpenShell:** the two fixes haven't run end to end (above). A cleanup error
that's thrown still omits its stage and whether its delete was sent, and the
inventory is a snapshot, not an atomic read. 0.7.8's requirements, cost note
and cleanup limits still apply, including "Never grant the guest access to the
daemon's main API port (normally 7433)." A same-generation reconciled launch
opens no viewer, so run `rig environment connect` yourself; non-text startup
actions aren't supported on environment seats. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A cancel is acknowledged, not settled, and
says nothing about further spend. Cancel an exec run with `rig exec cancel`:
`rig acp cancel` given its ID while it's still requested can signal the wrong
process. A Ctrl-C just before the connector is launched isn't caught by
`--wait`.
([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **After a managed compaction, a step can still hold:** when the input box
reads as typed, with or without a suggestion; on a seat whose draft-aware
delivery or typing guard holds OpenRig's input; and after a `/compact` whose
result was lost. To take over by hand, run `rig compact <seat> --cancel`, then
send the seat a restore request naming its newest restore map, its restore
marker and its packet; a step already being pasted can still land. Which
reading the reported seat's box gave isn't established, so the report stays
open. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
deliveries to idle Claude agents, queue wakes included, were reported held at
an empty prompt when a narrow pane showed
`✔ Update installed · Restart to update` on its own line. 0.7.10 still doesn't
recognize that bare banner, though it recognizes `/rc` and the full versioned
banner. A message the draft-aware delivery path holds isn't replayed
automatically by that path. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **Grant-service assistants aren't woken:** "an item stays queued until its
receiver claims it."
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which we don't test,
and direct messages aren't supported. The channel and people reads, and a
click on a re-parked ask, haven't been run against a live Slack workspace.
Redaction of the bot token from a transport error can still leave a malformed
token fragment shorter than six characters, so review an error before sharing
it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
- **Shared asks:** "a click's option written before its answer row … can stand
beside or inside the closer's answer without the first clicker's authorship."
- **Existing seats** last launched fresh before 0.7.9 pick up Codex launch
instructions and the single skill load at their next fresh launch. A
`CLAUDE.md` or `AGENTS.md` that already grew isn't trimmed.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart. Claude's suggested rules
can contain sensitive values, and `rig seat status` and both seat-status routes
can return them. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation, since it doesn't reload the spec. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session, so until then
`rig seat stop`, `restart` and `clean` refuse them; the refusal now says so.
([#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade to 0.7.9 showed "Hook failed — plugin contents changed; hook approval
required" until relaunched; in that upgrade test, one seat restarted with
OpenRig's managed restart showed no review afterwards. 0.7.10 changes
openrig-core's files again.
- **The runner contract** has run natively only at the scope its status section
states (`docs/reference/harness-runner-contract.md`); any other runner,
provider or path is unqualified until shown.
- **Fixed or changed from 0.7.9's limits:** the OpenShell worker's CA settings
and owned-delete inventory (fixed in the source, not yet run end to end), a
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
where to read what has run under the runner contract, and Codex seats'
`rig: command not found` (now a non-login shell by default; see Behaviour that
changed).
- **The 0.7.8 limits that 0.7.9 didn't fix or change, and the 0.7.7 and 0.7.6
limits, below still apply.**
### Credits
Thanks to @dajiaohuang, @Aummadour, @billbenetti, @smota, @Nikhila-Dhevi-R and
@Vaishnavi220506 for merged contributions, and to @AlephCasara, whose work on a
fork pointed us to the unpaged event notification drain
([#2610](https://github.com/mvschwarz/openrig/pull/2610)), and to @natefaerber, whose
approach in [#2467](https://github.com/mvschwarz/openrig/pull/2467) the Herdr view fix follows. Thanks to
@billbenetti, @KM199, @ziadeh, @dajiaohuang, @jajabong, @george661 and
@aanestesia for reports carried into this release, and to @smota for the
OpenTelemetry discussion behind [#2589](https://github.com/mvschwarz/openrig/pull/2589).
## [0.7.9]
Published 2026-11-06 as `@openrig/[email protected]`, built from the commit tagged
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.10` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
+4 -1
View File
@@ -289,7 +289,10 @@ Each array contains resource IDs. These can be:
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed.
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
"not found in resource pool".
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
+13 -2
View File
@@ -201,11 +201,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -270,6 +270,17 @@ opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
the running choice.
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.10`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
+472 -178
View File
@@ -1,192 +1,486 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.10
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
after an upgrade it can still hold the previous version's note.
This note is for an agent on an install that was just upgraded to 0.7.10. OpenRig
0.7.10 adds `rig exec` for finite runs on Claude Code or Codex (not yet run with
a real adapter), sends a held
post-compaction step after two idle polls that can't read the input box, leaves
every tmux mode before a delivery, and shows agent spec advisories at launch. It
fixes in the source the two problems that stopped the OpenShell worker in 0.7.9,
but that worker hasn't run end to end. If you upgraded from 0.7.8 or earlier,
also read "What changed in OpenRig 0.7.9" below, and from 0.7.7 or earlier,
"What changed in OpenRig 0.7.8" too. For the full capability map, read
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
daemon starts, `rig context get reference/whats-new.md` describes this version.
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until
startup.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`. This release adds no migrations.
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
and read any `agent spec advisory` line. A profile's `uses.<category>` written
as a single value now selects that resource; if the resource pool doesn't hold
it, a member using that profile fails to resolve with "not found in resource
pool". Ask the person before you change their spec.
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
failed — plugin contents changed; hook approval required" until relaunched; in
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
no review afterwards.
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
using it; the CA repair is in that file, which the image carries.
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
aliases, may be missing from tool commands, and a model request for
`login: true` gets Codex's refusal, "login shell is disabled by config". To
defer to Codex's own setting, run `rig config set launch.codex_login_shell
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
at each seat's next launch, and a restart in place keeps the running choice.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
## New things you can do
## Finite runs: `rig exec`
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
Claude Code or Codex.
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
`rig exec harnesses` reports the adapter and native versions it finds.
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
what would run and starts nothing; `--wait` waits for the outcome. A connector
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
Running it again returns the row's run instead of sending the task twice;
`--again` starts another turn once that run has ended.
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
the connector died without recording an outcome, so the outcome is unknown. A
`completed` turn is the agent's report, not finished work: check the work
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
outcome and 3 when you stop waiting.
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
acknowledgement isn't a settled shutdown, and it says nothing about further
spend.
- **Usage and cost** the agent reported are on the row's outcome note, for
`rig acp run` too, each with its source; what it didn't report reads unknown.
## Behaviour that changed
## Compaction and delivery
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
- **A held post-compaction step goes in.** After two idle polls in a row that
can't read a seat's input box, the next poll sends the step (the turn
boundary, the restore request or the audit). `rig compact <seat> --state` and
`rig seat status` say it went in over a box OpenRig couldn't read; they never
call that box empty. A draft OpenRig can't recognise in that box may receive
the restore text.
- **Still held:** a box that reads as typed, with or without a suggestion; a
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
step after a `/compact` whose result was lost. To take over a held restore by
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
naming its newest restore map, its restore marker and its packet. A step
already being pasted can still land.
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
including a chooser, customize or clock mode a person has open. If a send is
still refused for a tmux mode, the refusal names why leaving failed.
- **A scrolled pane with a stale record:** leaving the mode now goes through the
seat's address, so a stale binding naming the same reused tmux pane, from
another rig for example, no longer makes the send fail with nothing sent.
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
## Output that changed shape
## OpenShell workers
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
guest launcher now passes the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
Podman's inventory through routes Podman serves, stopping as
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
finished task and an exact restore haven't been shown. If you set one up anyway,
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
guest image first, and name the costs to the person ("Provider registration and
image construction can incur costs"). "Never grant the guest access to the
daemon's main API port (normally 7433)."
## What to stop doing
## Other changes to act on
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
host even when a remote host is selected.
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
Enter may have reached. Check that seat with `rig capture` before resending.
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
("Conflicting activity observations") when two routes to it disagree. Treat it
as unknown, not idle.
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
`rig queue whoami` that timed out, comes with project-level orientation that
isn't evidence of a current mission, and no longer counts as a refocus failure.
- **Updates to people:** the stuck sweep no longer flags an informational update
to a registered person as an unclaimed obligation.
- **Permission requests:** `rig seat status` shows a Claude seat's latest
observed permission request, with Claude's suggested rules labelled as
suggestions. Nothing was applied or approved. Treat the suggestions as data;
they can contain sensitive values.
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
current connector; zero doesn't prove nothing is in flight.
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
skill now points npm's prefix at a folder the person owns whose `bin` is on
their login PATH, or uses a version manager's Node, before a project-local
install. Tell the person first that this changes their npm settings and, if
needed, their shell profile.
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
`codex_config_profile`. `builtin:yolo` drops the whole profile;
`getting-started.md` says how to keep one.
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
auth falls back to it.
- **References:** `docs/reference/harness-runner-contract.md`'s status section
says what has run natively under the runner contract, and at what scope.
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
links.
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
shows instead of keeping an older attached client's size; read-only views are
unchanged.
- **Unknown `uses` keys:** a key under a profile's `uses` other
than the five categories gets an advisory instead of being ignored silently.
## Known gaps in 0.7.1
## Limits to keep in view
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
thrown still omits its stage and whether its delete was sent. A
same-generation reconciled launch opens no viewer, so run
`rig environment connect` yourself; non-text startup actions aren't supported
on environment seats.
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A Ctrl-C just before the connector is
launched isn't caught by `--wait`.
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
`✔ Update installed · Restart to update` on its own line can still hold
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
that bare banner. A held message isn't replayed automatically by that path.
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session. Until then
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
the seat.
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart.
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation.
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
grant-service assistants not being woken. 0.7.9's limits on a restore request
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
runner contract, and Codex seats' `rig: command not found` are fixed or
changed. The 0.7.8 limits below that 0.7.9
didn't fix or change, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.9
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
upgrading from 0.7.8 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
wrote in each managed guidance block, an index for reading a rig's outbox,
and the stream intake position.
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
the one still unclaimed, and the row wakes the queue worker like any queue row.
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
intake rows, run `rig config set stream.intake off`.
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from the preserved copy.
- **Where seat guidance goes now:**
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
`AGENTS.md`, unless that file or folder is a link;
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
instructions, not in the repository `AGENTS.md`, when no config layer
OpenRig reads sets `developer_instructions`;
- a Codex resume, fork, restore or handover successor, an older or unknown
Codex, and a config layer that sets `developer_instructions` or can't be read
still use the repository `AGENTS.md`;
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
`~/.codex`, so a `developer_instructions` set in their home is replaced for
that session. Declare the same absolute path as `CODEX_HOME` in the member's
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
("Codex guidance at launch") is the rule;
- a managed block a person edited by hand is kept and named
(`guidance_block_edited`), not replaced. Read the warning before you change
the block.
- **Slack:** on the first start after upgrading, the connector edits the asks
that ended earlier to show they were handled, at Slack's edit rate: about an
hour for 450 asks. `rig slack status` shows pending edits.
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
helper; an older helper rejects the new startup step, and the error names the
rebuild.
### Runner seats
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
permissions, and section 4 for activity. Read them before writing or changing a
runner.
- **Typed words:** a reply of just yes or no counts as the agent's allow or
reject. Any other words choose nothing: OpenRig's default permits the request
when the agent offered an allow option, and the read result carries the words
whole as `said`. A runner may queue them for the agent as an ordinary prompt
after the current turn, never as the answer; one that drops them still keeps
the contract. The ask now tells the person this.
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
an ask closed without a choice, including a row passed to an agent seat) is
recorded once, as a stream item from the seat, naming the tool call, why no
person answered and the option given. Read them with
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
has no destination and never changes the permit. Stream intake reads past it, so
it opens no intake row and wakes no one.
- **The working beat:** while a prompt is outstanding and the agent is alive, a
runner may post `active` / `liveness` so a long call keeps reading working.
After delivering an answer or permit, it posts `active` /
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
runner and the dsh example post it.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
It now covers the beat, and no longer falsely fails a correct runner on a busy
machine.
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
`env:`, and section 8 names ACP's `session/resume`.
### OpenShell workers
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
Startup reaches the guest through a hash-checked first-input file, and nothing
is typed through the host viewer. Readiness checks the guest's own execution,
and a failed owned delete reports the stage that failed. Release testing on
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
settings the OpenShell exec supplies, and deleting an owned environment never
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
fixes for both are described above. "Never grant the guest access to the
daemon's main API port (normally 7433)."
### Assistants connected through a grant service (experimental)
Experimental, unsupported, off by default, and at the person's own risk. With
`rig mcp serve --http --grant-service <url>` and a grant service the person
supplies and runs, a connected assistant can claim, update and park the rows
addressed to its own seat, and see its team with `rig_team`, within the scope its
existing tools have. Nothing wakes it: an item stays queued until its receiver
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
to the person before you set this up. `docs/reference/hosted-members.md` is the
rule.
### Other changes to act on
- **Held messages:** a seat that starts, or takes over a seat, with held
messages is told how many there are. Read them with
`rig seat held-messages <seat>`.
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
when it can't see what its Enter did, and `rig walk` reports unconfirmed
instead of lost. Inspect the recipient before any resend.
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
`rig queue update <id> --closure-required-at <ISO>`.
- **Shared workspaces:** any registered person can settle another's ask as
themselves, and the first committed answer wins; a later answer gets 409
`qitem_already_terminal` and records nothing. A person registered with no
connector binding is asked on Steering and in the Feed only.
- **Handover:** a fresh or rebuild successor's first turn lists its startup
files, role first. A successor that started without a required startup file is
marked in the seat list.
- **Ambiguous rig names:** when an active rig name matches more than one rig,
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
- **Seat activity:** window samples older than their bound no longer decide a
seat's activity, so a seat whose pane can't be read reads unknown rather than
its last verdict. Treat unknown as unknown, not idle.
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
one Claude member its own `--channels`, untested with a real channel plugin.
- **Files:** `rig down` removes OpenRig's own additions from a working folder
once no live seat uses them. `rig export` with no `-o` writes
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
1 for malformed optional frontmatter.
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
explains the optional move; a fresh launch there starts a new native
conversation, so ask the person before moving.
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
check it with `bash -lc 'command -v node rig'`.
### Limits to keep in view
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which isn't tested,
and direct messages aren't supported. The channel and people reads haven't
been run against a live Slack workspace. Redaction of the bot token from a
transport error can still leave a malformed token fragment shorter than six
characters, so review an error before sharing it.
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. `rig host ls` reads the ssh config with
`ssh -G`, which runs any `Match exec` command in it.
- **Shared asks:** a click's option written before its answer row can stand
beside or inside the closer's answer without the first clicker's authorship.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind.
- **Existing seats** pick up Codex launch instructions and the single
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
`AGENTS.md` that already grew a line per launch isn't trimmed.
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched.
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
a re-parked click and unrecorded default permits are fixed or changed. The
other 0.7.8 limits below, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.8
The changes from 0.7.7 to 0.7.8, published on 2026-11-05, for an install
upgrading from 0.7.7 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and a seat's chosen permission person.
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
changed. Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
### Runner seats: a person answers permission requests
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
before writing or changing a runner.
- **Who is asked:**
- **the default person** (`rig gateway default`, or the only registered
person) gets an ordinary ask in the Feed and on Slack;
- **a chosen person,** after
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default;
- **no one:** when no one can be asked, the request is "Permitted at once,
with the agent's own allow option";
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
- **What a person's answer does:** a click gives that option, a plain yes gives
the allow, and a plain no gives the reject. "A person's no is never a grant."
An unanswered request stays pending until the agent's turn ends.
- **The runner's protocol:**
- `POST /api/activity/permission-requests` submits the request;
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
Deliver an answer once, only to the live request.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
from the installed package. It needs Node and tmux, and no daemon, model or
network.
- **The worked example** is the dsh runner under
`daemon/docs/reference/community-harness-examples/dsh/`.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token.
### OpenShell workers
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8; the later repairs are described above.
**What it needs:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs", so name them to
the person before you start. "Never grant the guest access to the daemon's main
API port (normally 7433)."
### Other changes to act on
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
follows a mission's current owners, with the same Herdr path as slices.
- **Launch choices:**
- `claude_mcp: none` on a Claude member launches it without MCP servers;
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
own persistent tmux server.
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
entry. Use them instead of editing `hosts.yaml` by hand.
- **Another user's daemon:** when `rig doctor` warns that the port is held by
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
or set `OPENRIG_URL` to share that daemon on purpose.
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
- **Read-only tmux clients:** a submit key refused because of one goes through
the paste route.
- **Slack:** a typed reply to a decision parked on its person is recorded as the
answer. Read the person's words; don't treat the close as approval.
- **The TUI:** agent pages now show health findings and recently finished items,
and an agent spec's "not loaded in this view" means it wasn't read, not that
nothing runs it.
### Limits to keep in view
- ~~**No harness has run end to end under the runner contract yet.** The dsh
example and the conformance check were checked against stand-ins.~~ *Corrected
after release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
shipped dsh example runner for a person's allow-once and reject-once, and
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
input, activity, cancel and exact resume. The conformance check itself runs
against a stand-in agent.
- **The answer owner is a registered person,** and a colleague's answer is
followed on this host only. A request handed to another host waits as needs
input until the turn ends.
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
If the agent offers only `allow_always`, a permit makes its harness remember a
permission nobody chose.
- **Coming as follow-ups:** recording permits made when no one can be asked, and
passing typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
waiting after the hold is released.
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
idle.
- **The [0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits still apply.**
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7262,7 +7262,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7300,7 +7300,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7342,7 +7342,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+7
View File
@@ -104,6 +104,13 @@ Keys:
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
launch.codex_login_shell defer to Codex's own login-shell setting in managed Codex seats (default off)
Off adds allow_login_shell=false: tool commands inherit the seat's environment, so plain rig
works wherever OpenRig is installed. Inherited setup isn't re-run per command: setup only in
login or rc files (including a tmux default-command that isn't a login shell) and shell
functions or aliases may be absent. On omits that override; it doesn't force login shells,
and a login file that sets PATH outright can then hide rig. Applies at each seat's next
launch; a restart in place keeps the running choice.
terminal.inherit_tmux_environment inherit stored tmux global values for new agents and successors (default off)
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
+6 -3
View File
@@ -14,7 +14,7 @@ import {
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
@@ -267,7 +267,7 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
);
const DEFAULTS = {
launch: { nonInterruptive: false },
launch: { nonInterruptive: false, codexLoginShell: false },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
// FR-4 — own-host display name; default "localhost" (PRD-named).
@@ -470,6 +470,7 @@ export const VALID_KEYS = [
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist",
@@ -598,6 +599,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
"launch.non_interruptive": { primary: "OPENRIG_LAUNCH_NON_INTERRUPTIVE" },
"launch.codex_login_shell": { primary: "OPENRIG_LAUNCH_CODEX_LOGIN_SHELL" },
"health.context_pressure.warning_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT" },
"health.context_pressure.critical_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
@@ -707,6 +709,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"launch.non_interruptive": ["launch", "nonInterruptive"],
"launch.codex_login_shell": ["launch", "codexLoginShell"],
"health.context_pressure.warning_percent": ["health", "contextPressure", "warningPercent"],
"health.context_pressure.critical_percent": ["health", "contextPressure", "criticalPercent"],
"files.allowlist": ["files", "allowlist"],
@@ -1329,7 +1332,7 @@ export class ConfigStore {
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
launch: { nonInterruptive: v("launch.non_interruptive") as boolean },
launch: { nonInterruptive: v("launch.non_interruptive") as boolean, codexLoginShell: v("launch.codex_login_shell") as boolean },
runtime: {
readinessTimeoutSeconds: v("runtime.readiness_timeout_seconds") as number,
codex: {
@@ -0,0 +1,21 @@
import { it, expect, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConfigStore } from "../src/config-store.js";
import { SettingsStore } from "../../daemon/src/domain/user-settings/settings-store.js";
it("launch.codex_login_shell defaults off in both stores, with file and env precedence", () => {
const home=mkdtempSync(join(tmpdir(),"codex-login-shell-")), file=join(home,"config.json");
const cli=new ConfigStore(file), daemon=new SettingsStore(file);
try {
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL",undefined);
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
writeFileSync(file, JSON.stringify({launch:{codexLoginShell:true}}));
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL","false");
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
} finally { vi.unstubAllEnvs(); rmSync(home,{recursive:true,force:true}); }
});
@@ -143,6 +143,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist", "progress.scan_roots",
@@ -10,8 +10,8 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
lands, something rhymes, and you go check instead of building.
Ninety-one top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.9`). This marker describes the pack's teaching, not publication or live
Ninety-two top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.10`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -56,7 +56,12 @@ one command away. Your memory of the fleet is a claim about the past.
and whether the thing you did an hour ago actually landed. Before a relaunch, it shows what the
seat's next fresh managed launch will ask for, labelled planned rather than observed: the native
permission argument, where each part comes from, what OpenRig adds and the commands that change
it, plus the seat's terminal policy.
it, plus the seat's terminal policy. **When a Claude seat seems stuck on a permission prompt, read
this before capturing panes:** with openrig-core 0.1.26's `PermissionRequest` hook it shows the
latest observed permission request (time, actor and a fixed command descriptor that hides the
arguments) and Claude's bounded suggested rules, which can contain paths, patterns or sensitive
values. It's evidence, not an applied permission: it applies and approves nothing, and it's held
in memory for five minutes and lost on a daemon restart (`docs/reference/seat-delivery.md`).
- **`rig view list` / `rig view show <lens>`** — named lenses over coordination state: use
`view show escalations` for owner attention, `view show pickup` for claimed-row state, and
`view show execution` for done/now/next and current authored planning guidance with its
@@ -141,8 +146,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
holds only a suggestion, or typed text beside one. Don't use it to type over a half-written box:
to reach a seat with something that can't wait, use `--urgent` where its typing-guard level lets
urgent input through.
A send to a pane in tmux copy or view mode leaves the mode once and delivers; a pane that can't
leave is refused with `pane_in_mode`. With `transport.verify_input_box` on (off by default),
A send or wake to a pane in a tmux mode leaves every mode first and delivers, including a
chooser, customize or clock mode a person has open; a pane that can't leave is refused with
`pane_in_mode`, and the refusal names why. With `transport.verify_input_box` on (off by default),
`--verify` to Claude Code or Codex reads verified only when the text was seen in the input box
and the box was empty after Enter; it only reports.
- **`rig broadcast`** — one fact to everyone at once, rather than relaying it N times and getting
@@ -200,6 +206,20 @@ unseen — which makes messages the one delivery channel that never gets skipped
judge and close. Each permission request is a row to `--permissions-to` (default: you). The guide
is `docs/reference/acp-agents.md` in the source repo, and `docs/reference/a2a-agents.md` covers
A2A agents.
- **`rig exec run <row> --harness claude|codex`** — a finite task for Claude Code or Codex without
building a seat: the same single turn through that harness's ACP adapter, run by a detached
connector that outlives the command, so closing the terminal or leaving `--wait` doesn't stop it.
Install the adapters first (`@agentclientprotocol/claude-agent-acp` 0.101.0 and
`@agentclientprotocol/codex-acp` 2.5.0); `rig exec harnesses` shows the versions found, and
`--plan` starts nothing. **The row stays the custody record:** it gets the same notes as `rig acp
run`, each permission request is a row to `--permissions-to`, and the outcome is the agent's
report, so accepting the work stays with the row's custodian. One run per row: running it again
returns that run, and `--again` starts another turn once it has ended. `rig exec status
<runId|row>` reads it; a connector that died without an outcome reads `interrupted`, outcome
unknown. **`rig exec cancel <runId>`** (not `rig acp cancel`) asks the agent to stop its turn;
the acknowledgement isn't a settled shutdown and says nothing about further spend. It hasn't run
with a real adapter yet, and concurrent runs, long tasks and recovery across a reboot aren't
covered. `rig acp run` stays the synchronous form, in your terminal, for any ACP agent.
- **`rig terminal open <view>`** — open a desktop terminal with live agent tiles; a window is the
default. **Opening `rig terminal open saved:kernel --window` is the step after install or start**:
say you're opening it, run it on the daemon's desktop, then tell the person where it opened. Don't
@@ -790,6 +810,10 @@ version, from checking the environment to a useful report to the OpenRig team.
ID is verified.
Managed Codex launches turn off Codex's startup update check, so no update notice stops them;
updating Codex is the operator's job.
Managed Codex seats run tool commands in a non-login shell, so plain `rig` works however OpenRig
was installed (with a Codex that supports `allow_login_shell`); setup that lives only in login or
rc files, and shell functions and aliases, may be missing there. `rig config set
launch.codex_login_shell true` defers to Codex's own setting at each seat's next launch.
- **`rig seat set-model` / `stop` / `clean`** — persist the model for later managed resumes, stop
exactly one live seat (ending its session and any work in progress), or clear a dead seat's
stale binding. A stop is recorded, so the rig doesn't read `degraded` for that seat, and a rig
@@ -875,9 +899,17 @@ version, from checking the environment to a useful report to the OpenRig team.
kept short enough to run as a command and is sent only to an idle seat; its own result reads
`submitted, unconfirmed` (it reached the seat, not proof compaction ran), `uncertain` (not
replayed) or `not sent`, so confirm by effect. Before each paste (preparation prompt, `/compact`,
restore prompt) it checks the input box and holds that step while the box holds typed,
unreadable or changed input, so a draft needs no typing guard to protect it; the check is a read just before the paste,
not a lock. An idle Claude seat under the installed-update notice still reads idle, so it needs no
then the turn boundary, restore request and audit) it checks the input box, so a draft needs no
typing guard to protect it. Typed, unreadable or changed input holds the preparation prompt and
`/compact`. After `/compact`, a box that reads typed still holds the step, so a typed draft left
there holds the restore until it's cleared or you take over; but after two idle polls in a row
that can't read the box, the next poll sends the step, recorded as delivered over a box OpenRig
couldn't read (never as empty), so a draft OpenRig can't recognise there may receive the restore
text. The step after a `/compact` whose result was lost, and a seat whose draft-aware delivery or
typing guard holds OpenRig's input, still hold. To take over a held restore, run `rig compact
<seat> --cancel`, then send a restore request naming the newest restore map, the restore marker
and its packet; a step already being pasted can still land. The check is a read just before the
paste, not a lock. An idle Claude seat under the installed-update notice still reads idle, so it needs no
hand compaction for that. Claude seats that select `openrig-core` load its
hooks; a seat can give a slow refocus more time with `OPENRIG_REFOCUS_BUDGET_MS` (4500 to 52000).
The refocus work packet stays off by default for Claude Code and Codex;
@@ -889,7 +921,8 @@ version, from checking the environment to a useful report to the OpenRig team.
through the next retry interval; `--state` shows `idleDeadlineAt`. In either mode, expiry reads
`map_complete_seat_never_idle` when the map is finished, or `preparation_incomplete` when it is not.
`rig seat status` shows an owed restore stage and its last
delivery result, and repeated delivery failures open one queue item for the seat's orchestrator, or a
delivery result, including a step that went in over an unreadable box, and repeated delivery
failures open one queue item for the seat's orchestrator, or a
person when it has none.
- **Codex seats get `openrig-core` too: its refocus after a compaction and its skills, with no
first-launch review stop.** OpenRig installs the plugin into Codex on boot and on a fresh launch.
@@ -1057,6 +1090,14 @@ working arrangement becomes something someone else can instantiate.
baseline, and at launch the runner says whether the seat has a stored sign-in or provider key
(advisory; launch continues). A provider key reaches a Pi seat only when its name is in
`recovery.provider_auth_env_allowlist`; `MINIMAX_API_KEY` serves `minimax/<id>` models.
- **`rig agent validate <agent.yaml>`** — check an agent spec before you relaunch from it; that it
launched doesn't make it right. It and every launch (`rig up`, a restore, a first-start retry)
print `agent spec advisory` lines, which never make a spec invalid: an unknown key under a
profile's `uses`, or a single value where `uses.<category>` takes a list. That single value is
read as a one-item list and resolves like one, so **a member whose profile selects a resource
that isn't available fails that member's resolution** ("not found in resource pool"), as the
list form does; it isn't only a warning. A native resume that continues a saved conversation
shows no advisories.
- **`rig context trace --pod <pod>`** — walk the context chain through instance → rig → pod → seat
when the pod altitude matters.
- **`rig specs show` / `preview` / `add` / `sync` / `rename` / `remove`** — where a spec lives,
@@ -1332,8 +1373,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
stop them without killing the rig.
- **`rig environment`** — not `rig env`: places a worker seat in an NVIDIA OpenShell sandbox and returns
verified result files (`setup`, `create` or `attach`, `export`; reference in the packaged
`daemon/assets/openshell/README.md`). In 0.7.8's release testing the worker never became usable, so
read the installed version's release notes before relying on it.
`daemon/assets/openshell/README.md`). In 0.7.8's release testing the worker never became usable;
0.7.10 fixes in the source the two problems that stopped it in 0.7.9, but it hasn't run end to
end. Read the installed version's release notes before relying on it.
- **`rig setup`** — what OpenRig would change about this machine, shown before it touches anything.
It installs herdr by default on macOS and Linux (`--no-herdr` declines; if the install fails,
views fall back to plain tmux) and on a Mac offers Ghostty once (`--ghostty` / `--no-ghostty`).
@@ -1521,6 +1563,6 @@ deliberate writing.
**`rig --help`**, then `rig <verb> --help`.
Ninety-one verbs ship, and this page named most of them once. **You will not remember which — you
Ninety-two verbs ship, and this page named most of them once. **You will not remember which — you
are meant to remember only that the list is long enough to be worth reading before you build
anything.** The question is never *how do I write this*; it is **does this already exist**.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
+4 -2
View File
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import type { TmuxAdapter } from "./tmux.js";
import type { SeatLaunchEnvironment } from "../domain/seat-launch-environment.js";
import type { ResumeResult } from "./claude-resume.js";
import { assessNativeResumeProbe, buildCodexResumeCore } from "../domain/native-resume-probe.js";
import { assessNativeResumeProbe, buildCodexResumeCore, codexNonLoginShellArg } from "../domain/native-resume-probe.js";
import { verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { execRuntimeProbe } from "./runtime-probe-exec.js";
import { shellQuote } from "./shell-quote.js";
@@ -38,6 +38,8 @@ interface CodexResumeOptions {
detectDaemonSupport?: CodexDaemonSupportDetector;
/** #275: Codex's own answer on the plain floor's network default; absent keeps the existing invocation. */
readNetworkDefault?: CodexNetworkDefaultReader;
/** launch.codex_login_shell: true keeps Codex's own login shells for tool commands. Absent: off. */
codexLoginShell?: () => boolean;
/** Ensure the managed Codex config (hooks, trust) just before a resume, as a runtime-adapter launch does, in the
* seat's own home when it has one. */
ensureManagedConfig?: (codexHome?: string) => Promise<void>;
@@ -138,7 +140,7 @@ export class CodexResumeAdapter {
workspaceArg.trim() || undefined,
resolvedPosture,
model,
`${postureArg}${networkArg}`,
`${postureArg}${networkArg}${this.options.codexLoginShell?.() ? "" : codexNonLoginShellArg}`,
daemonSupport?.kind === "supported",
effort,
) + ownRoots.filter((root): root is string => !!root).map((root) => ` --add-dir ${shellQuote(root)}`).join("");
@@ -33,7 +33,7 @@ import {
readCodexThreadIdFromCandidateHomes,
type ResolveHomeDirByPid,
} from "../domain/codex-thread-id.js";
import { assessNativeResumeProbe, buildCodexResumeCore, hasCodexUpdateHeader, type NativeResumeProbeResult } from "../domain/native-resume-probe.js";
import { assessNativeResumeProbe, buildCodexResumeCore, codexNonLoginShellArg, hasCodexUpdateHeader, type NativeResumeProbeResult } from "../domain/native-resume-probe.js";
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
import { CODEX_NETWORK_DEFAULT_OVERRIDE, codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
import { resolveCodexGitAddDirs, type CodexGitAddDirResolver } from "../domain/codex-git-add-dirs.js";
@@ -95,6 +95,8 @@ type CodexProcess = NativeProcessRow;
export interface RunningCodexLaunch {
networkOverride: boolean;
daemonOptOut: boolean;
/** Whether it was launched with tool commands in a non-login shell (`allow_login_shell=false`). */
nonLoginShell: boolean;
addDirs: string[];
}
@@ -115,6 +117,8 @@ interface PreparedCodexLaunch {
daemonOptOut: boolean;
daemonArg: string;
networkArg: string;
/** Keeps OpenRig's command folder on the PATH of the seat's tool commands; see codexNonLoginShellArg. */
loginShellArg: string;
}
export interface CodexAdapterFsOps {
@@ -176,6 +180,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// #275: Codex's own answer on whether the plain floor may get network access. Startup wires the
// real reader; absent keeps every invocation unchanged.
private readNetworkDefault?: CodexNetworkDefaultReader;
// launch.codex_login_shell: true keeps Codex's own login shells for tool commands. Absent: off.
private codexLoginShell?: () => boolean;
// Issue #121: git metadata dirs for the `--add-dir`s of fresh, resume and fork (a linked worktree's `.git` is a file).
// Default = the real resolver; tests may inject a controlled one.
private resolveGitAddDirs: CodexGitAddDirResolver;
@@ -230,6 +236,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
profileFingerprint?: (path: string) => Promise<ProfileFingerprint>;
detectDaemonSupport?: CodexDaemonSupportDetector;
readNetworkDefault?: CodexNetworkDefaultReader;
codexLoginShell?: () => boolean;
resolveGitAddDirs?: CodexGitAddDirResolver;
/** The seat's own topology folder, ready to write (its LEARNED.md and recap). Absent: no seat folder is added. */
prepareSeatFolder?: PrepareCodexSeatFolder;
@@ -267,6 +274,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
this.launchPath = deps.launchPath;
this.detectDaemonSupport = deps.detectDaemonSupport;
this.readNetworkDefault = deps.readNetworkDefault;
this.codexLoginShell = deps.codexLoginShell;
this.resolveGitAddDirs = deps.resolveGitAddDirs ?? resolveCodexGitAddDirs;
this.activityRelayPath = deps.activityRelayPath;
this.listProcesses = deps.listProcesses ?? defaultListProcesses;
@@ -900,7 +908,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// Only a launch that starts a conversation may install the core plugin; a resume never does.
const prepared = await this.prepareLaunch(binding, opts.name, undefined, { installCorePlugin: !opts.resumeToken });
if (!prepared.ok) return prepared;
const { modelArg, effortArg, appliedLaunch, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonArg, networkArg } = prepared;
const { modelArg, effortArg, appliedLaunch, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonArg, networkArg, loginShellArg } = prepared;
// Fork branch: `codex fork <parent_thread_id>`. Captures the NEW thread id
// post-fork. Parent thread id is NOT persisted onto the new seat record
@@ -922,7 +930,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// 0.5.2-07 A2-3: the FORK path threads the SPEC model too (fork-instantiate reverted it before).
// The git dirs and the seat folder follow the parent id: ps flattens argv, so a path with a space placed before the
// id would be read as the id when the fork is verified.
const cmd = `codex${daemonArg}${postureArg}${networkArg}${modelArg}${effortArg} fork${queueStateDirArg} ${shellQuote(parentId)}${gitDirArg}${seatFolderArg}`;
const cmd = `codex${daemonArg}${postureArg}${networkArg}${loginShellArg}${modelArg}${effortArg} fork${queueStateDirArg} ${shellQuote(parentId)}${gitDirArg}${seatFolderArg}`;
const textResult = await this.tmux.sendShellCommand(binding.tmuxSession, await this.seatCommand(binding, cmd));
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
@@ -959,7 +967,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// 0.5.2-07 A2-3: the pod-aware RESUME path threads the SPEC model too (reverted before — the
// grounding map assumed codex parity with the claude adapter, but only fresh emitted -m).
? this.resumeCore(prepared, opts.resumeToken, binding)
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
: `codex${daemonArg}${postureArg}${networkArg}${loginShellArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
const staged = await this.seatCommand(binding, cmd);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Codex launch preparation; no native command was sent.", recovery: "attention_required" };
@@ -1088,7 +1096,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
gitDirArg: running.addDirs.map((dir) => ` --add-dir ${shellQuote(dir)}`).join(""),
seatFolderArg: "",
daemonOptOut: running.daemonOptOut, daemonArg: running.daemonOptOut ? " --no-daemon" : "",
networkArg: running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "" };
networkArg: running.networkOverride ? ` -c ${shellQuote(CODEX_NETWORK_DEFAULT_OVERRIDE)}` : "",
loginShellArg: running.nonLoginShell ? codexNonLoginShellArg : "" };
}
// OPR.0.3.4.7 — profile-LOAD probe before launch/resume. A legacy
@@ -1135,14 +1144,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// The launch facts keep the probed daemon choice, so a printed recovery command can repeat it; no probe, no fact.
const launchFacts = daemonSupport ? { ...appliedLaunch, daemonOptOut } : appliedLaunch;
return { ok: true, model, modelArg, effort, effortArg, profile, appliedLaunch: launchFacts, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonOptOut, daemonArg, networkArg };
return { ok: true, model, modelArg, effort, effortArg, profile, appliedLaunch: launchFacts, postureArg, queueStateDirArg, gitDirArg, seatFolderArg, daemonOptOut, daemonArg, networkArg,
loginShellArg: this.codexLoginShell?.() ? "" : codexNonLoginShellArg };
}
private resumeCore(prepared: PreparedCodexLaunch, resumeToken: string, binding: NodeBinding): string {
const { profile, queueStateDirArg, gitDirArg, seatFolderArg, model, postureArg, networkArg, daemonOptOut, effort } = prepared;
const { profile, queueStateDirArg, gitDirArg, seatFolderArg, model, postureArg, networkArg, loginShellArg, daemonOptOut, effort } = prepared;
// The git dirs and the seat folder follow the conversation id, as on a fork, so the resume proof reads the id even
// from a path with a space.
return buildCodexResumeCore(resumeToken, profile, false, queueStateDirArg.trim() || undefined, binding.launchPosture, model, `${postureArg}${networkArg}`, daemonOptOut, effort)
return buildCodexResumeCore(resumeToken, profile, false, queueStateDirArg.trim() || undefined, binding.launchPosture, model, `${postureArg}${networkArg}${loginShellArg}`, daemonOptOut, effort)
+ gitDirArg + seatFolderArg;
}
@@ -65,6 +65,12 @@ export function buildNativeResumeCommand(
return null;
}
/** Managed Codex seats run tool commands in a non-login shell, so they keep the seat's own environment, including the
* OpenRig command folder at the front of PATH. Codex otherwise gives each command the user's login-shell environment
* and puts back only its own directories, so a login file that sets PATH outright drops that folder. */
export const CODEX_NON_LOGIN_SHELL_OVERRIDE = "allow_login_shell=false";
export const codexNonLoginShellArg = ` -c ${shellQuote(CODEX_NON_LOGIN_SHELL_OVERRIDE)}`;
export function buildCodexResumeCore(
resumeToken: string,
codexConfigProfile?: string | null,
@@ -20,6 +20,7 @@ import { rebindAndVerifyPaneIdentity } from "./seat-attention-reconciler.js";
import type { RunningCodexLaunch } from "../adapters/codex-runtime-adapter.js";
import { emittedEffortFromArgv } from "./seat-effort.js";
import { CODEX_NETWORK_DEFAULT_OVERRIDE } from "./codex-network-default.js";
import { CODEX_NON_LOGIN_SHELL_OVERRIDE } from "./native-resume-probe.js";
/**
* OPR.0.7.0.5 — restart ONE Codex seat in place so it reads its Codex home's current account: the same
@@ -1007,7 +1008,8 @@ function runningLaunch(argv: string[]): RunningCodexLaunch {
else if ((arg === "-c" || arg === "--config") && argv[index + 1] !== undefined) configs.push(argv[++index]!);
else if (arg.startsWith("--config=")) configs.push(arg.slice("--config=".length));
}
return { networkOverride: configs.includes(CODEX_NETWORK_DEFAULT_OVERRIDE), daemonOptOut: argv.includes("--no-daemon"), addDirs };
return { networkOverride: configs.includes(CODEX_NETWORK_DEFAULT_OVERRIDE), daemonOptOut: argv.includes("--no-daemon"),
nonLoginShell: configs.includes(CODEX_NON_LOGIN_SHELL_OVERRIDE), addDirs };
}
/** The settings a Codex command line runs with. cwd, add-dirs, images and the resume target are not settings. */
@@ -2904,7 +2904,10 @@ export class SessionTransport {
let failure = "";
const failed = (message: string) => { failure = ` Leaving it failed: ${message.trim().replace(/\s+/g, " ").replace(/\.$/, "")}.`; };
try {
const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(readTarget) : undefined;
// Leave through the seat's address, as the text and Enter writes do; the guard writes to its bound pane. A raw
// pane id can match a stale binding elsewhere (tmux reuses ids after a server restart), and the guard refuses an
// ambiguous target before writing anything, so the mode was never left.
const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(sessionName) : undefined;
if (left && !left.ok) failed(left.message);
} catch (error) { failed(error instanceof Error ? error.message : String(error)); }
if (await readPaneInMode() !== true) return null;
@@ -0,0 +1,96 @@
// The tile commands earlier releases generated, verbatim, so a view kept across an upgrade is recognised only by
// a command OpenRig actually generated for the same seat. Kept views exist from 0.7.5 on.
// 0.7.9: tile-attach.ts at 7cabac44 (#2464). 0.7.5 through 0.7.8: tile-attach.ts at 80616b79 (#2133).
export function tileAttachArgv079(
tmux: string, server: readonly string[], session: string, readOnly: boolean,
retrySeconds = 30,
): string[] {
// tmux honours ignore-size server-wide: while any client of any session sizes
// its window, ignore-size clients size nothing, so a session they alone view
// keeps its 80x24 creation size. A writable tile therefore ignores size only
// when its session already has a sizing client.
const ro = readOnly ? "-r " : "$ro ";
const pick = (target: string) => (readOnly ? "" : `pick ${target} "$@"; `);
// No apostrophes or control characters in the script: Herdr also stores this
// argv as its restart command. All external values stay positional arguments.
const script = [
'target=$1; shift;',
...(readOnly ? [] : [
// Shell builtins only: the provider PATH may hold nothing but tmux.
'pick() { s=$1; shift; ro=; for f in $("$@" list-clients -t "$s" -F "#{client_flags}" 2>/dev/null); do',
'case ",$f," in *,ignore-size,*) ;; *) ro="-f ignore-size";; esac; done; };',
]),
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || { ${pick('"=$target"')}exec "$@" attach ${ro}-t "=$target"; };`,
// display-message may succeed with empty/fallback context for a missing target.
`if [ "\${snapshot#*:}" != "$target" ]; then ${pick('"=$target"')}exec "$@" attach ${ro}-t "=$target"; fi;`,
'snapshot=${snapshot%%:*};',
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
'if [ -z "$node" ]; then',
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
`${pick('"${snapshot#* }"')}exec "$@" attach ${ro}-t "\${snapshot#* }"; fi;`,
'while :; do',
`${pick('"${snapshot#* }"')}"$@" attach ${ro}-t "\${snapshot#* }";`,
`left=${retrySeconds}; same=0;`,
'while [ "$left" -gt 0 ]; do',
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
'if [ -n "$next" ]; then',
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
// A session can disappear between the two metadata reads; unknown waits.
'if [ -n "$identity" ]; then',
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
'if [ "$next" != "$snapshot" ]; then snapshot=$next; break; fi;',
// The stop adapter detaches BEFORE killing. Give that kill a chance to
// finish; an ordinary Ctrl-b d leaves the original alive and ends the tile.
'same=$((same + 1)); if [ "$same" -ge 3 ]; then exit 0; fi;',
'else same=0; fi; else same=0; fi;',
'sleep 1; left=$((left - 1));',
'done;',
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
'done',
].join(" ");
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
}
export function tileAttachArgv078(
tmux: string, server: readonly string[], session: string, readOnly: boolean,
retrySeconds = 30,
): string[] {
const ro = readOnly ? "-r " : "-f ignore-size ";
// No apostrophes or control characters in the script: Herdr also stores this
// argv as its restart command. All external values stay positional arguments.
const script = [
'target=$1; shift;',
`snapshot=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || exec "$@" attach ${ro}-t "=$target";`,
// display-message may succeed with empty/fallback context for a missing target.
`if [ "\${snapshot#*:}" != "$target" ]; then exec "$@" attach ${ro}-t "=$target"; fi;`,
'snapshot=${snapshot%%:*};',
'node=$("$@" show-environment -t "${snapshot#* }" OPENRIG_NODE_ID 2>/dev/null) || node=;',
'case "$node" in OPENRIG_NODE_ID=?*) ;; *) node=;; esac;',
'if [ -z "$node" ]; then',
'printf "%s\\n" "No managed identity for $target; reopen this view after replacement.";',
`exec "$@" attach ${ro}-t "\${snapshot#* }"; fi;`,
'while :; do',
`"$@" attach ${ro}-t "\${snapshot#* }";`,
`left=${retrySeconds}; same=0;`,
'while [ "$left" -gt 0 ]; do',
'next=$("$@" display-message -p -t "=$target:" "#{pid} #{session_id}:#{session_name}" 2>/dev/null) || next=;',
'if [ "${next#*:}" = "$target" ]; then next=${next%%:*}; else next=; fi;',
'if [ -n "$next" ]; then',
'identity=$("$@" show-environment -t "${next#* }" OPENRIG_NODE_ID 2>/dev/null) || identity=;',
// A session can disappear between the two metadata reads; unknown waits.
'if [ -n "$identity" ]; then',
'if [ "$identity" != "$node" ]; then printf "%s\\n" "Seat identity changed for $target; reopen its view."; exit 1; fi;',
'if [ "$next" != "$snapshot" ]; then snapshot=$next; break; fi;',
// The stop adapter detaches BEFORE killing. Give that kill a chance to
// finish; an ordinary Ctrl-b d leaves the original alive and ends the tile.
'same=$((same + 1)); if [ "$same" -ge 3 ]; then exit 0; fi;',
'else same=0; fi; else same=0; fi;',
'sleep 1; left=$((left - 1));',
'done;',
'if [ "$left" -eq 0 ]; then printf "%s\\n" "No replacement for $target; reopen the view with rig terminal open."; exit 0; fi;',
'done',
].join(" ");
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
}
@@ -7,6 +7,7 @@ import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes,
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
import type { HerdrTransport } from "./herdr-transport.js";
import { earlierTileAttachment } from "./tile-attach.js";
export type ReadFollowInput = (target: ComposedPane) => Promise<"empty" | "text" | "unknown">;
interface Tile extends FollowTile { member: ViewerKey; target: ComposedPane; viewer: ViewerMapping; reason?: string }
@@ -167,6 +168,11 @@ export class HerdrFollowView {
const currentMember = view.task.members.find(m => keyOf(m.key.hostId, m.key.nodeId) === tile.key);
if (currentMember?.availability === "available") {
const target = view.opened.find(p => p.seat === currentMember.seat);
// An upgrade can change only the tile script; the kept tile then follows the current target.
if (target?.attachArgv && tile.target.attachArgv && target.paneCommand !== tile.target.paneCommand
&& target.readOnly === tile.target.readOnly && earlierTileAttachment(tile.target.attachArgv, target.attachArgv, target.readOnly)) {
tile.target = target;
}
if (!target || target.paneCommand !== tile.target.paneCommand || target.readOnly !== tile.target.readOnly
|| JSON.stringify(target.attachArgv) !== JSON.stringify(tile.target.attachArgv)) {
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
@@ -6,6 +6,7 @@ import path from "node:path";
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import { resolveOnPath } from "../../adapters/tmux-exec.js";
import type { ComposedPane } from "./terminal-provider.js";
import { earlierTileAttachment } from "./tile-attach.js";
export interface ViewerKey { hostId: string; nodeId: string }
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
@@ -133,6 +134,12 @@ export class HerdrViewer {
const id = JSON.stringify([key.hostId, key.nodeId]);
entry = journal.entries[id];
let created = false;
// An upgrade can change only the tile script (argv[2]); the seat it attaches is the same, so the kept
// viewer moves to the current script.
if (entry && entry.command !== target.paneCommand && entry.readOnly === target.readOnly && Array.isArray(entry.argv)
&& earlierTileAttachment(entry.argv, target.attachArgv, target.readOnly)) {
entry.command = target.paneCommand; entry.argv = target.attachArgv; if (provision) save();
}
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
}
@@ -1,3 +1,5 @@
import { tileAttachArgv078, tileAttachArgv079 } from "./earlier-tile-attach.js";
// A tile owns its attach process; no daemon watcher or steady-state polling.
// Session names survive fresh launch, handover and restart. The node identity
// must survive too: never follow a same-name session belonging to another seat.
@@ -44,3 +46,15 @@ export function tileAttachArgv(
].join(" ");
return ["/bin/sh", "-c", script, "openrig-tile", session, tmux, ...server];
}
/** Whether a kept tile is the one an earlier release generated for the same seat: exactly the command 0.7.9's, or
* 0.7.5-0.7.8's, script makes for the current tile's session, tmux, server and read-only intent. An upgrade changes
* only OpenRig's own script; any other difference, an edited script included, is a different attachment. */
export function earlierTileAttachment(kept: readonly string[], current: readonly string[], readOnly: boolean): boolean {
if (current.length < 6) return false;
const [session, tmux, ...server] = current.slice(4) as [string, string, ...string[]];
return [tileAttachArgv079, tileAttachArgv078].some((make) => {
const earlier = make(tmux, server, session, readOnly);
return earlier.length === kept.length && earlier.every((word, i) => word === kept[i]);
});
}
@@ -112,6 +112,7 @@ export const SETTINGS_VALID_KEYS = [
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist",
@@ -273,6 +274,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
"launch.non_interruptive": { primary: "OPENRIG_LAUNCH_NON_INTERRUPTIVE" },
"launch.codex_login_shell": { primary: "OPENRIG_LAUNCH_CODEX_LOGIN_SHELL" },
"health.context_pressure.warning_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT" },
"health.context_pressure.critical_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT" },
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
@@ -380,6 +382,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"launch.non_interruptive": ["launch", "nonInterruptive"],
"launch.codex_login_shell": ["launch", "codexLoginShell"],
"health.context_pressure.warning_percent": ["health", "contextPressure", "warningPercent"],
"health.context_pressure.critical_percent": ["health", "contextPressure", "criticalPercent"],
"files.allowlist": ["files", "allowlist"],
@@ -677,6 +680,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "skills.router_line.enabled": return false;
case "onboarding.default_pack.enabled": return true;
case "launch.non_interruptive": return false;
case "launch.codex_login_shell": return false;
case "health.context_pressure.warning_percent": return 95;
case "health.context_pressure.critical_percent": return 99;
// Preview Terminal v0 (PL-018) defaults — match cli/src/config-store.ts.
+4 -2
View File
@@ -761,7 +761,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// A Codex seat's own topology folder (its LEARNED.md and recap) as a writable root; topology.root is read per launch.
const prepareSeatFolder = (sessionName: string) => prepareCodexSeatFolder(sessionName,
String(new ContextPackSettingsStore().resolveOne("topology.root").value));
const codexResume = new CodexResumeAdapter(tmuxAdapter, { ensureManagedConfig: async (home?: string) => { await codexAdapter.ensureManagedConfig(home); }, prepareTeamWorkspace, prepareSeatFolder, resolveGitAddDirs: resolveCodexGitAddDirs, seatLaunchEnvironment, codexHome: configuredCodexHome, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault });
// launch.codex_login_shell, read at each Codex launch (absent or unreadable = off).
const readCodexLoginShell = () => { try { return new ContextPackSettingsStore().resolveOne("launch.codex_login_shell").value === true; } catch { return false; } };
const codexResume = new CodexResumeAdapter(tmuxAdapter, { ensureManagedConfig: async (home?: string) => { await codexAdapter.ensureManagedConfig(home); }, prepareTeamWorkspace, prepareSeatFolder, resolveGitAddDirs: resolveCodexGitAddDirs, seatLaunchEnvironment, codexHome: configuredCodexHome, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, codexLoginShell: readCodexLoginShell });
// OPR.0.4.6.PI1 — the Pi seat-state root + the compiled runner entry (daemon
// dist). Shared by the Pi runtime adapter, the resume adapter, and the
// resume-token capture sidecar reader.
@@ -965,7 +967,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// #2456: the adapters' guidance writes check and keep OpenRig's record of each managed block.
const guidanceBlockRecords = managedBlockRecords(db);
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: claudeCollectorAssetPath, autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), managedBlockRecords: guidanceBlockRecords, recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const codexAdapter = new CodexRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, codexLoginShell: readCodexLoginShell, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
}, resolveManagedConfigSettings: async () => {
@@ -21,7 +21,7 @@ async function finalObservation(output: string) {
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, "codex"), `#!${process.execPath}\n` +
`const fs = require("node:fs");\n` +
`if (process.argv.slice(2).join(" ") !== "-s workspace-write -c check_for_update_on_startup=false resume fixture-id") process.exit(2);\n` +
`if (process.argv.slice(2).join(" ") !== "-s workspace-write -c check_for_update_on_startup=false -c allow_login_shell=false resume fixture-id") process.exit(2);\n` +
`process.stdout.write("\\n".repeat(36) + "fixture waiting for final observation\\n");\n` +
`const tick = setInterval(() => { if (fs.existsSync(${JSON.stringify(gate)})) {\n` +
`process.stdout.write(fs.readFileSync(${JSON.stringify(gate)}, "utf8") + "\\n"); clearInterval(tick);\n` +
+17 -9
View File
@@ -67,12 +67,20 @@ describe("CodexResumeAdapter", () => {
expect(sendText).toHaveBeenCalledOnce();
expect(sendText.mock.calls[0]![0]).toBe("r99-demo1-impl");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid-123'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid-123'");
expect(sendKeys).toHaveBeenCalledOnce();
expect(sendKeys.mock.calls[0]![1]).toEqual(["Enter"]);
expect(sendText.mock.invocationCallOrder[0]).toBeLessThan(sendKeys.mock.invocationCallOrder[0]!);
});
it("launch.codex_login_shell on: the legacy resume keeps Codex's own login shells", async () => {
const sendText = vi.fn(async () => ({ ok: true as const }));
const tmux = mockTmux({ sendText, sendKeys: vi.fn(async () => ({ ok: true as const })) });
const adapter = new CodexResumeAdapter(tmux, { codexLoginShell: () => true });
await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid-123'");
});
it("0.5.2-07: a SPEC-pinned model threads -m onto the legacy codex resume command", async () => {
const sendText = vi.fn(async () => ({ ok: true as const }));
const sendKeys = vi.fn(async () => ({ ok: true as const }));
@@ -81,7 +89,7 @@ describe("CodexResumeAdapter", () => {
await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo", null, undefined, "gpt-5.4-cheap");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -m 'gpt-5.4-cheap' resume 'uuid-123'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' -m 'gpt-5.4-cheap' resume 'uuid-123'");
});
it("#75: reasoning effort threads -c model_reasoning_effort onto the legacy codex resume command", async () => {
@@ -92,7 +100,7 @@ describe("CodexResumeAdapter", () => {
await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo", null, undefined, "gpt-5.4-cheap", "high");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -m 'gpt-5.4-cheap' -c 'model_reasoning_effort=\"high\"' resume 'uuid-123'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' -m 'gpt-5.4-cheap' -c 'model_reasoning_effort=\"high\"' resume 'uuid-123'");
});
it("codex_last: sendText posture-preserving codex -s workspace-write resume --last", async () => {
@@ -104,7 +112,7 @@ describe("CodexResumeAdapter", () => {
await adapter.resume("r99-demo1-impl", "codex_last", null, "/repo");
expect(sendText).toHaveBeenCalledOnce();
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume --last");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume --last");
expect(sendKeys).toHaveBeenCalledOnce();
});
@@ -143,7 +151,7 @@ describe("CodexResumeAdapter", () => {
await adapter.resume("r99-demo1-impl", "codex_id", "uuid; rm -rf /", "/repo");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid; rm -rf /'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid; rm -rf /'");
});
it("profile-bearing resume uses -p flag after preflight passes", async () => {
@@ -155,7 +163,7 @@ describe("CodexResumeAdapter", () => {
await adapter.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo", "my-profile");
expect(exec).toHaveBeenCalled();
expect(sendText.mock.calls[0]![1]).toBe("codex -p 'my-profile' '-c' 'check_for_update_on_startup=false' resume 'uuid-123'");
expect(sendText.mock.calls[0]![1]).toBe("codex -p 'my-profile' '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid-123'");
});
// A restored seat keeps its own topology folder (its LEARNED.md and recap) writable on the plain floor. A named
@@ -166,7 +174,7 @@ describe("CodexResumeAdapter", () => {
await new CodexResumeAdapter(mockTmux({ sendText }), { prepareSeatFolder })
.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
expect(prepareSeatFolder).toHaveBeenCalledWith("r99-demo1-impl");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid-123' --add-dir '/topology/rigs/demo/seats/r99-demo1-impl'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid-123' --add-dir '/topology/rigs/demo/seats/r99-demo1-impl'");
const profileText = vi.fn(async () => ({ ok: true as const }));
const profileFolder = vi.fn(() => "/topology/rigs/demo/seats/r99-demo1-impl");
@@ -185,7 +193,7 @@ describe("CodexResumeAdapter", () => {
await new CodexResumeAdapter(mockTmux({ sendText }), { resolveGitAddDirs, prepareSeatFolder })
.resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
expect(resolveGitAddDirs).toHaveBeenCalledWith("/repo");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid-123' --add-dir '/repo/.git' --add-dir '/topology/rigs/demo/seats/r99-demo1-impl'");
expect(sendText.mock.calls[0]![1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid-123' --add-dir '/repo/.git' --add-dir '/topology/rigs/demo/seats/r99-demo1-impl'");
const profileText = vi.fn(async () => ({ ok: true as const }));
const profileDirs = vi.fn(async () => ["/repo/.git"]);
@@ -456,6 +464,6 @@ describe("a seat's own permission envelope on the legacy resume (argv only)", ()
it("keeps the call unchanged without an envelope", async () => {
const sendText = vi.fn(async () => ({ ok: true as const }));
await new CodexResumeAdapter(mockTmux({ sendText })).resume("r99-demo1-impl", "codex_id", "uuid-123", "/repo");
expect((sendText.mock.calls[0] as unknown as [string, string])[1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'uuid-123'");
expect((sendText.mock.calls[0] as unknown as [string, string])[1]).toBe("codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'uuid-123'");
});
});
@@ -101,7 +101,7 @@ function expectedFreshLaunchCommand(options: { cwd?: string; model?: string; eff
const queueDirArg = options.queueRoot === null ? "" : ` --add-dir ${quote(options.queueRoot ?? testQueueRoot())}`;
const modelArg = options.model ? ` -m ${quote(options.model)}` : "";
const effortArg = options.effort ? ` -c ${quote(`model_reasoning_effort="${options.effort}"`)}` : "";
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false' -C ${quote(cwd)}${gitDirArg}${queueDirArg}${modelArg}${effortArg}`;
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' -C ${quote(cwd)}${gitDirArg}${queueDirArg}${modelArg}${effortArg}`;
}
// A resume and a fork add the same git metadata dir as the fresh launch, after the conversation id (`gitCwd` null: none,
@@ -114,14 +114,14 @@ function expectedResumeCommand(token = "sess-456", queueRoot: string | null = te
const queueDirArg = queueRoot === null ? "" : ` --add-dir ${quote(queueRoot)}`;
const modelArg = model ? ` -m ${quote(model)}` : "";
const effortArg = effort ? ` -c ${quote(`model_reasoning_effort="${effort}"`)}` : "";
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false'${modelArg}${effortArg} resume ${quote(token)}${queueDirArg}${gitDirOption(gitCwd)}`;
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false'${modelArg}${effortArg} resume ${quote(token)}${queueDirArg}${gitDirOption(gitCwd)}`;
}
function expectedForkCommand(parentId = "parent-thread-id", options: { model?: string; effort?: string; queueRoot?: string | null; gitCwd?: string | null } = {}): string {
const queueDirArg = options.queueRoot === null ? "" : ` --add-dir ${quote(options.queueRoot ?? testQueueRoot())}`;
const modelArg = options.model ? ` -m ${quote(options.model)}` : "";
const effortArg = options.effort ? ` -c ${quote(`model_reasoning_effort="${options.effort}"`)}` : "";
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false'${modelArg}${effortArg} fork${queueDirArg} ${quote(parentId)}${gitDirOption(options.gitCwd === undefined ? "/project" : options.gitCwd)}`;
return `codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false'${modelArg}${effortArg} fork${queueDirArg} ${quote(parentId)}${gitDirOption(options.gitCwd === undefined ? "/project" : options.gitCwd)}`;
}
function expectedProfileFreshLaunchCommand(profile: string, options: { cwd?: string; model?: string; queueRoot?: string | null } = {}): string {
@@ -129,12 +129,12 @@ function expectedProfileFreshLaunchCommand(profile: string, options: { cwd?: str
const gitDirArg = ` --add-dir ${quote(nodePath.join(cwd, ".git"))}`;
const queueDirArg = options.queueRoot === null ? "" : ` --add-dir ${quote(options.queueRoot ?? testQueueRoot())}`;
const modelArg = options.model ? ` -m ${quote(options.model)}` : "";
return `codex -p ${quote(profile)} '-c' 'check_for_update_on_startup=false' -C ${quote(cwd)}${gitDirArg}${queueDirArg}${modelArg}`;
return `codex -p ${quote(profile)} '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' -C ${quote(cwd)}${gitDirArg}${queueDirArg}${modelArg}`;
}
function expectedProfileResumeCommand(profile: string, token = "sess-456", queueRoot: string | null = testQueueRoot(), gitCwd: string | null = "/project"): string {
const queueDirArg = queueRoot === null ? "" : ` --add-dir ${quote(queueRoot)}`;
return `codex -p ${quote(profile)} '-c' 'check_for_update_on_startup=false' resume ${quote(token)}${queueDirArg}${gitDirOption(gitCwd)}`;
return `codex -p ${quote(profile)} '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume ${quote(token)}${queueDirArg}${gitDirOption(gitCwd)}`;
}
beforeEach(() => {
@@ -608,6 +608,15 @@ describe("Codex runtime adapter", () => {
expect(sendText).toHaveBeenCalledWith("r01-qa", expectedFreshLaunchCommand());
});
it("launch.codex_login_shell on keeps Codex's own login shells: the fresh launch has no non-login flag", async () => {
const tmux = mockTmux();
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), listProcesses: () => [], sleep: async () => {}, codexLoginShell: () => true });
const result = await adapter.launchHarness(makeBinding(), { name: "dev-qa@test-rig" });
expect(result.ok).toBe(true);
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
expect(sendText).toHaveBeenCalledWith("r01-qa", expectedFreshLaunchCommand().replace(" -c 'allow_login_shell=false'", ""));
});
it("launchHarness passes the requested Codex model on fresh launch", async () => {
const tmux = mockTmux();
const adapter = new CodexRuntimeAdapter({
@@ -983,7 +992,7 @@ describe("Codex runtime adapter", () => {
const result = await adapter.buildResumeCommand(binding, {
name: "dev-qa@test-rig", resumeToken: "sess-456",
running: { addDirs: ["/carried/git", "/carried/queue"], daemonOptOut: true, networkOverride: false },
running: { addDirs: ["/carried/git", "/carried/queue"], daemonOptOut: true, networkOverride: false, nonLoginShell: false },
});
expect(result.ok).toBe(true);
@@ -995,6 +1004,15 @@ describe("Codex runtime adapter", () => {
expect(verifyProfilePreflight).not.toHaveBeenCalled();
});
it("a relaunch from a running Codex adds the non-login shell flag only when that Codex ran with it", async () => {
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), fsOps: mockFs() });
const running = { addDirs: [], daemonOptOut: false, networkOverride: false };
const kept = await adapter.buildResumeCommand(makeBinding(), { name: "dev-qa@test-rig", resumeToken: "sess-456", running: { ...running, nonLoginShell: true } });
const unchanged = await adapter.buildResumeCommand(makeBinding(), { name: "dev-qa@test-rig", resumeToken: "sess-456", running: { ...running, nonLoginShell: false } });
expect(kept.ok && kept.core).toContain("-c 'allow_login_shell=false' resume 'sess-456'");
expect(unchanged.ok && unchanged.core).not.toContain("allow_login_shell");
});
it("launchHarness passes the disposable proof Codex model on fresh launch", async () => {
const tmux = mockTmux();
const adapter = new CodexRuntimeAdapter({
@@ -11,6 +11,7 @@ import type { ViewerPane } from "../src/domain/terminal/herdr-viewer.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
import { codexTwoRowComposer } from "./fixtures/codex-two-row-composer.js";
import { tileAttachArgv078, tileAttachArgv079 } from "../src/domain/terminal/earlier-tile-attach.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
@@ -417,3 +418,44 @@ it("keeps a real empty mission workspace enabled through Core with a mission wai
expect((await f.service.status()).following).toHaveLength(1);
} finally { await f.service.stop(); }
});
describe("follow views kept from an earlier release", () => {
const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'";
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"a writable follow view kept from %s still adds and closes tiles, and moves to the current script", async (_, make) => {
const f = fixture(); expect(await f.follow()).toMatchObject({ ok: true, opened: ["a", "b", "c"] });
// Rewrite the saved canvas and the viewer journal as that release left them.
const earlier = (seat: string) => make("/usr/bin/tmux", ["-S", "/tmp/follow-seats.sock"], seat, false);
const canvasFile = path.join(path.dirname(path.dirname(f.journal())), "canvas.json");
const canvas = JSON.parse(readFileSync(canvasFile, "utf8"));
for (const tile of canvas.tiles) { tile.target.attachArgv = earlier(tile.seat); tile.target.paneCommand = tile.target.attachArgv.map(quote).join(" "); }
writeFileSync(canvasFile, JSON.stringify(canvas));
const journal = JSON.parse(readFileSync(f.journal(), "utf8"));
for (const [id, entry] of Object.entries<{ argv: string[]; command: string }>(journal.entries)) {
entry.argv = earlier(JSON.parse(id)[1]); entry.command = entry.argv.map(quote).join(" ");
}
writeFileSync(f.journal(), JSON.stringify(journal));
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
await f.follow(["a", "b", "d"]); f.advance(4000);
expect(await f.follow(["a", "b", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "d"] });
expect(f.calls.filter(c => c[1] === "close")).toHaveLength(1);
const current = task(["a", "b", "d"]).opened;
expect(JSON.parse(readFileSync(canvasFile, "utf8")).tiles.map((t: { target: { paneCommand: string } }) => t.target.paneCommand))
.toEqual(current.map(t => t.paneCommand));
});
it("holds a kept follow view whose tile script only shares a tile script's opening, and leaves its saved tiles", async () => {
const f = fixture(); expect(await f.follow()).toMatchObject({ ok: true, opened: ["a", "b", "c"] });
const canvasFile = path.join(path.dirname(path.dirname(f.journal())), "canvas.json");
const canvas = JSON.parse(readFileSync(canvasFile, "utf8"));
const tile = canvas.tiles[0];
tile.target.attachArgv[2] = 'target=$1; shift; printf "%s\\n" "custom viewer";';
tile.target.paneCommand = tile.target.attachArgv.map(quote).join(" ");
writeFileSync(canvasFile, JSON.stringify(canvas));
const before = f.calls.length;
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" }, error: expect.stringContaining("current attachment differs") });
expect(f.calls.slice(before).some(c => ["create", "split", "run", "close"].includes(c[1]!))).toBe(false);
expect(JSON.parse(readFileSync(canvasFile, "utf8")).tiles[0].target).toEqual(tile.target);
});
});
+68
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { HerdrViewer, type HerdrViewerRun } from "../src/domain/terminal/herdr-viewer.js";
import { tileAttachArgv } from "../src/domain/terminal/tile-attach.js";
import { tileAttachArgv078, tileAttachArgv079 } from "../src/domain/terminal/earlier-tile-attach.js";
import { composeView } from "../src/domain/terminal/view-composer.js";
import { Hono } from "hono";
import { HerdrAdapter } from "../src/domain/terminal/herdr-adapter.js";
@@ -230,3 +231,70 @@ describe("owned Herdr viewer", () => {
expect((await send("Bearer fixture-owner-token")).status).toBe(200); expect(calls).toBe(1);
});
});
describe("viewers kept from an earlier release", () => {
const quote = (s: string) => "'" + s.replace(/'/g, "'\"'\"'") + "'";
const id = JSON.stringify([key.hostId, key.nodeId]);
const readOnlyTarget = () => composeView("qitem:qitem-root", [{ seat: "a@team", label: "a", tmuxSession: "a@team", host: null, alive: true, readOnly: true }],
{ resolveHost: () => null, localTmux: "/usr/bin/tmux", localTmuxServer: ["-S", "/tmp/own-tmux.sock"] }).opened[0]!;
/** Rewrite the journal as an earlier release left it: that release's script for a seat, with the same tmux and server. */
function keepFrom(f: ReturnType<typeof fixture>, make: typeof tileAttachArgv079, readOnly: boolean, session = "a@team") {
const file = path.join(f.root, readdirSync(f.root).find(n => n.endsWith(".json"))!);
const journal = JSON.parse(readFileSync(file, "utf8"));
const entry = journal.entries[id];
entry.argv = make("/usr/bin/tmux", ["-S", "/tmp/own-tmux.sock"], session, readOnly);
entry.command = entry.argv.map(quote).join(" "); entry.readOnly = readOnly;
writeFileSync(file, JSON.stringify(journal));
return () => JSON.parse(readFileSync(file, "utf8")).entries[id] as { command: string; argv: string[] };
}
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"keeps a writable viewer recorded by %s and moves it to the current script", async (_, make) => {
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
const stored = keepFrom(f, make, false);
expect(stored().command).not.toBe(f.target.paneCommand);
expect(await f.viewer().lookup(key, f.target)).toMatchObject({ ok: true });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
expect(stored()).toMatchObject({ command: f.target.paneCommand, argv: f.target.attachArgv });
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
});
it.each([["0.7.9", tileAttachArgv079], ["0.7.8", tileAttachArgv078]] as const)(
"keeps a read-only viewer recorded by %s", async (_, make) => {
const f = fixture(); f.setAttachFlags(["-r"]); const target = readOnlyTarget();
expect((await f.viewer().ensure(key, target)).ok).toBe(true);
keepFrom(f, make, true);
expect(await f.viewer().ensure(key, target)).toMatchObject({ ok: true, created: false });
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
});
it("still holds a kept viewer for another seat, for the other mode, or under a command that isn't a tile script", async () => {
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
keepFrom(f, tileAttachArgv079, false, "other@team");
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
keepFrom(f, tileAttachArgv079, true);
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
const stored = keepFrom(f, tileAttachArgv079, false);
const file = path.join(f.root, readdirSync(f.root).find(n => n.endsWith(".json"))!);
const journal = JSON.parse(readFileSync(file, "utf8"));
journal.entries[id].argv[2] = "exec /usr/bin/tmux attach"; journal.entries[id].command = journal.entries[id].argv.map(quote).join(" ");
writeFileSync(file, JSON.stringify(journal));
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
expect(stored().argv[2]).toBe("exec /usr/bin/tmux attach");
expect(f.calls.filter(a => a[1] === "create")).toHaveLength(1);
});
it("holds a kept viewer whose script only shares a tile script's opening, and leaves its saved command", async () => {
const f = fixture(); expect((await f.viewer().ensure(key, f.target)).ok).toBe(true);
const stored = keepFrom(f, tileAttachArgv079, false);
const file = path.join(f.root, readdirSync(f.root).find(n => n.endsWith(".json"))!);
const journal = JSON.parse(readFileSync(file, "utf8"));
journal.entries[id].argv[2] = 'target=$1; shift; printf "%s\\n" "custom viewer";';
journal.entries[id].command = journal.entries[id].argv.map(quote).join(" ");
writeFileSync(file, JSON.stringify(journal));
const before = stored();
expect(await f.viewer().lookup(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
expect(stored()).toEqual(before);
});
});
@@ -380,7 +380,7 @@ describe("Lifecycle reboot/recovery scenario matrix (Tier 1)", () => {
const r = await adapter.resume("r99-worker", "codex_id", "tok-abc", "/tmp");
expect(tmux.sendShellCommand).toHaveBeenCalledWith("r99-worker", "codex -s workspace-write '-c' 'check_for_update_on_startup=false' resume 'tok-abc'");
expect(tmux.sendShellCommand).toHaveBeenCalledWith("r99-worker", "codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume 'tok-abc'");
expect(tmux.sendText).not.toHaveBeenCalled();
expect(r).toEqual({
ok: true,
@@ -40,9 +40,9 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
it("codex adapter: the resume branch passes the model into buildCodexResumeCore", () => {
const src = read("adapters/codex-runtime-adapter.ts");
// The LAUNCH resume call threads binding.launchPosture, model, then the exact precomputed
// posture segment (followed by #275's network segment). The latter prevents W3 observation
// from re-deciding policy.
expect(src).toMatch(/buildCodexResumeCore\([^;]*binding\.launchPosture,\s*model,\s*(?:postureArg|`\$\{postureArg\}\$\{networkArg\}`)(?:,\s*daemonOptOut)?(?:,\s*effort)?\)/);
// posture segment (followed by #275's network segment and the non-login tool-shell segment). The latter
// prevents W3 observation from re-deciding policy.
expect(src).toMatch(/buildCodexResumeCore\([^;]*binding\.launchPosture,\s*model,\s*(?:postureArg|`\$\{postureArg\}\$\{networkArg\}(?:\$\{loginShellArg\})?`)(?:,\s*daemonOptOut)?(?:,\s*effort)?\)/);
});
it("claude adapter: every claude seat-launch template threads modelArg", () => {
@@ -207,7 +207,7 @@ describe("S03 selected arguments and unchanged defaults", () => {
});
it("Codex legacy resume uses the same explicit selection", async () => {
const t = refusedTransport(); await new CodexResumeAdapter(t.tmux).resume("seat", "codex_id", "original", "/inert", null, "full_bypass");
expect(t.send.mock.calls[0]?.[1]).toContain("codex -s danger-full-access -a never '-c' 'check_for_update_on_startup=false' resume");
expect(t.send.mock.calls[0]?.[1]).toContain("codex -s danger-full-access -a never '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' resume");
});
it("does not widen unset, floor, named-profile or environment-only behavior", () => {
expect(codexPostureArg("", {})).toBe(" -s workspace-write");
@@ -1028,7 +1028,13 @@ describe("#1196 round 1: launch choices come from the running Codex (LOW-1); ver
it("passes the running argv's network override, --no-daemon and add-dirs to the relaunch", async () => {
const w = world({ runningArgs: `--no-daemon -m gpt-5.5 -s workspace-write -c sandbox_workspace_write.network_access=true resume --add-dir /shared/state ${TOKEN}` });
await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(w.builds).toEqual([{ running: { networkOverride: true, daemonOptOut: true, addDirs: ["/shared/state"] } }]);
expect(w.builds).toEqual([{ running: { networkOverride: true, daemonOptOut: true, nonLoginShell: false, addDirs: ["/shared/state"] } }]);
});
it("reads the running Codex's non-login shell choice, so the relaunch keeps it", async () => {
const w = world({ runningArgs: `-s workspace-write -c allow_login_shell=false resume ${TOKEN}` });
await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(w.builds).toEqual([{ running: { networkOverride: false, daemonOptOut: false, nonLoginShell: true, addDirs: [] } }]);
});
it("an --add-dir holding a space survives whole: the relaunch reads the exact argv, not ps's whitespace split", async () => {
@@ -1036,7 +1042,7 @@ describe("#1196 round 1: launch choices come from the running Codex (LOW-1); ver
const argv = ["/usr/local/bin/codex", "-m", "gpt-5.5", "-s", "workspace-write", "resume", TOKEN, "--add-dir", "/home/u/my repo/.git"];
const w = world({ runningArgs: argv.slice(1).join(" "), runningArgv: argv, facts: { 200: facts({ argv }) } });
await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(w.builds).toEqual([{ running: { networkOverride: false, daemonOptOut: false, addDirs: ["/home/u/my repo/.git"] } }]);
expect(w.builds).toEqual([{ running: { networkOverride: false, daemonOptOut: false, nonLoginShell: false, addDirs: ["/home/u/my repo/.git"] } }]);
});
it("does not plan a restart when the saved token occurs only inside an argv option value", async () => {
@@ -1081,7 +1087,7 @@ describe("#1196 round 1: launch choices come from the running Codex (LOW-1); ver
});
const binding = { id: "", nodeId: "n", attachmentType: "tmux", tmuxSession: SEAT, tmuxWindow: null, tmuxPane: "%1",
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/work", codexConfigProfile: "prof", teamPermissionDefault: true } as NodeBinding;
const built = await adapter.buildResumeCommand(binding, { name: SEAT, resumeToken: TOKEN, running: { networkOverride: false, daemonOptOut: true, addDirs: ["/a b"] } });
const built = await adapter.buildResumeCommand(binding, { name: SEAT, resumeToken: TOKEN, running: { networkOverride: false, daemonOptOut: true, nonLoginShell: false, addDirs: ["/a b"] } });
expect(probes).toEqual([]);
expect(built).toMatchObject({ ok: true, core: expect.stringContaining("--no-daemon") });
if (built.ok) {
@@ -1120,7 +1126,7 @@ describe("#1196 round 2: the shell branch keeps the running settings (F1)", () =
const result = await w.service.restartInPlace({ seatRef: SEAT, reason: "x" });
expect(result).toMatchObject({ ok: true, outcome: "restarted_account_unverified", paneShape: "shell_backed" });
// Built once, before anything stopped; nothing is prepared again after Codex exits.
expect(w.builds).toEqual([{ running: { networkOverride: true, daemonOptOut: true, addDirs: ["/shared/one", "/shared/two"] } }]);
expect(w.builds).toEqual([{ running: { networkOverride: true, daemonOptOut: true, nonLoginShell: false, addDirs: ["/shared/one", "/shared/two"] } }]);
expect(w.launches).toHaveLength(1);
const typed = shellWords(w.launches[0]!.command);
expect(typed).toContain("--no-daemon");
@@ -492,8 +492,8 @@ describe("CodexRuntimeAdapter.launchHarness fork branch", () => {
expect(result.ok).toBe(true);
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
const sentCmd = sendText.mock.calls[0]?.[1] as string;
// The no-profile floor and managed update setting precede the fork subcommand.
expect(sentCmd).toMatch(/^codex -s workspace-write '-c' 'check_for_update_on_startup=false' fork/);
// The no-profile floor, managed update setting and non-login tool shells precede the fork subcommand.
expect(sentCmd).toMatch(/^codex -s workspace-write '-c' 'check_for_update_on_startup=false' -c 'allow_login_shell=false' fork/);
expect(sentCmd).toContain("PARENT-THREAD-ABC");
if (result.ok) {
expect(result.resumeToken).toBe("NEW-CODEX-THREAD-XYZ");
@@ -694,6 +694,40 @@ describe("SessionTransport", () => {
expect(callOrder).toEqual(["cancelPaneMode", "sendText", "sendKeys:Enter"]);
});
it("leaves the mode through the seat's address when a stale binding elsewhere names the same pane", async () => {
const { node } = seedCanonicalRig();
sessionRegistry.updateBinding(node.id, { tmuxPane: "%27" });
// tmux reuses pane ids after a server restart: another rig's binding can still name %27.
const studio = rigRepo.createRig("studio-build");
const stale = rigRepo.addNode(studio.id, "sdk.impl", { role: "worker", runtime: "claude-code" });
sessionRegistry.registerSession(stale.id, "sdk-impl@studio-build");
sessionRegistry.updateBinding(stale.id, { tmuxSession: "sdk-impl@studio-build", tmuxPane: "%27" });
// The guard can't resolve the raw pane id to one seat, so a write addressed to it is refused before any key.
expect(resolveGuardTarget(db, "%27")).toBeNull();
expect(resolveGuardTarget(db, "dev-impl@my-rig")).toMatchObject({ nodeId: node.id, pane: "%27" });
const callOrder: string[] = [];
const tmux = modalPane({ leaves: true, callOrder });
const modeReads: string[] = [];
let left = false;
tmux.isPaneInMode = vi.fn(async (target: string) => { modeReads.push(target); return !left; });
const cancelTargets: string[] = [];
// As the adapter's guarded write does: an unresolvable target writes nothing, so the pane stays in its mode.
tmux.cancelPaneMode = vi.fn(async (target: string) => {
cancelTargets.push(target); callOrder.push("cancelPaneMode");
if (!resolveGuardTarget(db, target)) return { ok: false as const, code: "guard_target_unknown", message: `Cannot establish managed input target ${target}; no input written.` };
left = true;
return { ok: true as const };
});
const transport = createTransport(tmux);
const result = await transport.send("dev-impl@my-rig", "hello");
// The person's harm first: the message is delivered and submitted, not refused as pane_in_mode.
expect(result).toMatchObject({ ok: true });
expect(callOrder).toEqual(["cancelPaneMode", "sendText", "sendKeys:Enter"]);
expect(cancelTargets).toEqual(["dev-impl@my-rig"]);
expect(modeReads.every(target => target === "%27")).toBe(true);
});
it("leaves the mode before a submitOnly Enter", async () => {
seedCanonicalRig();
const callOrder: string[] = [];
@@ -133,7 +133,9 @@ describe("config routes (User Settings v0)", () => {
// + 2 retention.stream_days / stream_max_items (slice 22 outcome 9 stream retention; default 30, 20000).
// + 1 specs.builtins_visible (show built-in library entries by default).
// + 1 stream.intake (issue #1856 queue intake routing; default queue.worker).
expect(Object.keys(body.settings).length).toBe(95);
// + 1 launch.codex_login_shell (defer to Codex's own login-shell setting; default off).
expect(Object.keys(body.settings).length).toBe(96);
expect(body.settings["launch.codex_login_shell"]).toMatchObject({ value: false, source: "default", defaultValue: false });
expect(body.settings["specs.builtins_visible"]).toMatchObject({ value: true, source: "default", defaultValue: true });
expect(body.settings["stream.intake"]).toMatchObject({ value: "queue.worker", source: "default", defaultValue: "queue.worker" });
expect(body.settings["retention.stream_days"]).toMatchObject({ value: 30, source: "default", defaultValue: 30 });
@@ -123,6 +123,7 @@ describe("SettingsStore (User Settings v0)", () => {
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist", "progress.scan_roots",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.10",
"private": true,
"license": "Apache-2.0",
"type": "module",