Compare commits

..
Author SHA1 Message Date
dev-driver 34f7c46087 docs(daemon): scope the draft-aware gap comments to the tested schedule
Two code-comment precisions on billbenetti's paste-side mitigation
(#3097), as its reviewers asked:
- seat-delivery-guard.ts: what happens after typing lands between the
  final check and the paste is stated for the tested append-mode
  schedule only, not as a guarantee.
- tmux.ts: the guard's checks run last, rather than its read being the
  last wait before the paste.

In the tested append-mode schedule, Enter is refused and nothing is submitted or deleted; this is no guarantee for every interleaving, and #3076 stays open.

This narrows #3097's merge message, which stated that outcome without the limit.
2026-11-13 02:41:16 +00:00
billbenetti f46bd7ec08 fix(daemon): re-check a seat's input right before an automatic paste (#3076) (#3097)
A mitigation of the delivery race in #3076: it narrows the window in which an automatic notice can be pasted into a draft a person is typing. Before, the draft-aware guard read the seat's input once, found it empty, and pasted; a person who started typing in between got the notice appended to their draft. Now, right before an automatic paste, the send's own waiting checks run first, then the guard holds when a writable client had activity in the last 3 s (session-wide tmux client activity, not proof of typing in this pane), re-reads the input after a 300 ms wait and holds unless it's still empty, and holds as draft_input_unknown when tmux can't be read. A held paste uses the existing retained handling, so a later attempt delivers once the input reads quiet and empty. An ordinary guarded paste now waits about 300 ms longer.

Typing after the guard's final check and before the paste is still not seen. The notice then lands beside the draft, the Enter is refused, nothing is submitted and nothing is deleted; a test pins that outcome. #3076 stays open for that window.

Thanks to billbenetti.
2026-11-12 18:39:26 -08:00
Mike Schwarz c411603c8c docs(changelog): 0.7.13's upgrade steps name the install (#3099)
0.7.13's "Before you upgrade" now names every step in order. It said to stop the daemon, back up the database and start it again, but never when to install the new version. The bullet now reads: back up the database, install 0.7.13, then restart the daemon, with rig daemon stop, a copy of the database file (rig config get db.path prints its path), installing OpenRig 0.7.13 with your existing installation method, and then rig daemon start. Text only.
2026-11-12 18:30:34 -08:00
Mike Schwarz 39f83a7904 fix(tui): retain connected check-ins when inventory fails (#3105)
Connected assistants keep their check-in lines visible in the TUI when a rig's seat inventory can't be read; the inventory error stays visible beside them. An unrecognized check-in status reads unknown, including names that are inherited JavaScript properties, since only a status's own keys are checked. It reuses the existing assistant renderer and adds no requests or dependencies.
2026-11-12 18:25:52 -08:00
27 changed files with 539 additions and 1113 deletions

No files matched your search

-181
View File
@@ -8,187 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.14]
OpenRig 0.7.14 is a patch for steadier circulation on a busy team. A burst of node
reads now takes turns, so a health check, a queue read or a send waits for one
step of it instead of the whole burst, and bounded queue listings over a long
retained history use indexes instead of scanning it. An assistant connected to
your team can reach a seat by the name its team shows, connected assistants
appear in `rig ps` and the TUI, and setup guidance tells the agent to fit a
first team to your logins. There are fixes across delivery, seats and the TUI,
many contributed by people using OpenRig. See Known limits for what this
release doesn't fix.
### Before you upgrade
- **Stop the daemon, back up the database, install 0.7.14, then start the
daemon.** Your agents keep running through a daemon restart; don't use
`rig down`, which ends them.
```sh
rig daemon stop
cp "$(rig config get db.path)" "$(rig config get db.path).before-0.7.14"
npm install -g @openrig/cli@0.7.14 # or your existing installation method
rig daemon start
```
- **Three migrations run before the daemon listens.** Migration 163 adds the
`network_installations` table and four columns to `network_bindings`.
Migrations 164 and 166 build indexes. The author's measurement of 166 took
3.47 s on 28,976 rows, not a real installation's size, so a large database can
take longer. The write overhead, and going back to 0.7.13 after these three
migrations, aren't established; keep the backup. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3008](https://github.com/mvschwarz/openrig/pull/3008), [#3090](https://github.com/mvschwarz/openrig/pull/3090))
- **If the start is reported pending:** the CLI waits 20 s, then may print
"Daemon startup is pending after the readiness deadline". Readiness remains
unconfirmed. It leaves the child running and the startup reservation
retained, without recording the daemon. Inspect the address and log it
prints. The child may later answer, and status can then report it running,
but that does not finish this start's recording or release its reservation.
Don't start it again until the steps below are done. The message's restart
steps stop that child and clear the reservation: stop the named child PID and
wait for it to exit, inspect `daemon-start.lock`, and only when both recorded
launcher and child PIDs are absent, archive the lock and run
`rig daemon start`. On a large database the new start can be reported pending
again; reaching a recorded start this way hasn't been tested.
`rig daemon stop` cannot stop that unpublished child.
- **The `openrig` plugin carries the CLI's version, 0.7.14; openrig-core stays
0.1.29.**
### Steadier under load
- Node reads (`rig ps --nodes`, the TUI, several agents at once) take turns,
one step at a time, so other reads and sends are no longer held behind a
whole burst. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
- Bounded queue listings over retained history use indexes for their ordering
and for live and archived owner notifications. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
- Codex seats re-checked in one readiness poll share process listings where
each seat's observation order and the 2 s freshness allow; each keeps its own
later observation and its own fallback. ([#3061](https://github.com/mvschwarz/openrig/pull/3061))
- New tmux servers raise their soft open-file limit to 8,192 or the hard limit
where they can, keeping an already higher limit. This makes descriptor
exhaustion less likely as panes and viewers add up; servers already running
keep their limits. ([#3070](https://github.com/mvschwarz/openrig/pull/3070))
- A delegation parent gets one standing notice per child seat for unclaimed
rows, not one per row, and held work gets its notice back when its typing
hold clears. ([#3049](https://github.com/mvschwarz/openrig/pull/3049), [#3082](https://github.com/mvschwarz/openrig/pull/3082))
### Your team and the people it works for
- An assistant connected over MCP reaches a seat by its plain name, stored
under the seat's actual session; an unknown name, or one several seats match,
says so. ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
- Connected assistants appear, with their check-in, in `rig ps`, `rig_team` and
the TUI's tables, and their person sees plain tool titles. ([#3057](https://github.com/mvschwarz/openrig/pull/3057), [#3058](https://github.com/mvschwarz/openrig/pull/3058), [#3092](https://github.com/mvschwarz/openrig/pull/3092))
- `rig doctor` and setup say a login is needed when a seat you start runs on
that harness. The operator is taught to check each seat's runtime against
your logins before launch, name any missing sign-in and let you choose.
([#3077](https://github.com/mvschwarz/openrig/pull/3077))
- When the readable person registry holds nobody, `rig daemon start` and
`rig gateway participants` tell the setup agent to offer registration. The
kernel operator is taught to include that offer in its greeting and register
only on your yes. ([#3041](https://github.com/mvschwarz/openrig/pull/3041), [#3071](https://github.com/mvschwarz/openrig/pull/3071))
- A team your agent hands work to knows how to ask you a question, and your
answer wakes it. A click on an ask with several questions records that
choice; the ask stays open until the last one. ([#3059](https://github.com/mvschwarz/openrig/pull/3059), [#3045](https://github.com/mvschwarz/openrig/pull/3045))
- A pairing request's description of itself is quoted wherever it's shown. ([#3067](https://github.com/mvschwarz/openrig/pull/3067))
- `rig queue list` takes exact `--tag`, `--mission` and `--slice` filters, and
several tags must all match. ([#3066](https://github.com/mvschwarz/openrig/pull/3066))
- A skill-only edition of the `openrig` plugin, for Claude's plugin directory,
follows handed-on work and keeps its reply route. ([#3056](https://github.com/mvschwarz/openrig/pull/3056), [#3075](https://github.com/mvschwarz/openrig/pull/3075))
### Fixes
- An ordinary send is refused as needing input while an open Claude Code or
Codex `/model` picker is positively identified; nothing is typed. Close the
picker before trying to deliver the message. ([#3083](https://github.com/mvschwarz/openrig/pull/3083))
- On a resume of a Codex seat's own conversation in a rig with pods, including
`rig up --existing`, OpenRig avoids adding missing guidance blocks to the
repository's tracked `AGENTS.md` when Codex is 0.164.0 or later, no guidance
file or developer instructions are configured, and the configuration and
block sources can be read. ([#3039](https://github.com/mvschwarz/openrig/pull/3039))
- With `recovery.auto_drive_provider_prompts` on, Claude Code's gated-grants
trust check is never answered for you, and the plain trust prompt is answered
"Yes". ([#3073](https://github.com/mvschwarz/openrig/pull/3073))
- Codex seats with linked `CODEX_HOME` folders or config files get hook trust
under the keys Codex reads. ([#3079](https://github.com/mvschwarz/openrig/pull/3079))
- `rig terminal open --provider herdr` reuses a live Herdr workspace, and Rex
tiles show the seat's agent and its activity. ([#3068](https://github.com/mvschwarz/openrig/pull/3068), [#2992](https://github.com/mvschwarz/openrig/pull/2992))
- `rig broadcast` prints a held recipient as retained, not sent. ([#3054](https://github.com/mvschwarz/openrig/pull/3054))
- Account erasure also removes OpenRig's own wakes and notices for that
account's seats. ([#3089](https://github.com/mvschwarz/openrig/pull/3089))
- A snapshot without sessions returns its `invalid_snapshot_data` blocker
instead of throwing a TypeError; the restore still stops there. A retried
no-launch import recovers the original rig by its operation ID. ([#3069](https://github.com/mvschwarz/openrig/pull/3069), [#3042](https://github.com/mvschwarz/openrig/pull/3042))
- A rebuilt briefing limits optional superseded recap paths to the newest
three by default when one folder covers that history; the current recap and
all artifact records remain. Other layouts keep the full list, and
`output.rebuild_recaps` set to 0 lists all paths. ([#3055](https://github.com/mvschwarz/openrig/pull/3055))
- `rig compact --state` names the input check that stopped a managed
compaction, and the TUI's graph shows a seat the daemon reads as unknown as
unknown. ([#3053](https://github.com/mvschwarz/openrig/pull/3053), [#3046](https://github.com/mvschwarz/openrig/pull/3046))
- Setting a context-pressure threshold explains when an environment override
inverts the pair, and `rig workflow validate` warns that harness pins aren't
checked against seats. ([#2567](https://github.com/mvschwarz/openrig/pull/2567), [#3102](https://github.com/mvschwarz/openrig/pull/3102))
- The config browser's entries say each setting's type, choices, range, format
and write route. ([#3050](https://github.com/mvschwarz/openrig/pull/3050))
### Experimental
- `rig network` is experimental, unsupported and at your own risk. Its connector
isn't inert: the daemon starts it, sweeps leftover credential folders and
schedules local rounds even with no binding. ([#3007](https://github.com/mvschwarz/openrig/pull/3007), [#3084](https://github.com/mvschwarz/openrig/pull/3084))
- A grant service can hold back only new work under its fair-use limit, and an
app can read what needs a person, and the answers to it, by link. ([#3048](https://github.com/mvschwarz/openrig/pull/3048), [#3008](https://github.com/mvschwarz/openrig/pull/3008))
### Behaviour that changed
- **Node reads:** a nodes response is no longer one snapshot across its steps,
and in a burst each nodes response comes later: about 43 ms against 21 ms
median in a 24-request burst. One very large rig is still one step. ([#3101](https://github.com/mvschwarz/openrig/pull/3101))
- **An assistant naming a seat now hears three errors:** `destination_ambiguous`
(several seats match; the candidates are listed), `destination_not_found` (no
seat it can reach has that name) and `destination_unchecked` (the team
couldn't be read; try again). ([#3088](https://github.com/mvschwarz/openrig/pull/3088))
### Known limits
- **Queue listings:** the new indexes aren't a cure for every stall. Listings of
active work showed no gain, one case was slower, and writes weren't measured. ([#3090](https://github.com/mvschwarz/openrig/pull/3090))
- **Draft-aware delivery ([#3076](https://github.com/mvschwarz/openrig/issues/3076)):** an automatic OpenRig notice can still be left beside
a draft started during delivery. In the reproduced paste-window cases the person's draft
stayed intact and no Enter was sent, but OpenRig's text stayed in the input.
Those cases don't cover a later window. On a conditional send, one that names
the conversation it expects, an isolated test with a scripted terminal showed
characters typed while OpenRig ran its last checks before Enter submitted
together with OpenRig's text, and the send reported success. The same test
reproduces it on 0.7.13. It hasn't been tested with a real client or model.
- **The TUI's tables** can drop a connected assistant's check-in line when its
rig's seat inventory can't be read; `rig roster` still prints it. ([#3105](https://github.com/mvschwarz/openrig/pull/3105) isn't in this release)
- **Repository guidance on restore:** rigs without pods, old or unknown Codex
versions, configured guidance and unreadable configuration keep the existing
file path. Fresh, fork and handover launches are outside this resume fix.
Existing managed blocks may still refresh, and the seat-skills line still
belongs to the file writer; check `git status`. A block whose source can't be
read is reported missing.
- **From 0.7.13:** [its known limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13)
still apply, except where this release fixes them. The runtime fixes cover
adding missing guidance on the eligible Codex resumes described above
([#3039](https://github.com/mvschwarz/openrig/pull/3039)), recognized model pickers ([#3083](https://github.com/mvschwarz/openrig/pull/3083)) and the unquoted
pairing description ([#3067](https://github.com/mvschwarz/openrig/pull/3067)).
This release also changes the operator-first registration ([#3041](https://github.com/mvschwarz/openrig/pull/3041),
[#3071](https://github.com/mvschwarz/openrig/pull/3071)) and pre-launch login guidance ([#3077](https://github.com/mvschwarz/openrig/pull/3077)); their observed
outcomes aren't established yet. They weren't re-checked for this release
unless named here.
### Credits
Thanks to @Kayhusk, @billbenetti, @cavalcanticampos, @Aummadour, @Tankard,
@w4rc0n and @Totopo27 for merged contributions. Thanks also for the reports
behind these fixes: @Kayhusk ([#3035](https://github.com/mvschwarz/openrig/issues/3035) and [#3078](https://github.com/mvschwarz/openrig/issues/3078)), @Tankard ([#2583](https://github.com/mvschwarz/openrig/issues/2583)),
@dajiaohuang ([#2529](https://github.com/mvschwarz/openrig/issues/2529)), @billbenetti ([#2728](https://github.com/mvschwarz/openrig/issues/2728)), @emilio-gagliardi ([#3087](https://github.com/mvschwarz/openrig/issues/3087)), @korallis
([#161](https://github.com/mvschwarz/openrig/issues/161)) and @w4rc0n ([#3017](https://github.com/mvschwarz/openrig/issues/3017)).
## [0.7.13]
OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/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.14` 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.12` 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.14/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.14/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/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.
+2 -3
View File
@@ -182,9 +182,8 @@ With `needs-me.read` supported:
about the open rows blocked on it (at most 100; empty when none are recorded).
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
already reflected in it. A click that records or changes an answer emits `queue.answer_recorded` even while the set
is partial. An identical repeated choice, or a later click after a pending ask's answer set is complete, records
nothing new and emits none. Events are best effort; re-read the snapshot or row after a stream gap.
already reflected in it. Recording one answer of several emits no event yet, so re-read the snapshot (or the row)
rather than relying on events alone for partial answers.
- **The answers returned to a request:** `GET /api/queue/<qitemId>` adds `answers`, oldest first, each
`{ qitemId, by, at, text, episode, recordedIdentity }`. They are found by the answer row's link to the request, not
by its text, so don't parse the `In reply to:` line in an answer's body. `text` is the person's answer without that
+4 -4
View File
@@ -185,10 +185,10 @@ agent doing the setup. Both use this release's tag:
```sh
# Claude Code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs" --skill rigs --agent claude-code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent claude-code
# Codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs" --skill rigs --agent codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent codex
```
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
@@ -203,11 +203,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.14/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.14/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/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
+2 -2
View File
@@ -12,9 +12,9 @@ In your project folder, add OpenRig's `rigs` skill to the agent you use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.14/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
```
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.14",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.14",
"version": "0.7.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7298,7 +7298,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.14",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7336,7 +7336,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.14",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7380,7 +7380,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.14",
"version": "0.7.1",
"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.14",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/acp",
"version": "0.7.14",
"version": "0.7.1",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.14"
"@openrig/cli": "0.7.1"
},
"license": "Apache-2.0",
"author": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.14",
"version": "0.7.1",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
@@ -12,7 +12,7 @@
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]4"
"package": "@openrig/[email protected]"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.14",
"version": "0.7.1",
"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": [
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.14",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.14",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.14",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"logo": "assets/logo.svg"
}
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "openrig",
"version": "0.7.14",
"version": "0.1.0",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns.",
"author": {
"name": "Mike Schwarz",
@@ -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-three top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.14`). 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.12`). 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`.
@@ -33,10 +33,7 @@ one command away. Your memory of the fleet is a claim about the past.
*including the broken ones*. **The bare form is the question you usually want:** `--active`
filters to running and **hides detached, exited and attention states** — which are exactly the
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it. Since 0.7.14 `rig ps` ends with connected
assistants and their check-in line, and node reads take turns through their three synchronous
steps, so other reads and sends aren't held behind a burst of them; a nodes response is no longer
one snapshot across those steps, and in a burst each one comes later.
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
@@ -112,8 +109,7 @@ one command away. Your memory of the fleet is a claim about the past.
Closed work's content and metadata can also be erased on a schedule
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
opt-in, and a retried create can't bring erased work back. Since 0.7.14 an account's erasure also
removes OpenRig's own wakes and notices for that account's seats.
opt-in, and a retried create can't bring erased work back.
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
@@ -128,9 +124,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
consequential delivery gets an effect check at the far end rather than a blind retry.
`--verify` exits 1 when it can't confirm what its Enter did (`submission unconfirmed`, or text
staged but not submitted): the message may still have arrived, so inspect with `rig capture`
before any resend, and don't let a script resend on that exit code alone. Since 0.7.14 an ordinary
send is refused as needing input while an open Claude Code or Codex `/model` picker is
positively identified; nothing is typed. A
before any resend, and don't let a script resend on that exit code alone. A
producer-link advisory reading `no_activity_signal` means activity could not be determined;
confirm by effect, never relaunch on it. A send or wake to a seat whose latest hook is a
question waiting for a person is refused with `target_needs_input`, even when its screen
@@ -181,8 +175,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
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
the wording wrong on the fourth. **Blast radius is real** — on a large rig this lands in every
seat's turn. A broadcast that reached no one says so, and a recipient a seat's setting held
reads as retained, not sent.
seat's turn. A broadcast that reached no one says so.
- **`rig capture <seat>`** — read what is on their screen right now. **`--rig` / `--pod`** captures
every seat in one call. `--lines N` is the last N lines (stderr says how many earlier lines were
cut); `--history-plus-pane` gives the older N history lines plus the whole pane. A short capture
@@ -262,7 +255,7 @@ unseen — which makes messages the one delivery channel that never gets skipped
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
wait for a phrase like “show me my agents”; the same command brings their agents back later. No
YAML, herdr when installed or plain tmux. Rex tiles show each seat's agent and its activity. The layout follows the terminal's width: on a narrow
YAML, herdr when installed or plain tmux. The layout follows the terminal's width: on a narrow
one the operator gets the first tab; on a wide one the dashboard and operator sit side by side
and the advisor gets its own tab. A `kernel` view the person saved still wins.
Tiles show their seat's screen without attaching, so whole lines show at any tile width; a
@@ -487,8 +480,7 @@ relay is a reason to inspect the available routing, not to surrender judgment to
- **`rig workflow validate`** — will this spec instantiate at all, before a run finds out for you. A
step with no allowed exit that can finish it or route onward is reported as `step_cannot_finish`.
An allowed mapped exit is valid; in a dependency graph, `handoff` can finish a sink without a next
step. Since 0.7.14 each step with a `harness:` pin gets a `harness_pin_unchecked` warning:
validation doesn't check pins against seats.
step.
- **`rig workflow compile <mission>`** / **`instantiate-lifecycle`** — derive one executable graph
from `project.yaml` → `mission.yaml` → `slice.yaml`, inspect it without writing, then start an
eligible graph with an opaque replay key. Typed acceptance candidate, verdict, and evidence
@@ -640,8 +632,6 @@ not have to tear one down to change it, and you rarely have to start from nothin
steps): hand it work, follow it, answer the team's questions in their own words, and read the
result. VS Code, Copilot CLI and Cursor load it with no seat: give the app its own
`rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and turn the plugin's off.
A skill-only edition for Claude's plugin directory, in `plugins/claude-directory/`, follows
handed-on work and keeps its reply route.
- **A Claude member can bring 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 own storage, never the project, so one seat in a shared repository can have its own
@@ -759,10 +749,7 @@ not have to tear one down to change it, and you rarely have to start from nothin
conversation continuity stays unverified. Both refuse while typing protection is on.
- **`rig unclaim` / `rig release`** — stop managing one adopted session, or all of them,
**without killing the process or the agent inside it**. Handing something back to its human is a
first-class move, not an abandonment. To stop an adopted session after unclaiming it, end its
process and its tmux session; closing only the viewing window detaches it. Since 0.7.13 a fresh
`rig launch` after `rig unclaim` ended the server's last session reopens the server (a released
session that's still running blocks it, as before).
first-class move, not an abandonment.
> **Two words that mean different things at different altitudes.** `unclaim` and `release` each
> exist both here — *stop managing a live session* — and in the queue — *put a work row back down*.
@@ -879,9 +866,7 @@ version, from checking the environment to a useful report to the OpenRig team.
effectful handover path.** Both forms perform the operation by default; pass `--dry-run`
to plan without changing the seat. Check the selected source and continuity evidence first. A
seat can be handed over again into its own pane, so a seat left half-handed-over recovers by
running the handover again. A handover whose successor was launched but not ready says it may be
running: look at the pane before retrying. A handover cleans up the retiring occupant's own claims
and jobs. A Claude seat started through a `#!` wrapper that runs Claude without
running the handover again. A Claude seat started through a `#!` wrapper that runs Claude without
`exec`, or through the installer's `~/.local/bin/claude` symlink, rebuilds with `--source
rebuild`; a failed rebuild leaves the resume target on the earlier conversation, so rebuild again
rather than relaunching it fresh.
@@ -972,8 +957,7 @@ version, from checking the environment to a useful report to the OpenRig team.
- **`rig compact-plan`** → **`rig compact`** — who is near the context wall, then act on it.
**Ordering matters: running `compact` without the plan is guessing which seat needed it.**
Managed compaction waits for the seat's restore map before compacting; `rig compact --state`,
`--cancel` and `--skip-map` inspect, stop or skip that wait; since 0.7.14 `--state` also names the
input check behind a `draft_input_unknown` stop (`inputReadFailure`). If preparation stops at an ordinary
`--cancel` and `--skip-map` inspect, stop or skip that wait. If preparation stops at an ordinary
approval, a manual `rig compact` keeps waiting for someone to answer it, within its 180-second
budget; OpenRig never answers it, and the request times out after 240 seconds. The `claude-compaction-restore`
skill covers writing and reading the map; `claude-compact-in-place` no longer ships. After a
@@ -1247,9 +1231,6 @@ reaching them is ordinary work rather than an escalation.
see which host you are acting on, and check a registered host is *actually* usable end to end.
`rig host edit <id>` corrects an entry (its id and transport stay fixed), and `rig host remove <id>` deletes
only the registry entry: credential files, the learned identity and the selection stay as they are.
- **`rig network`** — **experimental, unsupported and at your own risk.** It connects one local seat
to a hub you already have. The daemon starts the connector, sweeps leftover credential folders and
schedules local rounds even with no binding, so it isn't inert, and its migrations apply on upgrade.
- **A cross-host queue write that times out reads INDETERMINATE, not failed.** The CLI waits 15
seconds for the daemon's answer, and `rig queue create` with no answer reads the row back once by
its ID. Reconcile by that ID before any retry; don't send the write twice.
@@ -1294,11 +1275,6 @@ reaching them is ordinary work rather than an escalation.
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
stays the default, and a person's connector binding can be unset.
**Since 0.7.13 a person's runner answer gets a note back:** who decides and in what state, or that
the answer is kept and the tool hasn't run, and a runner waiting on it wakes on the decision. Read
that note before asking the person to answer again. The answer a receiving agent reads says what an
answer typed at the machine's terminal means, to act on a clear one with the request's own decision
command, and how to ask the person back, with a new ask to them, when it's unclear.
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
@@ -1437,8 +1413,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
listing** — unbound seats, accounts shared across several seats. A Codex seat showing its
usage-limit banner reads activity `unknown` with reason `usage_limit`, and keeps it while its
screen redraws (a banner with a curly apostrophe included); a Claude seat whose usage-limit message
states a reset time is parked until then, instead of being retried every few minutes; `provider signals` can show a provider-event signal for that seat's account that
screen redraws; `provider signals` can show a provider-event signal for that seat's account that
allows a switch decision. With `provider.usage_windows` on (off by default), `rig provider status`
shows Codex's windows and Claude's 5-hour and weekly windows from the seats' status lines, each
seat's configured account, and with `--all-hosts` or `--hosts <ids>` every registered machine's
@@ -1509,14 +1484,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
adds its one line to shared guidance files (`skills.router_line.enabled`).
Setup offers to register the person when none is registered, and a team started from the
person's own assistant fits their own logins, with a review before anything is committed. Since
0.7.13, `rig daemon start` ends with that offer when the person registry can be read and holds
nobody. The kernel operator's startup is also taught to run `rig gateway participants` before its
first greeting and offer registration when nobody is registered. The agent asks first and
registers only on the person's yes. Since 0.7.14 setup guidance tells the agent to fit a first
team to the person's logins: a login `rig doctor` reports missing matters when a seat you'd start
runs on that harness, so say which one before launch
(`rig specs preview <team> --kind rig --json` shows each seat's runtime).
person's own assistant fits their own logins, with a review before anything is committed.
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
reset, or stopped reporting entirely. The last one is a signal, not a gap.
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
@@ -1601,12 +1569,7 @@ your circumstances is configuration, and the ones that are not, another agent ca
Clients on MCP protocol revision 2026-07-28 and on the 2025 revisions share the same endpoint.
With a grant service (experimental and off by default; you serve it to the internet behind
your own tunnel), `--task-destination <member@rig>` names where a caller's `rig_queue_create`
goes when it gives no destination, and a seat's name alone resolves within the grant's team. Since
0.7.14 a plain name finds the seat's actual session (its own name, then its pod-member form, then
that name's slug, among seats the grant can address): an ambiguous name returns
`destination_ambiguous` with the candidates, an unknown one `destination_not_found`, and an
unreadable team `destination_unchecked`. Its person sees each tool's plain title, and under a
grant service's fair-use limit only new work is held back. A
goes when it gives no destination, and a seat's name alone resolves within the grant's team. A
grant that names a person adds `rig_question_list` and `rig_question_answer`, so that person
answers the team's questions from their assistant, recorded as their answer through that
connection.
@@ -1618,10 +1581,6 @@ your circumstances is configuration, and the ones that are not, another agent ca
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
2xx records only that the ring was accepted, not that anything woke. When work waits for a
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
A hosted member's roster entry shows whether it's checking in: its expected cadence and its last
check-in, read as checking in, overdue, not yet checked in or checking in on request, and unknown
when that can't be read. It's a status, never a gate on delivery. Since 0.7.14 `rig ps`,
`rig_team` and the TUI's tables list connected assistants with that check-in line.
**Internet-facing hosted members are experimental, unsupported, off by default and at your
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
@@ -1679,8 +1638,7 @@ database. It is one more set of primitives on a machine full of them.
original byte size and the exact expansion command. Read the complete original record with
`rig queue show <qitemId> --full --json`; a preview or a compact list is not the whole body.
Compact `rig queue list` rows carry `summary`; the fields they leave out are absent, not empty,
and `fieldsElided` names them. Since 0.7.14 `--tag` (repeatable; every tag must match),
`--mission` and `--slice` filter exactly, and bounded listings over retained history use indexes.
and `fieldsElided` names them.
Delivery status is separate from content: inspect its receipt before claiming it was consumed.
- **"What am I waiting on?"** — the queue row's `waiting` view derives its owner, exact blocker
and blocker owner, last meaningful change, activity confidence and next backstop from live
@@ -1718,6 +1676,6 @@ deliberate writing.
**`rig --help`**, then `rig <verb> --help`.
Ninety-three 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.14",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+27 -4
View File
@@ -494,7 +494,29 @@ export class TmuxAdapter {
if (probe.state !== "present") return false;
const panes = await this.listPanes(target.session);
return panes.length === 1 && panes[0]!.id === target.pane && await this.isPaneDead(target.pane);
});
}, target => this.lastClientActivity(target.session));
}
/** When a writable tmux client attached to this session last had activity (epoch ms, whole seconds), or null when no
* writable client is attached. tmux's client_activity is session-wide and covers more than text keys, so this is
* recent writable-client activity, not proof of typing in the pane. A read-only client (a viewer tile) can't type
* into the seat and doesn't count. An attach counts: tmux can't tell a key in the attach's second from the attach,
* so a delivery can be held briefly after one. A tmux failure or an unreadable row throws: the guard then holds
* the paste rather than take it for "no activity" (#3076). */
async lastClientActivity(session: string): Promise<number | null> {
const exact = exactTarget(session, "session");
const format = `#{client_readonly}${TMUX_FIELD_SEPARATOR}#{client_activity}`;
const output = await this.run(["tmux", "list-clients", "-t", exact, "-F", format],
`tmux list-clients -t ${shellQuote(exact)} -F "${format}"`);
let latest: number | null = null;
for (const line of output.split("\n").map(row => row.trim()).filter(row => row !== "")) {
const [readonly, activity, ...rest] = line.split(TMUX_FIELD_SEPARATOR);
if (rest.length || !/^[01]$/.test(readonly ?? "") || !/^[1-9]\d*$/.test(activity ?? "")) {
throw new Error(`tmux list-clients: unreadable client row for ${session}`);
}
if (readonly === "0") latest = Math.max(latest ?? 0, Number(activity) * 1000);
}
return latest;
}
private readonly freshProbes = new Map<string, string>();
private readonly freshManaged = new Map<string, {nodeId: string; pane: string}>();
@@ -1081,12 +1103,13 @@ export class TmuxAdapter {
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
// checks pass; they then aren't run a second time.
beforePaste: async () => {
let checked = false;
const checks = async () => { await options?.beforePaste?.(); beforeWrite(); beforeInput?.(); checked = true; };
// This send's own checks can wait (a conversation lookup, an input capture), so they run first, and the
// guard's checks run last (#3076). A check that refuses leaves a leftover's record for the next delivery.
await options?.beforePaste?.();
const checks = async () => { beforeWrite(); beforeInput?.(); };
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
const enter = async () => { beforeWrite(); beforeInput?.(); return (await this.sendKeysUnchecked(pane, ["Enter"])).ok; };
if (checkDraft) await guard!.beforeInput(identity, false, text, { checks, enter });
if (!checked) await options?.beforePaste?.();
},
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
@@ -60,6 +60,15 @@ export interface TypingGuardConfig extends TypingGuardSettings { revision: strin
const DEFAULT_GUARD: TypingGuardConfig = { mode: "off", revision: "legacy-off" };
/** How long the guard waits before reading again an input it couldn't read right after its own paste or Enter (#2429). */
const OWN_INPUT_REREAD_MS = 300;
/** Right before an automatic paste, the empty input is read once more after this wait: a draft typed (or redrawn by the
* agent's TUI) since the first read is seen before the paste, not after it, where nothing is removed (#2447, #3076). */
const PASTE_SETTLE_MS = 300;
/** Recent activity at a writable tmux client of the seat (keys, but also other client input; session-wide, not proof of
* typing in this pane) holds the paste: a later attempt can deliver once it reads quiet and the input empty. tmux
* reports client activity in whole seconds, so this is three of them (#3076). */
const INPUT_QUIET_MS = 3000;
/** tmux's client_activity is in whole seconds; the activity may have come up to this much after the second it reports. */
const CLIENT_ACTIVITY_PRECISION_MS = 999;
/** A terminal member with nothing set holds OpenRig's own notices and takes deliberate sends. */
const TERMINAL_DEFAULT: TypingGuardConfig = { mode: "off", level: "sends-only", revision: "terminal-default" };
/** The actor a guard change records when its request carries no seat identity, as from an ordinary terminal (#2350).
@@ -122,6 +131,9 @@ export class SeatDeliveryGuard {
/** A paste this guard kept from being submitted because it couldn't read the input, per node (#2429). */
private readonly leftovers = new Map<string, { target: GuardTarget; humanEpoch: number; text: string; collapsedPaste: string | null }>();
private inspectInput?: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>;
/** When a writable tmux client of the seat last had activity (epoch ms, whole seconds); null when no writable client
* is attached; a rejection when that can't be known. */
private inputActivity?: (target: GuardTarget) => Promise<number | null>;
private inputAbsent?: (target: GuardTarget) => Promise<boolean>;
private readonly hasModeConfig: boolean;
@@ -133,9 +145,11 @@ export class SeatDeliveryGuard {
this.hasModeConfig = (db.prepare("PRAGMA table_info(seat_delivery_guards)").all() as Array<{ name: string }>).some(c => c.name === "desired_config");
}
attachInputInspection(inspect: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>, absent: (target: GuardTarget) => Promise<boolean>): void {
attachInputInspection(inspect: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>, absent: (target: GuardTarget) => Promise<boolean>,
activity?: (target: GuardTarget) => Promise<number | null>): void {
this.inspectInput = inspect;
this.inputAbsent = absent;
this.inputActivity = activity;
}
/** Startup-only, before exposing routes or starting writers. A stopped operation cannot
@@ -483,6 +497,9 @@ export class SeatDeliveryGuard {
} else if (input.state !== "empty") {
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
} else {
// Right before the paste (the adapter's check passes the text): the read above is a snapshot, and a draft typed
// after it would get the paste appended and then lose the Enter, with the paste left in the person's input (#3076).
if (pasteText !== undefined) input = await this.quietBeforePaste(target, read, unknown);
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
if (uncertainty) {
const message = uncertainty === "controls"
@@ -496,6 +513,31 @@ export class SeatDeliveryGuard {
}
}
/** No recent writable-client activity before a short wait, the input still empty when read again after it, and still
* no recent activity after that read. A time tmux can't report holds the paste, as an unreadable input does. The
* read it returns is the one the paste goes on. Typing after this final check and before the paste can still leave
* the notice beside the person's draft. In the tested append-mode schedule the Enter is then refused and nothing is
* submitted or deleted; that isn't a guarantee for every interleaving (#3076). */
private async quietBeforePaste(target: GuardTarget, read: () => Promise<ComposerInput | null>,
unknown: (reading: ComposerInput | null) => DeliveryGuardError): Promise<ComposerInput> {
const quiet = async () => {
if (!this.inputActivity) return;
const last = await this.inputActivity(target).then(at => ({ at }), () => null);
if (!last) throw new DeliveryGuardError("draft_input_unknown", "OpenRig couldn't read the recent activity of the seat's terminal clients. Automatic delivery is held; nothing was pasted.");
// tmux reports whole seconds: activity at N.999 s reads as N, so the quiet window counts from the end of that second.
if (last.at !== null && Date.now() - (last.at + CLIENT_ACTIVITY_PRECISION_MS) < INPUT_QUIET_MS) {
throw new DeliveryGuardError("draft_input_busy", "A writable client of this seat had recent activity. Automatic delivery is held; nothing was pasted.");
}
};
await quiet();
await this.sleep(PASTE_SETTLE_MS);
const again = await read();
if (!again || again.state === "unknown") throw unknown(again);
if (again.state !== "empty") throw new DeliveryGuardError("draft_input_busy", "The input changed just before the paste. Automatic delivery is held; nothing was pasted.");
await quiet();
return again;
}
/** A leftover paste is OpenRig's only while it's exactly there: the same occupant, no person's input through OpenRig
* since, and its text or the label recorded for it. A label first seen later proves nothing (#1871). */
private ownsLeftover(target: GuardTarget, input: ComposerInput): boolean {
@@ -32,7 +32,7 @@ function signal() {
return { ready: new Promise<void>(resolve => { release = resolve; }), release: () => release() };
}
function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean } = {}) {
function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean; appendPaste?: boolean } = {}) {
const db = new Database(saved ?? ":memory:");
if (!saved) {
migrate(db, ALL_MIGRATIONS);
@@ -42,23 +42,27 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('sa','a','worker@test','running'),('sb','b','sibling@test','running');
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('ga','a',1,'g1','fresh'),('gb','b',1,'g2','fresh');`);
}
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void>;
settle?: (ms: number) => void | Promise<void>; clients?: () => string } = {};
// The guard's own waits (its re-reads) go to `settle`, kept apart from the transport's `sleep`; none is real time.
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name), async ms => { await hooks.settle?.(ms); });
guard.recoverActivation();
const bodies: Record<string, string | null> = { a: "", b: "" };
const history: Record<string, string> = { a: "", b: "" };
const writes: string[] = [], submissions: string[] = [];
const observations: unknown[] = [];
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void> } = {};
let staged = "";
// A terminal pane shows a submitted line as typed; an agent pane echoes a sent message under its prompt, as Claude does.
const echo = (node: string, body: string) => (db.prepare("SELECT runtime FROM nodes WHERE id=?").get(node) as { runtime: string }).runtime === "terminal"
? `${body}\n` : `${body.split("\n").map((row, i) => (i ? " " : "❯ ") + row).join("\n")}\n`;
const tmux = new TmuxAdapter(async command => {
if (command.includes("list-clients")) return hooks.clients?.() ?? "";
if (command.includes("load-buffer")) await hooks.load?.();
const node = command.includes("%2") ? "b" : "a";
if (command.includes("paste-buffer")) {
await hooks.paste?.();
writes.push(`paste:${node}:${staged}`); bodies[node] = staged;
// appendPaste: a paste lands after whatever the input already holds, as in a real composer (#3076 tests).
writes.push(`paste:${node}:${staged}`); bodies[node] = opts.appendPaste ? (bodies[node] ?? "") + staged : staged;
}
if (command.includes("send-keys")) {
writes.push(`keys:${node}:${command}`);
@@ -1156,3 +1160,158 @@ describe("draft-aware at a full held quota (#2430)", () => {
expect(f.outbox.retentionUsage("a").count).toBe(100);
});
});
describe("#3076: a draft typed between the guard's read and the paste", () => {
const draft = "Please send it to the team. Also";
const notice = "[OpenRig watchdog scheduler · policy: periodic-reminder · job: j]\nWake timer fired.";
const pasted = (f: ReturnType<typeof fixture>) => f.writes.filter(w => w.startsWith("paste:"));
const setActivity = (f: ReturnType<typeof fixture>, at: () => number | null) => {
vi.spyOn(f.tmux, "lastClientActivity").mockImplementation(async () => at());
};
it("is seen by the re-read after the settle wait: the notice is held and nothing is pasted into the person's input", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const waits: number[] = [];
// The person starts typing during the guard's wait right before the paste (the first read saw an empty input).
f.hooks.settle = ms => { waits.push(ms); if (!pasted(f).length) f.bodies.a = draft; };
const result = await f.send("watchdog-wake", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(waits[0]).toBe(300);
expect(pasted(f)).toEqual([]);
expect(f.submissions).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("holds on recent writable-client activity, also activity during the wait, and a later attempt delivers once quiet", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last: number | null = Date.now() - 1000;
setActivity(f, () => last);
const busy = await f.send("wake-1", "Wake timer fired.", { openrigNotice: true, verify: false });
expect(busy).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
last = Date.now() - 60_000;
f.hooks.settle = () => { last = Date.now(); }; // activity during the settle wait, nothing drawn yet
f.advance();
const during = await f.send("wake-2", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(during).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
last = Date.now() - 60_000;
f.hooks.settle = undefined;
f.advance();
const sent = await f.send("wake-3", "Wake timer fired a third time.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
expect(f.submissions).toHaveLength(1);
});
it("counts the quiet window from the end of the whole second tmux reports: 3.5 s ago still holds, 4.5 s ago delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last = Date.now() - 3500; // activity at N.999 s reads as N: it may have been 2.5 s ago
setActivity(f, () => last);
const held = await f.send("whole-second", "Wake timer fired.", { openrigNotice: true, verify: false });
expect(held).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
last = Date.now() - 4500;
f.advance();
const sent = await f.send("whole-second-later", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("holds as unreadable when tmux can't report recent writable-client activity; with no writable client it delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
vi.spyOn(f.tmux, "lastClientActivity").mockRejectedValue(new Error("command timed out"));
const held = await f.send("activity-unreadable", notice, { openrigNotice: true, verify: false });
expect(held).toMatchObject({ sent: false, reason: "draft_input_unknown" });
expect(pasted(f)).toEqual([]);
setActivity(f, () => null);
f.advance();
const sent = await f.send("no-client", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("holds as unreadable when the input can't be read after the wait", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
f.hooks.settle = () => { if (!pasted(f).length) f.bodies.a = null; };
const result = await f.send("unreadable", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ sent: false, reason: "draft_input_unknown" });
expect(pasted(f)).toEqual([]);
});
it("runs the send's own waiting checks before the guard's last read, so a draft typed while they wait is seen", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
// A conditional send's checks await (a conversation lookup); the person types meanwhile. The adapter returns the
// guard's refusal as its result.
const result = await f.guard.operation("worker@test", () =>
f.tmux.sendText("worker@test", notice, undefined, { beforePaste: async () => { f.bodies.a = draft; } }));
expect(result).toMatchObject({ ok: false, code: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("applies to every automatic input under draft-aware, a deliberate send included, as the empty check already did", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
f.hooks.settle = () => { if (!pasted(f).length) f.bodies.a = draft; };
const result = await f.send("deliberate", "a message from another seat", { verify: false });
expect(result).toMatchObject({ sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("the remaining gap: typing after the final check and before the paste leaves the notice beside the draft, never submitted or deleted", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let typed = false;
// The person types between the guard's last check and tmux's paste: the paste lands after their text.
f.hooks.paste = () => { if (!typed) { typed = true; f.bodies.a = draft; } };
const result = await f.send("final-boundary", notice, { openrigNotice: true, verify: false });
// Contamination: exactly one paste, and the input is the person's text followed by it, nothing removed.
expect(pasted(f)).toHaveLength(1);
const staged = pasted(f)[0]!.slice("paste:a:".length);
expect(f.bodies.a).toBe(draft + staged);
// Submission: none. No key at all reached the pane (no Enter, C-m or C-u), and the guard says why.
expect(f.writes.filter(w => w.startsWith("keys:a"))).toEqual([]);
expect(f.submissions).toEqual([]);
expect(result).toMatchObject({ ok: false });
expect(JSON.stringify(result)).toContain("draft_input_changed");
});
it("checks the activity again after the final read: activity that appears at that read holds the paste", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last: number | null = Date.now() - 60_000;
setActivity(f, () => last);
const real = f.tmux.captureComposerSnapshot.getMockImplementation()!;
let armed = false;
f.hooks.settle = () => { armed = true; };
// The composer still reads empty at the final read, but a writable client has activity right then.
vi.spyOn(f.tmux, "captureComposerSnapshot").mockImplementation(async (name: string) => {
const shot = await real(name);
if (armed) { armed = false; last = Date.now(); }
return shot;
});
const result = await f.send("activity-at-final-read", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
});
it("reads tmux's real list-clients output end to end: an attach in this second holds, a quiet client delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const now = () => Math.floor(Date.now() / 1000);
f.hooks.clients = () => `1|${now()}\n0|${now()}\n`; // a viewer tile and a writable client attached this second
const held = await f.send("attach-now", notice, { openrigNotice: true, verify: false });
expect(held).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
f.hooks.clients = () => `1|${now()}\n0|${now() - 10}\n`; // only the read-only tile is active
f.advance();
const sent = await f.send("attach-quiet", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
});
+41
View File
@@ -51,6 +51,47 @@ describe("TmuxAdapter.listSessionClientTerminals", () => {
});
});
describe("TmuxAdapter.lastClientActivity (#3076)", () => {
const activity = (stdout: string) => adapterFor(mockExec({ "list-clients": { stdout } })).lastClientActivity("op@kernel");
it("is the latest activity of a writable client, in milliseconds", async () => {
expect(await activity("0|1790000100\n0|1790000105\n")).toBe(1790000105000);
});
it("does not count a read-only client (a viewer tile)", async () => {
expect(await activity("1|1790000200\n0|1790000100\n")).toBe(1790000100000);
expect(await activity("1|1790000200\n")).toBeNull();
});
it("counts an attach as recent activity: a key in the same second as the attach can't be told apart from it", async () => {
// tmux would report client_created == client_activity for both an attach alone and an attach plus a key that second.
expect(await activity("0|1790000300\n")).toBe(1790000300000);
});
it("is null with no client", async () => {
expect(await activity("")).toBeNull();
expect(await activity("\n")).toBeNull();
});
it("throws on a row it can't read, alone or next to a readable one, so the guard holds", async () => {
for (const stdout of ["0|abc\n", "0|0\n", "0\n", "2|1790000100\n", "0|1790000000|1790000100\n", "0|1790000100\n0|abc\n"]) {
await expect(activity(stdout)).rejects.toThrow();
}
});
it("throws when tmux can't say", async () => {
for (const error of [new Error("can't find session: op@kernel"), NO_SERVER_ERROR, new Error("command timed out")]) {
await expect(adapterFor(mockExec({ "list-clients": { error } })).lastClientActivity("op@kernel")).rejects.toThrow();
}
});
it("asks tmux for the read-only flag and the activity of each client of the session", async () => {
const commands: string[] = [];
await adapterFor(async command => { commands.push(command); return ""; }).lastClientActivity("op@kernel");
expect(commands.some(c => c.includes("list-clients") && c.includes("#{client_readonly}|#{client_activity}"))).toBe(true);
});
});
describe("TmuxAdapter", () => {
it("starts only an empty terminal server and reconciles repeated requests", async () => {
let live = false;
@@ -158,6 +158,7 @@ async function driveEveryMethod(): Promise<RunCall[]> {
await a.readPaneLastActivity(SESSION);
await a.listClients();
await a.listSessionClientTerminals(SESSION);
await a.lastClientActivity(SESSION); // #3076: the client activity read right before an automatic paste
await a.switchClient("/dev/ttys001", SESSION);
// A runtime switch's exchange: two session-id reads, then the guarded if-shell swap (nested quoting); and its env writes.
await a.swapRuntimeSwitchPanes({ nodeId: "n1", stagingSession: "staging'q a", canonicalSession: SESSION, canonicalPane: "%1", stagedPane: "%1" });
+1 -1
View File
@@ -10,7 +10,7 @@ export function toConnectedAssistants(body: unknown): ConnectedAssistant[] | nul
if (!Array.isArray(members)) return null;
return members.flatMap((member: Record<string, unknown>) => {
if (typeof member?.seat !== "string" || !member.seat) return [];
const known = typeof member.status === "string" && member.status in CHECK_IN_DISPLAY;
const known = typeof member.status === "string" && Object.hasOwn(CHECK_IN_DISPLAY, member.status);
return [{ seat: member.seat, checkIn: {
status: known ? member.status as CheckInStatus : "unknown",
lastCheckInAt: typeof member.lastCheckInAt === "string" ? member.lastCheckInAt : null,
+5 -1
View File
@@ -958,7 +958,11 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
{ text: `Its seats aren't read in this view: rig ps --host ${rig.remoteHost} --nodes --rig ${rig.name}` },
];
if (rig.inventoryNotLoaded) return [{ text: `Reading ${rig.name}…` }];
if (rig.inventoryUnavailable) return [{ text: `Inventory unavailable for ${rig.name} · refresh to Retry` }];
if (rig.inventoryUnavailable) return [
{ text: `Inventory unavailable for ${rig.name} · refresh to Retry` },
...(leaf?.kind === "pod" ? [] : connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable,
rig.name, state.filter, contentWidth, state.timeZone)),
];
const podFilter = leaf?.kind === "pod" ? leaf.name : null;
const all = rig.pods.flatMap((p) => p.agents.map((a) => ({ pod: p.name, ...a })));
const rows = all
+28
View File
@@ -782,6 +782,34 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(instanceLines).not.toContain("not shown");
});
it("shows inherited-property check-in statuses as unknown", async () => {
const hosted = { members: ["constructor", "toString"].map((status, i) => ({
seat: `guest${i}@myrig`, status, reason: "unrecognized reason",
})) };
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/queue/hosted-members": hosted }));
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const lines = renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(lines).toContain("guest0@myrig unknown");
expect(lines).toContain("guest1@myrig unknown");
expect(lines).not.toContain("unrecognized reason");
});
it("shows connected check-ins beside a failed rig nodes read", async () => {
const hosted = { members: [
{ seat: "chat@myrig", status: "on-request", lastCheckInAt: null },
{ seat: "other@downrig", status: "checking-in", lastCheckInAt: null },
] };
const snap = await hydrateSnapshot(fixtureClient({ "/api/rigs/01JRIG/nodes": { status: 500 } },
{ "/api/queue/hosted-members": hosted }));
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const lines = renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(lines).toContain("Inventory unavailable for myrig");
expect(lines).toContain("chat@myrig on request:");
expect(lines).not.toContain("other@downrig");
});
it("a known-empty list of connected assistants stays quiet", async () => {
const snap = await hydrateSnapshot(fixtureClient());
expect(snap.hosts[0]!.connectedAssistants).toEqual([]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.14",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -1,14 +1,14 @@
{
"release": "0.7.13",
"archiveSha256": "534e9d5d626368e585d1ba19df20df7d97577153aabef61b9fca68a05edc86e8",
"release": "0.7.12",
"archiveSha256": "e90b2487fb744d7ca69e52e13b2aaef2a204fcfa76db00c373248acc0795eb52",
"plugins": {
"openrig": {
"version": "0.7.13",
"sha256": "62a5c308ec64b77d678b4276fc81dce933a7ef610da93790bc6a30a3dc3be990"
"version": "0.1.0",
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
},
"openrig-core": {
"version": "0.1.29",
"sha256": "5e08deffe33feed6f7beff6df39d70402248632f87e3b63f074835b400fff54d"
"version": "0.1.28",
"sha256": "59b2fb34be931a423583283696c22034fa417530ea682268a68156a70730fb5f"
}
}
}